From 50448c7031363608ebfc7303bd02324efb182a4f Mon Sep 17 00:00:00 2001 From: xcomcoopdev Date: Tue, 1 Sep 2026 15:40:36 +0300 Subject: [PATCH 1/3] Fix joining a saved skirmish battle --- src/Battlescape/NextTurnState.cpp | 14 +- src/CoopMod/HostMenu.cpp | 18 +- src/CoopMod/LobbyMenu.cpp | 79 +++++- src/CoopMod/LobbyMenu.h | 2 + src/CoopMod/TestServer.cpp | 11 + src/CoopMod/connectionTCP.cpp | 82 +++++- src/CoopMod/connectionTCP.h | 12 + src/Menu/LoadGameState.cpp | 23 +- src/Menu/PauseState.cpp | 16 +- tools/coop_test/test_pvp_skirmish_end_turn.py | 33 +++ .../test_skirmish_saved_battle_join.py | 239 ++++++++++++++++++ 11 files changed, 507 insertions(+), 22 deletions(-) create mode 100644 tools/coop_test/test_skirmish_saved_battle_join.py diff --git a/src/Battlescape/NextTurnState.cpp b/src/Battlescape/NextTurnState.cpp index 4d3e2ab62..df7c264e8 100644 --- a/src/Battlescape/NextTurnState.cpp +++ b/src/Battlescape/NextTurnState.cpp @@ -622,7 +622,19 @@ void NextTurnState::close() // Abort is a separate, explicitly host-announced vote (finishBattle(true), which // is NOT gated here and still crosses on the client). const bool coopHostAuthoritativeEnd = connectionTCP::parallelTurnActive() && !_game->getCoopMod()->getHost(); - if (!coopHostAuthoritativeEnd && ((!killingAllAliensIsNotEnough && tally.liveAliens == 0) || tally.liveSoldiers == 0)) + // PvP has its own seat-based elimination check in + // BattlescapeState::btnEndTurnClick. The ordinary mission tally cannot be + // used here: each PvP process presents its locally controlled side as the + // player faction, so after the alien half of a round tallyUnits() may report + // zero soldiers or zero aliens even though both network seats still have + // living units. That used to end a resumed gm2/gm3 battle as soon as the + // Next Turn screen was closed. Campaign and Custom Battle PvP now share the + // same authoritative seat-based end path. + const int coopGamemode = _game->getCoopMod()->getCoopGamemode(); + const bool coopPvpBattle = _game->getCoopMod()->getCoopStatic() + && (coopGamemode == 2 || coopGamemode == 3); + if (!coopHostAuthoritativeEnd && !coopPvpBattle + && ((!killingAllAliensIsNotEnough && tally.liveAliens == 0) || tally.liveSoldiers == 0)) { _state->finishBattle(false, tally.liveSoldiers); } diff --git a/src/CoopMod/HostMenu.cpp b/src/CoopMod/HostMenu.cpp index 15f4e726e..80c494d48 100644 --- a/src/CoopMod/HostMenu.cpp +++ b/src/CoopMod/HostMenu.cpp @@ -666,7 +666,17 @@ void HostMenu::hostTCPGame(Action* action) } - connectionTCP::_coopGamemode = 1; + // A resume keeps the mode serialized in the save (campaign and Custom + // Battle alike). Resetting a loaded PvP skirmish to 1 here ran the PVE unit + // conversion and changed both ownership and turn semantics before streaming. + const bool preserveLoadedMode = + (_game->getCoopMod()->inCoopCampaignContext() + && connectionTCP::session.lobbyMode == 2) + || connectionTCP::session.customBattleResumePending; + if (!preserveLoadedMode) + { + connectionTCP::_coopGamemode = 1; + } _game->getCoopMod()->setCoopSession(false); @@ -701,7 +711,8 @@ void HostMenu::hostTCPGame(Action* action) } - if (_game->getCoopMod()->getCoopCampaign() == true) + if (_game->getCoopMod()->getCoopCampaign() == true + || connectionTCP::session.customBattleResumePending) { convert = false; } @@ -765,7 +776,8 @@ void HostMenu::hostTCPGame(Action* action) _game->getCoopMod()->setServerOwner(true); // If the player has created a server or joined another player's game, close the ServerList and create the LobbyMenu - if (_game->getCoopMod()->getCoopCampaign() == true) + if (_game->getCoopMod()->getCoopCampaign() == true + || connectionTCP::session.customBattleResumePending) { _game->popState(); diff --git a/src/CoopMod/LobbyMenu.cpp b/src/CoopMod/LobbyMenu.cpp index 4b8bcf47e..3d7c4a9bd 100644 --- a/src/CoopMod/LobbyMenu.cpp +++ b/src/CoopMod/LobbyMenu.cpp @@ -21,6 +21,7 @@ #include "LobbyMenu.h" #include "../Engine/Logger.h" #include "../Savegame/SavedGame.h" +#include "../Savegame/SavedBattleGame.h" #include "../Engine/Game.h" #include "../Engine/Action.h" #include "../Engine/Exception.h" @@ -143,7 +144,8 @@ LobbyMenu::LobbyMenu() : _sortable(true) // reconnect are never mode 2, so they are unaffected. _resumeToGame = (battleRunning || (geoRunning && connectionTCP::session.sessionLocked)) - && connectionTCP::session.lobbyMode != 2; + && connectionTCP::session.lobbyMode != 2 + && !connectionTCP::session.customBattleResumePending; connectionTCP::session.markLobbyOpen(); @@ -244,7 +246,8 @@ LobbyMenu::LobbyMenu() : _sortable(true) { if (_game->getCoopMod()->getServerOwner() == true) { - _btnCancel->setText("BATTLE SETTINGS"); + _btnCancel->setText(connectionTCP::session.customBattleResumePending + ? "CONTINUE BATTLE" : "BATTLE SETTINGS"); _btnCancel->setVisible(false); // shown once the peer is in (think()) } else @@ -877,7 +880,14 @@ void LobbyMenu::btnCancelClick(Action*) { if (startEligible()) { - openBattleSettings(); + if (connectionTCP::session.customBattleResumePending) + { + continueCustomBattle(); + } + else + { + openBattleSettings(); + } } return; } @@ -969,6 +979,66 @@ void LobbyMenu::openBattleSettings() closeLobby(); } +void LobbyMenu::continueCustomBattle() +{ + if (!_game->getCoopMod()->getServerOwner() + || !connectionTCP::session.customBattleResumePending + || !startEligible() + || !_game->getSavedGame() + || !_game->getSavedGame()->getSavedBattle()) + { + return; + } + + connectionTCP::session.campaignStarted(); + + Json::Value start; + start["state"] = "custom_battle_continue"; + _game->getCoopMod()->sendTCPPacketData(start.toStyledString()); + + closeLobby(); + connectionTCP::session.finishCustomBattleResume(); + connectionTCP::session.beginCustomBattleResumeLoad(); + _game->getCoopMod()->inventory_battle_window = false; + + // HostMenu was opened over the loaded battle's PauseState. Return the host + // to that BattlescapeState before the peer begins loading its snapshot. + int guard = 0; + while (guard++ < 32 && _game->getStates().size() > 1 + && dynamic_cast(_game->getStates().back()) == nullptr) + { + _game->popState(); + } + _game->getCoopMod()->_battleInit = false; + if (_game->getCoopMod()->parallelTurnActive()) + { + connectionTCP::resetActionArbiter(true); + _game->getCoopMod()->setPlayerTurn(2); + SavedBattleGame* battle = _game->getSavedGame()->getSavedBattle(); + if (battle && battle->getBattleState()) + { + battle->getBattleState()->setCurrentTurn(2); + } + } + else if (_game->getCoopMod()->getCoopGamemode() == 2 + || _game->getCoopMod()->getCoopGamemode() == 3) + { + // Campaign-PvP resume derives the active player from the preserved mode: + // gm2 host=XCOM, gm3 host=Alien. isYourTurn itself is process-static and + // is not serialized, so establish that same role before snapshotting. + const int turn = _game->getCoopMod()->getCoopGamemode() == 2 ? 2 : 1; + _game->getCoopMod()->setPlayerTurn(turn); + SavedBattleGame* battle = _game->getSavedGame()->getSavedBattle(); + if (battle && battle->getBattleState()) + { + battle->getBattleState()->setCurrentTurn(turn); + } + } + _game->pushState(new CoopState(COOP_DLG_WAIT_PLAYERS)); + + _game->getCoopMod()->streamSkirmishBattleToClient(); +} + /** * Can the custom-battle client open the local EQUIP CRAFT screen safely? * @@ -1424,7 +1494,8 @@ void LobbyMenu::think() // the selected craft and entered equipment preparation. if (_game->getCoopMod()->getServerOwner() == true) { - _btnCancel->setText("BATTLE SETTINGS"); + _btnCancel->setText(connectionTCP::session.customBattleResumePending + ? "CONTINUE BATTLE" : "BATTLE SETTINGS"); _btnCancel->setVisible(startEligible()); } else diff --git a/src/CoopMod/LobbyMenu.h b/src/CoopMod/LobbyMenu.h index 0efedb25c..6bb016cd0 100644 --- a/src/CoopMod/LobbyMenu.h +++ b/src/CoopMod/LobbyMenu.h @@ -85,6 +85,8 @@ class LobbyMenu : public State void closeLobby(); /// Host pressed BATTLE SETTINGS in the skirmish lobby (mode 0). void openBattleSettings(); + /// Host continues a loaded Custom Battle after both players reached the lobby. + void continueCustomBattle(); /// Is the client's local custom-battle craft ready to be opened safely? bool canOpenEquipCraft() const; /// Client pressed EQUIP CRAFT in the skirmish lobby (mode 0). diff --git a/src/CoopMod/TestServer.cpp b/src/CoopMod/TestServer.cpp index 3a092b66d..55ed605a9 100644 --- a/src/CoopMod/TestServer.cpp +++ b/src/CoopMod/TestServer.cpp @@ -60,6 +60,7 @@ #include "../Engine/Logger.h" #include "../Engine/Options.h" #include "../Engine/State.h" +#include "../Menu/PauseState.h" #include "../Geoscape/GeoscapeState.h" #include "../Geoscape/ItemsArrivingState.h" #include "../Geoscape/GeoscapeCraftState.h" @@ -2589,6 +2590,15 @@ bool TestServer::executeShared11(const std::string& cmd, const Json::Value& req, _game->pushState(new LobbyMenu()); resp["ok"] = true; } + else if (cmd == "open_pause_coop") + { + // Drive the real Battlescape pause-menu COOP action. In particular, a + // freshly loaded Custom Battle must open HostMenu rather than ServerList. + PauseState* pause = new PauseState(OPT_BATTLESCAPE); + _game->pushState(pause); + pause->btnCoopClick(nullptr); + resp["ok"] = true; + } else if (cmd == "lobby_set_team") { // Host-only: put lobby row (0 = host, 1 = the joined client) on @@ -7308,6 +7318,7 @@ std::string TestServer::execute(const std::string& line) resp["lobbyClosed"] = connectionTCP::session.lobbyClosed; resp["lobbyFileStatus"] = connectionTCP::LobbyFileStatus; resp["lobbyMode"] = connectionTCP::session.lobbyMode; + resp["customBattleResumePending"] = connectionTCP::session.customBattleResumePending; resp["resumeAck"] = connectionTCP::session.resumeAck; resp["coopSession"] = coop->isCoopSession(); resp["hasSave"] = _game->getSavedGame() != nullptr; diff --git a/src/CoopMod/connectionTCP.cpp b/src/CoopMod/connectionTCP.cpp index 345c613db..2c5bc3eb0 100644 --- a/src/CoopMod/connectionTCP.cpp +++ b/src/CoopMod/connectionTCP.cpp @@ -343,6 +343,36 @@ void CoopSession::adoptResumeSave() Log(LOG_INFO) << "[coop-session] adoptResumeSave (lobbyMode=2, unlocked, ack cleared)"; } +void CoopSession::adoptCustomBattleResume() +{ + lobbyMode = 0; + sessionLocked = false; + lobbyClosed = true; + resumeAck = false; + skirmishRejoinPending = false; + customBattleResumePending = true; + Log(LOG_INFO) << "[coop-session] adoptCustomBattleResume (waiting for CONTINUE BATTLE)"; +} + +void CoopSession::finishCustomBattleResume() +{ + customBattleResumePending = false; + Log(LOG_INFO) << "[coop-session] finishCustomBattleResume"; +} + +void CoopSession::beginCustomBattleResumeLoad() +{ + customBattleResumePending = false; + customBattleResumeLoading = true; + Log(LOG_INFO) << "[coop-session] beginCustomBattleResumeLoad"; +} + +void CoopSession::completeCustomBattleResumeLoad() +{ + customBattleResumeLoading = false; + Log(LOG_INFO) << "[coop-session] completeCustomBattleResumeLoad"; +} + void CoopSession::armResumeHandshake(bool hasBattle) { resumeAck = false; @@ -407,6 +437,8 @@ void CoopSession::resetSession() campaignBegun = false; customBattleCraftLocked = false; customBattleCraftId = -1; + customBattleResumePending = false; + customBattleResumeLoading = false; skirmishRejoinPending = false; pendingHostSaveName.clear(); @@ -5675,6 +5707,28 @@ void connectionTCP::onTCPMessage(std::string stateString, Json::Value obj) } + if (stateString == "custom_battle_continue" && getServerOwner() == false) + { + // The host has kept its loaded tactical world paused while both players + // waited in the lobby. Leave that lobby now; SEND_FILE_CLIENT_TRUE follows + // on the same ordered connection and loads the existing battle directly. + connectionTCP::session.campaignStarted(); + connectionTCP::session.markLobbyClosed(); + connectionTCP::session.finishCustomBattleResume(); + connectionTCP::session.beginCustomBattleResumeLoad(); + _game->getCoopMod()->inventory_battle_window = false; + + while (!_game->getStates().empty()) + { + bool isLobby = dynamic_cast(_game->getStates().back()) != nullptr; + _game->popState(); + if (isLobby) + { + break; + } + } + } + if (stateString == "lobby_timer") { @@ -12008,8 +12062,9 @@ void connectionTCP::onTCPMessage(std::string stateString, Json::Value obj) // and strand the client on its hold. The campaign resume keeps the pop - // its host already clicked RESUME back in the lobby. bool inBattleResume = false; + const bool customBattleResume = connectionTCP::session.customBattleResumeLoading; if (_game->getSavedGame() && _game->getSavedGame()->getSavedBattle() != nullptr - && _game->getCoopMod()->getCoopCampaign() == true) + && (_game->getCoopMod()->getCoopCampaign() == true || customBattleResume)) { for (auto* st : _game->getStates()) { @@ -12030,6 +12085,10 @@ void connectionTCP::onTCPMessage(std::string stateString, Json::Value obj) _game->popState(); } } + if (customBattleResume) + { + connectionTCP::session.completeCustomBattleResumeLoad(); + } Json::Value root; root["state"] = "COOP_READY_CLIENT_REQUEST"; @@ -12442,16 +12501,15 @@ void connectionTCP::onTCPMessage(std::string stateString, Json::Value obj) _peerLeftCleanly = false; connectionTCP::session.clientAttached(); - // issue #93: a joiner arriving while a SKIRMISH battle is already running is - // a rejoin into that battle, not a new lobby guest. The skirmish lobby has - // nothing left to offer (the battle started; its BATTLE SETTINGS button is - // gone) and the host is frozen behind the reconnect dialog waiting for - // exactly this. Route it down the campaign rejoin road - the world stream - // and the resume_ack that releases the freeze are mode-agnostic. - const bool skirmishBattleRejoin = _game->getCoopMod()->getCoopCampaign() == false - && coopBattleLive(_game); + // A joiner arriving while a SKIRMISH session is already running goes + // straight into that battle (issue #93). A freshly loaded Custom Battle is + // the exception: customBattleResumePending keeps the joiner in the lobby + // until the host explicitly presses CONTINUE BATTLE. + const bool skirmishBattleJoin = _game->getCoopMod()->getCoopCampaign() == false + && coopBattleLive(_game) + && !connectionTCP::session.customBattleResumePending; - if (_game->getCoopMod()->getCoopCampaign() == true || skirmishBattleRejoin) + if (_game->getCoopMod()->getCoopCampaign() == true || skirmishBattleJoin) { root["state"] = "COOP_READY_SAVE_PROGRESS"; // Kept on the wire for older clients; host-save authority is the only mode. @@ -12459,9 +12517,9 @@ void connectionTCP::onTCPMessage(std::string stateString, Json::Value obj) // campaign lobbies (new or resume): the client joins the lobby // instead of requesting a world (flow-redesign F2/F3). A live // session (lobby closed) = mid-session rejoin: fetch directly. - root["campaign_started"] = skirmishBattleRejoin + root["campaign_started"] = skirmishBattleJoin || (connectionTCP::session.lobbyMode == 0 || connectionTCP::session.lobbyClosed == true); - root["rejoin"] = skirmishBattleRejoin + root["rejoin"] = skirmishBattleJoin || (connectionTCP::session.lobbyMode != 0 && connectionTCP::session.lobbyClosed == true); root["lobby_mode"] = connectionTCP::session.lobbyMode; // PRD-J01: tell the joining client the campaign economy model now, diff --git a/src/CoopMod/connectionTCP.h b/src/CoopMod/connectionTCP.h index f5f5a287d..2df77a9ec 100644 --- a/src/CoopMod/connectionTCP.h +++ b/src/CoopMod/connectionTCP.h @@ -331,6 +331,14 @@ struct CoopSession // authoritative for the lifetime of this multiplayer session. bool customBattleCraftLocked = false; int customBattleCraftId = -1; + // A loaded Custom Battle is being re-hosted through a campaign-style lobby. + // The existing tactical world stays paused on the host and is streamed only + // after the host presses CONTINUE BATTLE. + bool customBattleResumePending = false; + // CONTINUE BATTLE was pressed and the saved battle is being adopted by the + // client. The host stays behind WAIT_PLAYERS until the post-load COOP_READY + // handshake re-arms battlescape roles/turns on both machines. + bool customBattleResumeLoading = false; // issue #93: this client is rejoining a SKIRMISH (lobbyMode 0) session whose // battle is already running, so the battle blob it is about to load is a // REJOIN, not the start of a mission. One-shot: the load consumes it to send @@ -353,6 +361,10 @@ struct CoopSession // --- multi-field / cross-file lifecycle writes funnelled here (PRD-12) --- void adoptResumeSave(); // a co-op save is loaded for resume (lobbyMode=2, unlock, clear ack) + void adoptCustomBattleResume(); // loaded Custom Battle -> HostMenu/lobby, wait for CONTINUE BATTLE + void finishCustomBattleResume(); // CONTINUE BATTLE accepted; future joins are live reconnects + void beginCustomBattleResumeLoad(); // map transfer begun; post-load COOP_READY is owed + void completeCustomBattleResumeLoad(); // post-load handshake reached its completion point void armResumeHandshake(bool hasBattle); // resume/rejoin: clear ack, arm battle phase-two if a battle is loaded void markLobbyOpen(); // the lobby UI opened (lobbyClosed=false) void markLobbyClosed(); // the lobby UI dismissed (lobbyClosed=true) diff --git a/src/Menu/LoadGameState.cpp b/src/Menu/LoadGameState.cpp index 9ebdd3059..9dd8cdd58 100644 --- a/src/Menu/LoadGameState.cpp +++ b/src/Menu/LoadGameState.cpp @@ -477,7 +477,9 @@ void LoadGameState::think() // "battleclient" key through its own handshake and must not ack. const bool skirmishRejoin = connectionTCP::session.lobbyMode == 0 && connectionTCP::session.skirmishRejoinPending; - if ((connectionTCP::session.lobbyMode != 0 || skirmishRejoin) + const bool customBattleResume = connectionTCP::session.lobbyMode == 0 + && connectionTCP::session.customBattleResumeLoading; + if ((connectionTCP::session.lobbyMode != 0 || skirmishRejoin || customBattleResume) && _game->getCoopMod()->getServerOwner() == false && _coopKey == "battleclient") { if (skirmishRejoin) @@ -514,6 +516,25 @@ void LoadGameState::think() Json::Value done; done["state"] = "close_load_progress"; _game->getCoopMod()->sendTCPPacketData(done.toStyledString()); + if (customBattleResume) + { + if (_game->getCoopMod()->parallelTurnActive()) + { + connectionTCP::resetActionArbiter(true); + _game->getCoopMod()->setPlayerTurn(2); + bs->setCurrentTurn(2); + } + else if (_game->getCoopMod()->getCoopGamemode() == 2 + || _game->getCoopMod()->getCoopGamemode() == 3) + { + // Campaign-PvP role model: gm2 client=Alien (waiting), + // gm3 client=XCOM (YOUR TURN). + const int turn = _game->getCoopMod()->getCoopGamemode() == 2 ? 1 : 2; + _game->getCoopMod()->setPlayerTurn(turn); + bs->setCurrentTurn(turn); + } + connectionTCP::session.completeCustomBattleResumeLoad(); + } } } diff --git a/src/Menu/PauseState.cpp b/src/Menu/PauseState.cpp index 4c66659fb..29fbd9cd6 100644 --- a/src/Menu/PauseState.cpp +++ b/src/Menu/PauseState.cpp @@ -36,6 +36,7 @@ #include "../CoopMod/ServerList.h" #include "../CoopMod/HostMenu.h" +#include "../CoopMod/connectionTCP.h" namespace OpenXcom { @@ -243,9 +244,22 @@ void PauseState::btnSaveClick(Action *) // Opens COOP view void PauseState::btnCoopClick(Action *) { + // A locally loaded Custom Battle follows the campaign-resume presentation: + // configure hosting first, keep both players in a lobby, and stream the + // existing tactical world only after the host presses CONTINUE BATTLE. + if (_origin == OPT_BATTLESCAPE + && _game->getSavedGame() + && _game->getSavedGame()->getSavedBattle() + && _game->getCoopMod()->getCoopCampaign() == false + && _game->getCoopMod()->getServerOwner() == false + && _game->getCoopMod()->isConnected() != 1) + { + connectionTCP::session.adoptCustomBattleResume(); + _game->pushState(new HostMenu()); + } // Open the host menu if the host saves the players' campaign progress, the client joins the game through the main menu (New Battle) - if (_game->getCoopMod()->getCoopCampaign() == true && _game->getCoopMod()->getServerOwner() == false && _game->getCoopMod()->getCoopStatic() == false) + else if (_game->getCoopMod()->getCoopCampaign() == true && _game->getCoopMod()->getServerOwner() == false && _game->getCoopMod()->getCoopStatic() == false) { _game->pushState(new HostMenu()); diff --git a/tools/coop_test/test_pvp_skirmish_end_turn.py b/tools/coop_test/test_pvp_skirmish_end_turn.py index 81a2ea13e..3f94aa7e3 100644 --- a/tools/coop_test/test_pvp_skirmish_end_turn.py +++ b/tools/coop_test/test_pvp_skirmish_end_turn.py @@ -196,6 +196,39 @@ def test_end_turn_gamemode_3(fails): else: print(f"PASS gm3: host now commands {len(hs2)} alien unit(s)") + # ---- end the alien turn (complete the first full round) ------------ + # This second boundary is the saved-PvP2 regression: after displaying + # NextTurnState, its vanilla faction tally used to call finishBattle + # even though both PvP seats still had living units. + end_turn(host) + time.sleep(1) + if PVP._top(host) == "NextTurnState": + host.ok({"cmd": "dismiss_popup"}) + time.sleep(3) + + hb = battle(host) + cb = battle(client) + if not hb.get("inBattle") or not cb.get("inBattle"): + _fail(fails, + "gm3: battle ended after one complete XCOM+alien round") + return + if hb.get("pvpWin", 0) or cb.get("pvpWin", 0): + _fail(fails, + f"gm3: false PvP verdict after full round: " + f"host={hb.get('pvpWin')} client={cb.get('pvpWin')}") + return + + hs3, host_exec3 = selectable(host) + cs3, client_exec3 = selectable(client) + if not cs3: + _fail(fails, + "gm3: client (XCOM) did not regain control after alien turn") + elif hs3: + _fail(fails, + f"gm3: host still has selectable alien units on XCOM turn: {hs3}") + else: + print("PASS gm3 full round: battle continues and control returns to XCOM") + except Exception as e: print(f"[ERROR] gm3: {e}") _fail(fails, str(e)) diff --git a/tools/coop_test/test_skirmish_saved_battle_join.py b/tools/coop_test/test_skirmish_saved_battle_join.py new file mode 100644 index 000000000..c53b8dceb --- /dev/null +++ b/tools/coop_test/test_skirmish_saved_battle_join.py @@ -0,0 +1,239 @@ +"""Regression: joining a hosted custom-battle save must not wait for RESUME. + +A loaded skirmish save already has a SavedBattleGame before its new multiplayer +session starts. The join gate used to mistake that fact for a live-session +reconnect: the client downloaded the map and reached BattlescapeState, then was +parked forever in COOP_DLG_CLIENT_RESUME_HOLD even though the host had no +reconnect dialog (and therefore no RESUME button capable of releasing it). + +This test creates a real co-op skirmish save, replaces both processes, loads the +save on the new host, and opens COOP through the real Battlescape pause menu. +Like a campaign resume, that must open HostMenu and put both players in a lobby; +the map must not stream until the host presses CONTINUE BATTLE. The client must +then enter it without dialog 68. The existing +test_skirmish_rejoin_battle.py covers the opposite half: a genuine mid-session +reconnect still must hold until the host presses RESUME. + +Run: python tools/coop_test/test_skirmish_saved_battle_join.py +Exit 0 = pass; 2 = failure. +""" + +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from harness import GameClient, make_user_dir +import session +import test_skirmish_flow as skirmish_flow +import pvp_fixture + + +SAVE = "skirmish_saved_battle_join.sav" +PORT = "47998" +CLIENT_RESUME_HOLD = 68 + + +def in_battle(gc): + return gc.cmd({"cmd": "battle_state"}).get("inBattle") or None + + +def battle(gc): + return gc.cmd({"cmd": "battle_state"}) + + +def selectable(gc): + return sorted(u["id"] for u in battle(gc).get("units", []) + if u.get("selectable") and not u.get("isOut")) + + +def end_turn(gc): + gc.ok({"cmd": "battle_action", "action": "end_turn_button"}) + + +def main(): + host_dir = make_user_dir( + "skirm_saved_join_host", options={"EnableCoopParallelTurns": True}) + host = GameClient("host", 48820, host_dir) + client = GameClient( + "client", 48821, make_user_dir("skirm_saved_join_client_seed")) + fail = None + + try: + # Produce the fixture through the real skirmish flow. Only the host's + # local save survives; the joining client below starts with a clean dir. + host.spawn() + host.connect() + client.spawn() + client.connect() + gamemode = pvp_fixture.start_pvp_skirmish_battle( + host, client, PORT, alien_player="host") + assert gamemode == 3, f"fixture did not create PvP2 gamemode 3: {gamemode}" + host.wait_for("seed host in battle", lambda: in_battle(host), timeout=120) + host.wait_for("seed PvP battle initialized", + lambda: battle(host).get("battleInit") or None, timeout=120) + host.ok({"cmd": "save_game", "file": SAVE}) + save_path = os.path.join(host_dir, "xcom1", SAVE) + assert os.path.exists(save_path), f"host did not write {save_path}" + print(f"PASS fixture: wrote a real custom-battle save ({SAVE})") + + host.shutdown() + client.shutdown() + + # Fresh processes are important: no live-session/reconnect flags may + # leak from the session that produced the save. + host = GameClient("host", 48822, host_dir) + client = GameClient( + "client", 48823, + make_user_dir("skirm_saved_join_client_clean", + options={"EnableCoopParallelTurns": False})) + host.spawn() + host.connect() + client.spawn() + client.connect() + + host.ok({"cmd": "load_save", "file": SAVE}) + host.wait_for("host loaded saved battle", lambda: in_battle(host), timeout=120) + before = host.ok({"cmd": "get_coop"}) + assert not before.get("sessionLocked"), ( + "a freshly loaded custom battle already looks like a live session: " + f"{before}") + + host.ok({"cmd": "open_pause_coop"}) + host.wait_for("loaded battle opened HostMenu", + lambda: session.has_state(host, "HostMenu"), timeout=30) + assert not session.has_state(host, "ServerList"), ( + "loaded Custom Battle COOP opened the server browser instead of " + f"HostMenu: {session.states(host)}") + assert host.ok({"cmd": "get_coop"}).get("customBattleResumePending"), ( + "loaded Custom Battle did not arm its lobby-gated resume flow") + + host.ok({"cmd": "host_menu_host", "visibility": 0, + "server": "SavedBattle", "port": PORT, + "player": "HostPlayer"}) + host.wait_for("host entered continue-battle lobby", + lambda: session.has_state(host, "LobbyMenu"), timeout=30) + + skirmish_flow.skirmish_client_at_browser(client) + client.ok({"cmd": "join_tcp", "ip": "127.0.0.1", "port": PORT, + "player": "ClientPlayer"}) + + # Campaign-style gate: joining alone must put both players in the lobby. + # In particular it must not trigger Downloading map or adopt the battle. + for gc, tag in ((host, "host"), (client, "client")): + gc.wait_for(f"{tag} join popup", + lambda gc=gc: session.has_state(gc, "Profile"), timeout=60) + assert session.has_state(gc, "LobbyMenu"), ( + f"{tag} did not wait in LobbyMenu: {session.states(gc)}") + gc.ok({"cmd": "profile_ok"}) + + time.sleep(2) + assert not in_battle(client), ( + "client downloaded the saved map before the host pressed CONTINUE BATTLE") + action = host.ok({"cmd": "lobby_state"}) + assert action.get("buttonVisible") and action.get("buttonText") == "CONTINUE BATTLE", ( + f"host lobby did not offer CONTINUE BATTLE: {action}") + assert not client.ok({"cmd": "coop_dialog_info"}).get("present"), ( + "client opened a download/hold dialog while it should still be in lobby") + print("PASS gate: HostMenu -> both in lobby; no map transfer before CONTINUE BATTLE") + + host.ok({"cmd": "lobby_action"}) + host.wait_for("host returned to loaded battle", + lambda: (not session.has_state(host, "LobbyMenu") + and not session.has_state(host, "PauseState")) or None, + timeout=30) + assert session.has_state(host, "BattlescapeState"), ( + f"host did not return to its loaded battle: {session.states(host)}") + + # CONTINUE BATTLE is the sole map-transfer trigger. + client.wait_for("client downloaded the saved battle", + lambda: in_battle(client), timeout=240, interval=0.5) + time.sleep(2) + + dialog = client.ok({"cmd": "coop_dialog_info"}) + assert not (dialog.get("present") + and dialog.get("code") == CLIENT_RESUME_HOLD), ( + "client reached the saved custom battle but was stranded on " + f"Waiting for host to resume: {dialog}; " + f"stack={session.states(client)}") + assert not session.has_state(client, "LobbyMenu"), ( + "the saved battle was routed back through the pre-battle lobby: " + f"{session.states(client)}") + assert not session.has_state(client, "BriefingState"), ( + "the saved battle incorrectly entered the new-mission briefing path: " + f"{session.states(client)}") + client_coop = client.ok({"cmd": "get_coop"}) + assert client_coop.get("onConnect", 0) > 0 and client_coop.get("coopSession"), ( + "client escaped the hold without a live multiplayer session: " + f"{client_coop}") + + # Match campaign PvP battle resume: preserve gamemode 3 and its opposing + # turns. Parallel Turns is intentionally inactive in PvP even though the + # host option is enabled in this fixture. + for gc, tag in ((host, "host"), (client, "client")): + gc.wait_for( + f"{tag} PvP battle initialized", + lambda gc=gc: (lambda b: b if (b.get("battleInit") + and b.get("coopGamemode") == 3) + else None)(battle(gc)), + timeout=120, interval=0.5) + + hb, cb = battle(host), battle(client) + assert not hb.get("parallelActive") and not cb.get("parallelActive"), ( + f"PvP incorrectly entered Parallel Turns: host={hb}, client={cb}") + assert hb.get("coopTurn") == 1 and cb.get("coopTurn") == 2, ( + "gamemode 3 must resume with client=XCOM on YOUR TURN and the alien " + f"host waiting: host={hb}, client={cb}") + assert hb.get("activeSync") is False and cb.get("activeSync") is True, ( + f"PvP executor/turn ownership is wrong: host={hb}, client={cb}") + hp = host.ok({"cmd": "parallel_state"}) + cp = client.ok({"cmd": "parallel_state"}) + assert not hp.get("readySeats") and not hp.get("autoSeats"), ( + f"PvP host displayed a Parallel END TURN tally: {hp}") + assert not cp.get("readySeats"), ( + f"PvP client retained Parallel END TURN readiness: {cp}") + + # Complete the exact reported PvP2 round: client XCOM ends, host Alien + # ends, then the Next Turn screen closes. Both seats are alive, so the + # battle must return to the client's XCOM turn instead of Debriefing. + end_turn(client) + host.wait_for("alien host received turn", + lambda: selectable(host) or None, timeout=30) + end_turn(host) + time.sleep(1) + if session.states(host)[-1].endswith("NextTurnState"): + host.ok({"cmd": "dismiss_popup"}) + time.sleep(3) + + hb, cb = battle(host), battle(client) + assert hb.get("inBattle") and cb.get("inBattle"), ( + "saved PvP2 battle falsely ended after the first complete round: " + f"host={hb}, client={cb}") + assert hb.get("pvpWin", 0) == 0 and cb.get("pvpWin", 0) == 0, ( + f"saved PvP2 battle produced a false winner: host={hb}, client={cb}") + assert selectable(client) and not selectable(host), ( + "saved PvP2 battle did not return control to client XCOM after the " + f"alien turn: host={hb}, client={cb}") + + print("PASS regression: client downloaded the hosted custom-battle save " + "with gamemode 3, completed XCOM+alien turns, and battle continued") + except Exception as exc: + fail = exc + print(f"[FAIL] {exc}") + for tag, gc in (("host", host), ("client", client)): + try: + print(f" {tag} stack: {session.states(gc)}") + print(f" {tag} coop: {gc.cmd({'cmd': 'get_coop'})}") + except Exception as debug_exc: + print(f" {tag} debug failed: {debug_exc}") + finally: + host.shutdown() + client.shutdown() + + if fail: + raise SystemExit(2) + print("ALL SAVED CUSTOM-BATTLE JOIN TESTS PASSED") + + +if __name__ == "__main__": + main() From 3b9ace59e9f59bd39bef88e7f265899cae2dff03 Mon Sep 17 00:00:00 2001 From: xcomcoopdev Date: Wed, 2 Sep 2026 11:26:31 +0300 Subject: [PATCH 2/3] Fix PvP round transitions, saved skirmish resume, and PVE2 turn gating PvP and PvP2 turn flow PvP battles are controlled by two players, so the vanilla HOSTILE phase must not run between their turns. Entering that phase allows the normal alien AI to take control of units that belong to the human alien player. Change the completed PvP/PvP2 round transition to skip HOSTILE and use the intended sequence: NEUTRAL -> PLAYER The XCOM-to-alien-player handoff is still handled by the existing network turn message. The HOSTILE phase is skipped only when the alien player has completed the human half of the round and the battle advances to the neutral phase. Allow the PvP neutral Next Turn screen to open even though the original BattlescapeGame condition normally suppresses the screen for FACTION_NEUTRAL. Mirror the host's PvP turn-screen clicks to the client so that the client does not remain stuck behind an End Turn screen after the host advances the round. PvP reaction-fire and round-boundary TU handling Do not reset the alien player's TU and energy during the XCOM-to-alien handoff. TU spent by alien reaction fire during the XCOM half-round must remain spent when the human alien player's active half-round begins. Remove the old BattleUnit PvP exception that disabled alien TU and energy recovery on every updateUnitStats call. That exception was previously hidden by the premature handoff reset, but after removing that reset, it also prevented the legitimate full-round refresh. Refresh TU and energy for both living human-controlled combat seats at the real NEUTRAL-to-PLAYER round boundary. This keeps the host and client copies equal: - reaction-fire TU remains spent during the following alien half-round; - all combat units receive their new-round TU after the alien player ends; - the gm2 host holds the correct current alien TU values; - the next XCOM-to-alien handoff cannot overwrite the client's full TU with an old value from the host; - any reaction fire during the new XCOM half-round is still deducted before the next alien handoff. Saved Custom Battle / skirmish resume regression Fix the saved-skirmish harness to load the generated custom-battle save through the real LoadGameState menu path. The raw TestServer load_save helper always installs a GeoscapeState after parsing and is not a valid loading path for an _autobattle_.asav Custom Battle save; using it can terminate the host process and close the harness control connection. After the real load completes, open COOP through the restored battlescape pause menu. Verify the campaign-style resume flow: - loading restores the host's existing battlescape; - COOP opens HostMenu rather than ServerList; - the host and client wait together in LobbyMenu; - joining does not download or enter the saved map early; - CONTINUE BATTLE is the sole map-transfer trigger; - the client enters the restored battle without Waiting for host to resume; - PvP2 gamemode and XCOM/alien ownership survive the transfer; - the battle continues after a complete XCOM and alien round. Update the saved PvP2 round portion of the test for the two-screen flow. The host closes NEUTRAL first and PLAYER second, while the client advances to the player side and regains XCOM control. PvP/PvP2 harness coverage Expand test_pvp_skirmish_end_turn.py to cover both gamemode 2 and gamemode 3. The test deterministically models reaction-fire expenditure by setting one alien's TU below its maximum on both process copies, without depending on a random reaction shot. For both PvP variants, verify that: - the XCOM-to-alien handoff preserves the spent TU value; - completing the alien half-round produces NEUTRAL then PLAYER, never HOSTILE; - the battle does not incorrectly enter Debriefing after one complete round; - the alien TU returns to tuMax at the full-round boundary on both machines; - the following handoff keeps the full value when no new reaction TU was spent; - the gm2 client is not overwritten by a stale alien TU value from the host. PVE2 parallel-turn boundary input gate In PVE2, both players control aliens. A client can close its own PLAYER Next Turn screen before the host closes the host copy. The client then sees the map and can send an action intent even though the authoritative host is still behind NextTurnState. Reject PVE2 action admission while BattlescapeState is not the host's top state. This prevents client movement and other actions until the host has dismissed the turn-boundary screen. Once BattlescapeState is active again, the same action intent is admitted normally. Keep this new modal-state check strictly limited to gamemode 4. PvE gamemode 1, PvP, PvP2, classic co-op, and single-player admission behavior are unchanged. Add test_parallel_pve2_turn_screen_gate.py. The test creates a parallel PVE2 skirmish, closes only the client's PLAYER screen, and sends a real client alien move intent while the host screen remains open. It verifies that the host rejects the intent with not_top_state and that neither process moves the unit. After the host closes its screen, the test sends the same intent again and requires the host to execute it. --- src/Battlescape/BattlescapeGame.cpp | 38 +++- src/Battlescape/NextTurnState.cpp | 10 +- src/CoopMod/connectionTCP.cpp | 40 ++-- src/Savegame/BattleUnit.cpp | 10 - .../test_parallel_pve2_turn_screen_gate.py | 201 ++++++++++++++++++ tools/coop_test/test_pvp_skirmish_end_turn.py | 135 ++++++++++-- .../test_skirmish_saved_battle_join.py | 41 +++- 7 files changed, 421 insertions(+), 54 deletions(-) create mode 100644 tools/coop_test/test_parallel_pve2_turn_screen_gate.py diff --git a/src/Battlescape/BattlescapeGame.cpp b/src/Battlescape/BattlescapeGame.cpp index 7f3cfb6e5..d5f1f918c 100644 --- a/src/Battlescape/BattlescapeGame.cpp +++ b/src/Battlescape/BattlescapeGame.cpp @@ -2168,8 +2168,39 @@ void BattlescapeGame::endTurn() } } + // In PvP both combat factions are controlled by network players. The + // remote-player hand-off is handled by the co-op protocol, so entering the + // vanilla HOSTILE phase here would incorrectly give the alien AI a turn. + // Advance PLAYER as if HOSTILE had already completed, preserving the normal + // NEUTRAL -> PLAYER boundary (including its turn increment and TU refresh). + const int coopGamemode = getCoopMod()->getCoopGamemode(); + if (getCoopMod()->getCoopStatic() + && (coopGamemode == 2 || coopGamemode == 3) + && _save->getSide() == FACTION_PLAYER) + { + _save->setSideCoop(FACTION_HOSTILE); + } + _save->endTurn(); + // PvP has no vanilla HOSTILE phase, so the opposing network seat does not + // pass through the place where that faction would normally receive its + // round-start TU/energy. Refresh both human seats together at the real + // NEUTRAL -> PLAYER round boundary. The host can then carry the current + // value (full minus any later reaction-fire cost) in PlayerTurnYour. + if (getCoopMod()->getCoopStatic() + && (coopGamemode == 2 || coopGamemode == 3) + && _save->getSide() == FACTION_PLAYER) + { + for (BattleUnit* unit : *_save->getUnits()) + { + if (!unit->isOut() && unit->getFaction() != FACTION_NEUTRAL) + { + unit->resetTimeUnitsAndEnergy(); + } + } + } + t = _save->getTileEngine()->checkForTerrainExplosions(); if (t) { @@ -2279,7 +2310,12 @@ void BattlescapeGame::endTurn() bool battleComplete = (!killingAllAliensIsNotEnough && tally.liveAliens == 0) || tally.liveSoldiers == 0; - if ((_save->getSide() != FACTION_NEUTRAL || battleComplete) && _endTurnRequested) + const int nextTurnGamemode = getCoopMod()->getCoopGamemode(); + const bool pvpNeutralScreen = getCoopMod()->getCoopStatic() + && (nextTurnGamemode == 2 || nextTurnGamemode == 3) + && _save->getSide() == FACTION_NEUTRAL; + if ((_save->getSide() != FACTION_NEUTRAL || pvpNeutralScreen || battleComplete) + && _endTurnRequested) { _parentState->getGame()->pushState(new NextTurnState(_save, _parentState)); } diff --git a/src/Battlescape/NextTurnState.cpp b/src/Battlescape/NextTurnState.cpp index df7c264e8..e1346666a 100644 --- a/src/Battlescape/NextTurnState.cpp +++ b/src/Battlescape/NextTurnState.cpp @@ -564,7 +564,15 @@ void NextTurnState::close() { // coop - if (_battleGame->getSide() == FACTION_HOSTILE && _game->getCoopMod()->getCoopStatic() == true && _battleGame->getTurn() >= 1 && _game->getCoopMod()->getHost() == true && _battleGame->isPreview() == false) + const int pvpScreenGamemode = _game->getCoopMod()->getCoopGamemode(); + const bool pvpTurnScreen = (pvpScreenGamemode == 2 || pvpScreenGamemode == 3) + && (_battleGame->getSide() == FACTION_NEUTRAL + || _battleGame->getSide() == FACTION_PLAYER); + if ((_battleGame->getSide() == FACTION_HOSTILE || pvpTurnScreen) + && _game->getCoopMod()->getCoopStatic() == true + && _battleGame->getTurn() >= 1 + && _game->getCoopMod()->getHost() == true + && _battleGame->isPreview() == false) { Json::Value root; diff --git a/src/CoopMod/connectionTCP.cpp b/src/CoopMod/connectionTCP.cpp index 2c5bc3eb0..a44bbcc76 100644 --- a/src/CoopMod/connectionTCP.cpp +++ b/src/CoopMod/connectionTCP.cpp @@ -11893,29 +11893,11 @@ void connectionTCP::onTCPMessage(std::string stateString, Json::Value obj) } - // Reset time units and energy at the start of the alien player's turn - // PVP - if (getHost() == false && getCoopGamemode() == 2) - { - for (auto& unit : *_game->getSavedGame()->getSavedBattle()->getUnits()) - { - if (unit->getCoop() == 1) - { - unit->resetTimeUnitsAndEnergy(); - } - } - } - // PVP2 - else if (getHost() == true && getCoopGamemode() == 3) - { - for (auto& unit : *_game->getSavedGame()->getSavedBattle()->getUnits()) - { - if (unit->getCoop() == 0) - { - unit->resetTimeUnitsAndEnergy(); - } - } - } + // PvP/PvP2 deliberately do not refresh the alien seat here. Reaction + // fire during the XCOM half-round spends that seat's TU, and the cost + // must carry into its following active half-round. The ordinary + // NEUTRAL -> PLAYER new-round boundary refreshes both seats after the + // alien player has ended the round. } @@ -14205,6 +14187,18 @@ bool connectionTCP::canAdmitAction() _admitBlocked = "no_battle"; return false; } + if (getCoopGamemode() == 4) + { + BattlescapeState* bstate = battle ? battle->getBattleState() : nullptr; + if (!bstate || !_staticGame->isState(bstate)) + { + // A NextTurnState (or any other modal) is still covering the PVE2 + // host's battlescape. The client must not start the shared alien side + // before the executor has dismissed the same boundary screen. + _admitBlocked = "not_top_state"; + return false; + } + } if (battle->getSide() != FACTION_PLAYER) { _admitBlocked = "ai_side"; diff --git a/src/Savegame/BattleUnit.cpp b/src/Savegame/BattleUnit.cpp index 59ea15db8..566c5c735 100644 --- a/src/Savegame/BattleUnit.cpp +++ b/src/Savegame/BattleUnit.cpp @@ -3060,16 +3060,6 @@ void BattleUnit::coopApplyDeferredTurnStart() void BattleUnit::updateUnitStats(bool tuAndEnergy, bool rest) { - // In PvP mode, do not reset time units or energy if the player is an alien - if (connectionTCP::getCoopStatic() == true && _coop == 1 && connectionTCP::getCoopGamemode() == 2) - { - tuAndEnergy = false; - } - else if (connectionTCP::getCoopStatic() == true && _coop == 0 && connectionTCP::getCoopGamemode() == 3) - { - tuAndEnergy = false; - } - // snapshot of current stats int TURecovery = 0; int ENRecovery = 0; diff --git a/tools/coop_test/test_parallel_pve2_turn_screen_gate.py b/tools/coop_test/test_parallel_pve2_turn_screen_gate.py new file mode 100644 index 000000000..71c55a88c --- /dev/null +++ b/tools/coop_test/test_parallel_pve2_turn_screen_gate.py @@ -0,0 +1,201 @@ +"""Parallel PVE2: client input waits for the host's PLAYER turn screen. + +Both players are on the Alien team (gamemode 4). The client can reach the +tactical map before the host dismisses NextTurnState; an action intent sent in +that window must be denied by the host. Once the host closes the screen, the +same real move intent must be admitted. + +Run: python tools/coop_test/test_parallel_pve2_turn_screen_gate.py +Exit 0 = pass; 2 = failure. +""" + +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from harness import GameClient, make_user_dir +import pvp_fixture as PVP +import session +import test_skirmish_flow as SK + + +PORT = "47999" + + +def top(gc): + return PVP._top(gc) + + +def battle(gc): + return gc.cmd({"cmd": "battle_state"}) + + +def unit(gc, uid): + return next((u for u in battle(gc).get("units", []) if u.get("id") == uid), None) + + +def position(gc, uid): + u = unit(gc, uid) + return (u["x"], u["y"], u["z"]) if u else None + + +def bring_up_pve2_at_boundary(host, client): + PVP.start_pvp_skirmish_lobby(host, client, PORT, alien_player="client") + host_row = PVP.row_for(host, "HostPlayer") + mode = host.ok({"cmd": "lobby_set_team", "row": host_row, + "team": "Alien"}).get("gamemode") + assert mode == 4, f"both Alien rows did not select PVE2/gamemode 4: {mode}" + + host.ok({"cmd": "lobby_action"}) + host.wait_for("host at battle settings", + lambda: not session.has_state(host, "LobbyMenu") or None) + host.ok({"cmd": "newbattle_ok"}) + + for gc, tag in ((host, "host"), (client, "client")): + gc.wait_for(f"{tag} battle flow", + lambda gc=gc: (session.has_state(gc, "BriefingState") + or session.has_state(gc, "InventoryState") + or session.has_state(gc, "BattlescapeState")) or None, + timeout=180, interval=0.5) + if session.has_state(gc, "BriefingState"): + gc.ok({"cmd": "close_briefing"}) + + # Clear inventory/briefing only. Deliberately preserve the host's first + # NextTurnState: that modal is the regression window under test. + deadline = time.time() + 120 + while time.time() < deadline: + for gc in (host, client): + if session.has_state(gc, "InventoryState"): + gc.ok({"cmd": "battle_inventory", "action": "ok"}) + elif top(gc) not in ("BattlescapeState", "NextTurnState"): + gc.cmd({"cmd": "dismiss_popup"}) + if top(host) == "NextTurnState" and top(client) == "NextTurnState": + client.ok({"cmd": "dismiss_popup"}) + client.wait_for("client closed its PLAYER screen first", + lambda: top(client) == "BattlescapeState" or None, + timeout=30, interval=0.2) + assert top(host) == "NextTurnState", \ + "client click unexpectedly closed the host PLAYER screen" + return + time.sleep(0.25) + raise TimeoutError( + f"PVE2 boundary shape not reached: host={session.states(host)}, " + f"client={session.states(client)}") + + +def main(): + host = GameClient( + "host", 48920, + make_user_dir("parallel_pve2_screen_host", + options={"EnableCoopParallelTurns": True})) + client = GameClient( + "client", 48921, + make_user_dir("parallel_pve2_screen_client", + options={"EnableCoopParallelTurns": True})) + failure = None + try: + host.spawn(); host.connect() + client.spawn(); client.connect() + bring_up_pve2_at_boundary(host, client) + + hb, cb = battle(host), battle(client) + assert hb.get("coopGamemode") == 4 and cb.get("coopGamemode") == 4, \ + f"fixture is not PVE2: host={hb.get('coopGamemode')} client={cb.get('coopGamemode')}" + assert hb.get("parallelActive") and cb.get("parallelActive"), \ + f"parallel turns inactive: host={hb} client={cb}" + + candidates = [u for u in cb.get("units", []) + if not u.get("isOut") and u.get("faction") == 0 + and u.get("coop") == 1 and u.get("tu", 0) > 0] + assert candidates, f"client has no owned PVE2 alien: {cb.get('units')}" + + mover = dest = None + for candidate in candidates: + probe = client.cmd({"cmd": "battle_intent", "action": "probe_step", + "unit": candidate["id"], "radius": 4, "max": 400}) + if probe.get("ok") and probe.get("steps"): + mover = candidate["id"] + step = probe["steps"][0] + dest = (step["x"], step["y"], step["z"]) + break + assert mover is not None, "no client-owned PVE2 alien has a walkable tile" + + before_h = position(host, mover) + before_c = position(client, mover) + assert before_h == before_c, f"fixture positions differ: {before_h} vs {before_c}" + + denied = client.ok({"cmd": "battle_intent", "action": "move", + "unit": mover, "x": dest[0], "y": dest[1], "z": dest[2]}) + time.sleep(3) + assert position(host, mover) == before_h and position(client, mover) == before_c, ( + "PVE2 client moved while host still had PLAYER NextTurnState: " + f"host {before_h}->{position(host, mover)}, " + f"client {before_c}->{position(client, mover)}, response={denied}") + gate = host.ok({"cmd": "parallel_state"}) + assert gate.get("admitBlocked") == "not_top_state", \ + f"host did not reject the early intent on its modal screen: {gate}" + print("PASS closed: client intent denied while host PLAYER screen is open") + + host.ok({"cmd": "dismiss_popup"}) + + # Closing the PLAYER screen starts the normal PVE2 opponent-AI cycle; + # it does not immediately reopen the shared player side. Drive its + # NextTurn screens and wait for the following PLAYER battlescape. + deadline = time.time() + 180 + while time.time() < deadline: + for gc in (host, client): + state = top(gc) + if state == "NextTurnState": + gc.cmd({"cmd": "dismiss_popup"}) + elif state != "BattlescapeState": + gc.cmd({"cmd": "dismiss_popup"}) + hb = battle(host) + cb = battle(client) + if (top(host) == "BattlescapeState" + and top(client) == "BattlescapeState" + and hb.get("side") == 0 and cb.get("side") == 0 + and hb.get("battleInit") and cb.get("battleInit") + and host.ok({"cmd": "parallel_state"}).get("canAdmit")): + break + time.sleep(0.5) + else: + raise TimeoutError( + f"next PVE2 player side did not open: host={battle(host)}, " + f"client={battle(client)}") + + # The AI cycle can change occupancy, so resolve a fresh legal step for + # the same client-owned alien before checking that admission reopened. + probe = client.cmd({"cmd": "battle_intent", "action": "probe_step", + "unit": mover, "radius": 4, "max": 400}) + assert probe.get("ok") and probe.get("steps"), \ + f"client alien has no step on the reopened player side: {probe}" + step = probe["steps"][0] + dest = (step["x"], step["y"], step["z"]) + before_h = position(host, mover) + + client.ok({"cmd": "battle_intent", "action": "move", + "unit": mover, "x": dest[0], "y": dest[1], "z": dest[2]}) + host.wait_for("same client move admitted after host closes screen", + lambda: position(host, mover) != before_h or None, + timeout=30, interval=0.2) + print("PASS open: same client intent admitted after host closed PLAYER screen") + except Exception as exc: + failure = exc + print(f"[FAIL] {exc}") + for tag, gc in (("host", host), ("client", client)): + try: + print(f" {tag} stack: {session.states(gc)}") + print(f" {tag} battle: {battle(gc)}") + except Exception as debug_exc: + print(f" {tag} debug failed: {debug_exc}") + finally: + host.shutdown(); client.shutdown() + + if failure: + raise SystemExit(2) + print("ALL PARALLEL PVE2 TURN-SCREEN GATE TESTS PASSED") + + +if __name__ == "__main__": + main() diff --git a/tools/coop_test/test_pvp_skirmish_end_turn.py b/tools/coop_test/test_pvp_skirmish_end_turn.py index 3f94aa7e3..b2765d748 100644 --- a/tools/coop_test/test_pvp_skirmish_end_turn.py +++ b/tools/coop_test/test_pvp_skirmish_end_turn.py @@ -1,12 +1,12 @@ -"""PvP skirmish end-turn: turn boundary TU reset and handoff. +"""PvP skirmish end-turn: round-boundary TU reset and handoff. In PvP, the two sides alternate turns: - XCOM turn: the XCOM-side player acts. - Alien turn: the alien-side player acts. An END TURN by the current executor must: - 1. Hand off control: the OTHER machine becomes executor (its selectable - units get TU reset, it gets coopTurn==2 + activeSync). + 1. Hand off control: the OTHER machine becomes executor without refreshing + reaction-fire TU spent during the first half-round. 2. The turn counter increments on both machines. Gamemode 2: host=XCOM (first executor), client=aliens (second). @@ -50,6 +50,68 @@ def end_turn(gc): gc.ok({"cmd": "battle_action", "action": "end_turn_button"}) +def unit_state(gc, unit_id): + return next((u for u in battle(gc).get("units", []) + if u.get("id") == unit_id), None) + + +def alien_seat_unit(gc, owner): + return next((u for u in battle(gc).get("units", []) + if u.get("coop") == owner and not u.get("isOut") + and u.get("faction") != 2), None) + + +def set_tu_on_both(host, client, unit_id, tu): + """Deterministically model reaction-fire TU already spent on both copies.""" + for gc in (host, client): + gc.ok({"cmd": "battle_intent", "action": "turn", "unit": unit_id, + "tu": tu, "dry": True}) + + +def assert_unit_tu(fails, gc, unit_id, expected, label): + u = unit_state(gc, unit_id) + if not u: + _fail(fails, f"{label}: unit {unit_id} missing") + return False + if u.get("tu") != expected: + _fail(fails, f"{label}: unit {unit_id} TU={u.get('tu')}, expected {expected}") + return False + return True + + +def finish_pvp_round(host, client, alien, label): + """End the alien player's half-round and verify that neither peer ever + exposes the vanilla HOSTILE/AI phase.""" + end_turn(alien) + + for gc, tag in ((host, "host"), (client, "client")): + gc.wait_for( + f"{label} {tag} neutral turn screen", + lambda gc=gc: (PVP._top(gc) == "NextTurnState" + and battle(gc).get("side") == 2) or None, + timeout=30, interval=0.2) + + host.ok({"cmd": "dismiss_popup"}) + + host.wait_for( + f"{label} host player screen without hostile phase", + lambda: (PVP._top(host) == "NextTurnState" + and battle(host).get("side") == 0) or None, + timeout=30, interval=0.2) + client.wait_for( + f"{label} client advanced to player side", + lambda: (PVP._top(client) != "NextTurnState" + and battle(client).get("side") == 0) or None, + timeout=30, interval=0.2) + + host.ok({"cmd": "dismiss_popup"}) + client.wait_for( + f"{label} client player screen closed by host", + lambda: PVP._top(client) != "NextTurnState" or None, + timeout=30, interval=0.2) + time.sleep(2) + + def test_end_turn_gamemode_2(fails): """Gamemode 2: host=XCOM starts, ends turn, client=aliens takes over.""" print("\n--- end-turn gamemode 2 (host=XCOM -> client=aliens) ---") @@ -87,6 +149,15 @@ def test_end_turn_gamemode_2(fails): print("PASS gm2 init: host=XCOM executor, client=aliens waits") + alien = alien_seat_unit(client, 1) + if not alien: + _fail(fails, "gm2: no live client-owned alien for TU regression") + return + alien_id = alien["id"] + alien_max = alien["tuMax"] + alien_spent = max(1, alien_max // 3) + set_tu_on_both(host, client, alien_id, alien_spent) + # ---- end the XCOM turn ---------------------------------------------- end_turn(host) time.sleep(3) @@ -115,17 +186,35 @@ def test_end_turn_gamemode_2(fails): else: print(f"PASS gm2: client now commands {len(cs2)} alien unit(s)") - # In PvP gm2 after the XCOM turn ends: - # The client's alien units get TU reset via the turn-boundary code - # (connectionTCP.cpp:10190-10198). The client becomes activeSync. - # However, the client might not be the full executor in PvP mode. - # Validating: the client has selectable units with refreshed TU. + assert_unit_tu(fails, client, alien_id, alien_spent, + "gm2 XCOM->alien must preserve reaction TU") + + # In PvP gm2 after the XCOM turn ends, the client becomes the alien + # player without a TU refresh. Reaction-fire TU spent during XCOM's + # half-round must remain spent until the complete round ends. if not client_exec2: # In PvP, the turn boundary from XCOM→alien may not make the # client the executor in the same way as PvE. The key is: client # units are now selectable (they weren't before). pass + finish_pvp_round(host, client, client, "gm2") + + assert_unit_tu(fails, host, alien_id, alien_max, + "gm2 host copy after full-round refresh") + assert_unit_tu(fails, client, alien_id, alien_max, + "gm2 client alien after full-round refresh") + + if not battle(host).get("inBattle") or not battle(client).get("inBattle"): + _fail(fails, "gm2: battle ended after one complete round") + + # Reproduce the reported stale-host overwrite: no reaction TU is spent + # in this XCOM half-round, so the following handoff must stay at tuMax. + end_turn(host) + time.sleep(3) + assert_unit_tu(fails, client, alien_id, alien_max, + "gm2 second handoff must not restore stale alien TU") + except Exception as e: print(f"[ERROR] gm2: {e}") _fail(fails, str(e)) @@ -171,6 +260,15 @@ def test_end_turn_gamemode_3(fails): print("PASS gm3 init: client=XCOM executor, host=aliens waits") + alien = alien_seat_unit(host, 0) + if not alien: + _fail(fails, "gm3: no live host-owned alien for TU regression") + return + alien_id = alien["id"] + alien_max = alien["tuMax"] + alien_spent = max(1, alien_max // 3) + set_tu_on_both(host, client, alien_id, alien_spent) + # ---- end the XCOM turn ---------------------------------------------- end_turn(client) time.sleep(3) @@ -196,15 +294,21 @@ def test_end_turn_gamemode_3(fails): else: print(f"PASS gm3: host now commands {len(hs2)} alien unit(s)") + assert_unit_tu(fails, host, alien_id, alien_spent, + "gm3 XCOM->alien must preserve reaction TU") + # ---- end the alien turn (complete the first full round) ------------ # This second boundary is the saved-PvP2 regression: after displaying # NextTurnState, its vanilla faction tally used to call finishBattle # even though both PvP seats still had living units. - end_turn(host) - time.sleep(1) - if PVP._top(host) == "NextTurnState": - host.ok({"cmd": "dismiss_popup"}) - time.sleep(3) + # A completed PvP round must skip the vanilla HOSTILE/AI phase. Both + # peers first display NEUTRAL, then PLAYER; the host drives both copies. + finish_pvp_round(host, client, host, "gm3") + + assert_unit_tu(fails, host, alien_id, alien_max, + "gm3 host alien after full-round refresh") + assert_unit_tu(fails, client, alien_id, alien_max, + "gm3 client copy after full-round refresh") hb = battle(host) cb = battle(client) @@ -229,6 +333,11 @@ def test_end_turn_gamemode_3(fails): else: print("PASS gm3 full round: battle continues and control returns to XCOM") + end_turn(client) + time.sleep(3) + assert_unit_tu(fails, host, alien_id, alien_max, + "gm3 second handoff must keep full alien TU") + except Exception as e: print(f"[ERROR] gm3: {e}") _fail(fails, str(e)) diff --git a/tools/coop_test/test_skirmish_saved_battle_join.py b/tools/coop_test/test_skirmish_saved_battle_join.py index c53b8dceb..3864704cf 100644 --- a/tools/coop_test/test_skirmish_saved_battle_join.py +++ b/tools/coop_test/test_skirmish_saved_battle_join.py @@ -92,8 +92,15 @@ def main(): client.spawn() client.connect() - host.ok({"cmd": "load_save", "file": SAVE}) - host.wait_for("host loaded saved battle", lambda: in_battle(host), timeout=120) + # Use the real load-menu path. The raw TestServer `load_save` helper + # always installs a GeoscapeState after parsing and is therefore invalid + # for an `_autobattle_.asav` custom-battle save (it can crash before the + # next RPC). LoadGameState performs the production routing back into + # the restored battlescape; the host then uses its real pause-menu COOP + # action to open HostMenu. + host.ok({"cmd": "load_save_menu", "file": SAVE}) + host.wait_for("host loaded saved battle", lambda: in_battle(host), + timeout=120, interval=0.5) before = host.ok({"cmd": "get_coop"}) assert not before.get("sessionLocked"), ( "a freshly loaded custom battle already looks like a live session: " @@ -200,10 +207,32 @@ def main(): host.wait_for("alien host received turn", lambda: selectable(host) or None, timeout=30) end_turn(host) - time.sleep(1) - if session.states(host)[-1].endswith("NextTurnState"): - host.ok({"cmd": "dismiss_popup"}) - time.sleep(3) + + for gc, tag in ((host, "host"), (client, "client")): + gc.wait_for( + f"saved PvP2 {tag} neutral turn screen", + lambda gc=gc: (session.states(gc)[-1].endswith("NextTurnState") + and battle(gc).get("side") == 2) or None, + timeout=30, interval=0.2) + + # First click starts/runs NEUTRAL. The client advances to PLAYER + # without retaining a second popup; the host displays PLAYER and needs + # the normal second click before control is released. + host.ok({"cmd": "dismiss_popup"}) + host.wait_for( + "saved PvP2 host player turn screen", + lambda: (session.states(host)[-1].endswith("NextTurnState") + and battle(host).get("side") == 0) or None, + timeout=30, interval=0.2) + client.wait_for( + "saved PvP2 client advanced to player side", + lambda: (not session.states(client)[-1].endswith("NextTurnState") + and battle(client).get("side") == 0) or None, + timeout=30, interval=0.2) + host.ok({"cmd": "dismiss_popup"}) + client.wait_for("saved PvP2 client XCOM control returned", + lambda: selectable(client) or None, + timeout=30, interval=0.2) hb, cb = battle(host), battle(client) assert hb.get("inBattle") and cb.get("inBattle"), ( From c4b641b63e448ee0ca3cd9175f386727efc4073d Mon Sep 17 00:00:00 2001 From: xcomcoopdev Date: Thu, 3 Sep 2026 15:51:16 +0300 Subject: [PATCH 3/3] Fix saved PVE2 custom battle turn initialization A resumed PVE2 custom battle was treated like a newly created battle because the session reset cleared pve2_init. When the restored BattlescapeState started thinking, the host replayed PVE2's one-time initial AI hand-off and advanced the saved PLAYER side to HOSTILE before either player pressed END TURN. Mark the initial PVE2 hand-off as already consumed on both the host and client custom-battle resume paths. Do this while the resume UI still covers the battlescape so the restored state cannot advance prematurely. Add a deterministic saved-PVE2 harness regression using a Battleship fixture and a fixed RNG seed. The test verifies that: - the saved battle resumes on its original shared PLAYER side; - both processes restore gamemode 4 with parallel turns active; - loading does not start the opponent AI automatically; - both players can ready the shared side normally; - host turn-screen dismissal releases the client where applicable; - the AI cycle completes and returns both players to the next PLAYER side. --- src/CoopMod/connectionTCP.cpp | 10 + src/Menu/LoadGameState.cpp | 7 + .../test_skirmish_saved_pve2_end_turn.py | 246 ++++++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 tools/coop_test/test_skirmish_saved_pve2_end_turn.py diff --git a/src/CoopMod/connectionTCP.cpp b/src/CoopMod/connectionTCP.cpp index a44bbcc76..58553566c 100644 --- a/src/CoopMod/connectionTCP.cpp +++ b/src/CoopMod/connectionTCP.cpp @@ -12045,6 +12045,16 @@ void connectionTCP::onTCPMessage(std::string stateString, Json::Value obj) // its host already clicked RESUME back in the lobby. bool inBattleResume = false; const bool customBattleResume = connectionTCP::session.customBattleResumeLoading; + // A loaded PVE2 Custom Battle has already crossed the mode's one-time + // new-battle hand-off. Session teardown deliberately clears pve2_init, + // but leaving it false on resume makes BattlescapeState::think treat the + // restored PLAYER side as a fresh battle and call endTurnCoop() before + // either seat presses END TURN. Mark it consumed while the resume modal + // still covers BattlescapeState, before popping back to the battle. + if (customBattleResume && getCoopGamemode() == 4) + { + pve2_init = true; + } if (_game->getSavedGame() && _game->getSavedGame()->getSavedBattle() != nullptr && (_game->getCoopMod()->getCoopCampaign() == true || customBattleResume)) { diff --git a/src/Menu/LoadGameState.cpp b/src/Menu/LoadGameState.cpp index 9dd8cdd58..b3f5f02ae 100644 --- a/src/Menu/LoadGameState.cpp +++ b/src/Menu/LoadGameState.cpp @@ -518,6 +518,13 @@ void LoadGameState::think() _game->getCoopMod()->sendTCPPacketData(done.toStyledString()); if (customBattleResume) { + // The streamed save is an already-running battle. Do not let + // PVE2's one-time NEW-battle initialization hand it straight + // back to the AI when BattlescapeState begins thinking. + if (_game->getCoopMod()->getCoopGamemode() == 4) + { + _game->getCoopMod()->pve2_init = true; + } if (_game->getCoopMod()->parallelTurnActive()) { connectionTCP::resetActionArbiter(true); diff --git a/tools/coop_test/test_skirmish_saved_pve2_end_turn.py b/tools/coop_test/test_skirmish_saved_pve2_end_turn.py new file mode 100644 index 000000000..b0af0e041 --- /dev/null +++ b/tools/coop_test/test_skirmish_saved_pve2_end_turn.py @@ -0,0 +1,246 @@ +"""Regression: a resumed PVE2 custom battle must cross the AI turn boundary. + +The fresh-battle path is already covered by the parallel PVE2 suites. This +test deliberately saves that battle, replaces both processes, loads the custom +battle through LoadGameState, hosts it through CONTINUE BATTLE, and then presses +the real END TURN button on both seats. The test closes every host boundary +screen and requires that click to release the client's matching screen; neither +copy may remain parked there, and the following shared PLAYER side must open on +both machines. + +Run: python tools/coop_test/test_skirmish_saved_pve2_end_turn.py +Exit 0 = pass; 2 = failure. +""" + +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from harness import GameClient, make_user_dir +import pvp_fixture as PVP +import session +import test_skirmish_flow as SK + + +SAVE = "skirmish_saved_pve2_end_turn.sav" +PORT = "47997" +OPTIONS = {"EnableCoopParallelTurns": True} +MISSION_BATTLESHIP = 6 +SEED = 20260903 + + +def write_battle_fixture(user_dir): + """Avoid the one-unit Small Scout fixture ending during startup AI.""" + path = os.path.join(user_dir, "xcom1", "battle.cfg") + with open(path, "w", encoding="utf-8") as stream: + stream.write(f"mission: {MISSION_BATTLESHIP}\n") + + +def top(gc): + states = session.states(gc) + return states[-1].split("::")[-1] if states else "" + + +def battle(gc): + return gc.cmd({"cmd": "battle_state"}) + + +def in_battle(gc): + return battle(gc).get("inBattle") or None + + +def battle_summary(gc): + b = battle(gc) + return {key: b.get(key) for key in ( + "inBattle", "coopGamemode", "parallelActive", "host", "side", + "turn", "sideSeq", "battleInit", "activeSync", "playerTurn")} + + +def parallel_summary(gc): + p = gc.cmd({"cmd": "parallel_state"}) + return {key: p.get(key) for key in ( + "parallelActive", "sideSeq", "readySeats", "autoSeats", "allReady", + "commitBlocked", "admitBlocked", "sideCommit")} + + +def make_pve2_fixture(host, client): + PVP.start_pvp_skirmish_lobby(host, client, PORT, alien_player="client") + host_row = PVP.row_for(host, "HostPlayer") + mode = host.ok({"cmd": "lobby_set_team", "row": host_row, + "team": "Alien"}).get("gamemode") + assert mode == 4, f"fixture did not select PVE2/gamemode 4: {mode}" + + host.ok({"cmd": "lobby_action"}) + host.wait_for("host at battle settings", + lambda: not session.has_state(host, "LobbyMenu") or None) + host.ok({"cmd": "set_seed", "seed": SEED}) + client.ok({"cmd": "set_seed", "seed": SEED + 1}) + host.ok({"cmd": "newbattle_ok"}) + for gc, tag in ((host, "host"), (client, "client")): + gc.wait_for(f"seed {tag} entered battle", lambda gc=gc: in_battle(gc), + timeout=180, interval=0.5) + + # Keep the two startup stacks moving together. PVE2 deliberately begins + # with an opponent-AI cycle; draining one process all the way before even + # entering the other would manufacture a startup skew unrelated to resume. + deadline = time.time() + 180 + while time.time() < deadline: + for gc in (host, client): + state = top(gc) + if state == "BriefingState": + gc.ok({"cmd": "close_briefing"}) + elif state == "InventoryState": + gc.ok({"cmd": "battle_inventory", "action": "ok"}) + elif state == "NextTurnState": + gc.ok({"cmd": "dismiss_popup"}) + elif state != "BattlescapeState": + gc.cmd({"cmd": "dismiss_popup"}) + hb, cb = battle(host), battle(client) + if (hb.get("battleInit") and cb.get("battleInit") + and hb.get("side") == 0 and cb.get("side") == 0 + and top(host) == "BattlescapeState" + and top(client) == "BattlescapeState"): + live_players = [u for u in hb.get("units", []) + if u.get("faction") == 0 and not u.get("isOut")] + assert len(live_players) >= 2, ( + "deterministic PVE2 fixture has too few surviving player units: " + f"{[u.get('id') for u in live_players]}") + return + time.sleep(0.25) + raise TimeoutError( + f"seed PVE2 did not initialize: host={session.states(host)} {battle(host)}; " + f"client={session.states(client)} {battle(client)}") + + +def resume_saved_battle(host, client): + host.ok({"cmd": "load_save_menu", "file": SAVE}) + host.wait_for("host loaded saved PVE2 battle", lambda: in_battle(host), + timeout=120, interval=0.5) + host.ok({"cmd": "open_pause_coop"}) + host.wait_for("loaded battle opened HostMenu", + lambda: session.has_state(host, "HostMenu"), timeout=30) + host.ok({"cmd": "host_menu_host", "visibility": 0, + "server": "SavedPVE2", "port": PORT, "player": "HostPlayer"}) + host.wait_for("host entered resume lobby", + lambda: session.has_state(host, "LobbyMenu"), timeout=30) + + SK.skirmish_client_at_browser(client) + client.ok({"cmd": "join_tcp", "ip": "127.0.0.1", "port": PORT, + "player": "ClientPlayer"}) + for gc, tag in ((host, "host"), (client, "client")): + gc.wait_for(f"{tag} joined resume lobby", + lambda gc=gc: session.has_state(gc, "Profile"), timeout=60) + gc.ok({"cmd": "profile_ok"}) + + host.ok({"cmd": "lobby_action"}) + for gc, tag in ((host, "host"), (client, "client")): + gc.wait_for(f"resumed {tag} entered battle", lambda gc=gc: in_battle(gc), + timeout=240, interval=0.25) + + # A mid-player-side save must resume on that same side. In particular, + # loading must not replay PVE2's one-time NEW-battle AI hand-off before the + # two seats have pressed END TURN. + deadline = time.time() + 120 + while time.time() < deadline: + hb, cb = battle(host), battle(client) + assert hb.get("side") == 0 and cb.get("side") == 0, ( + "loading replayed the new-PVE2 initial AI hand-off: " + f"host side={hb.get('side')} turn={hb.get('turn')} " + f"battleInit={hb.get('battleInit')} sideSeq={hb.get('sideSeq')}; " + f"client side={cb.get('side')} turn={cb.get('turn')} " + f"battleInit={cb.get('battleInit')} sideSeq={cb.get('sideSeq')}") + if (hb.get("battleInit") and cb.get("battleInit") + and hb.get("coopGamemode") == 4 and cb.get("coopGamemode") == 4 + and hb.get("parallelActive") and cb.get("parallelActive")): + return + time.sleep(0.25) + raise TimeoutError( + f"resumed PVE2 did not initialize: host={battle(host)} client={battle(client)}") + + +def main(): + host_dir = make_user_dir("saved_pve2_end_host", options=OPTIONS) + seed_client_dir = make_user_dir("saved_pve2_end_seed", options=OPTIONS) + write_battle_fixture(host_dir) + write_battle_fixture(seed_client_dir) + host = GameClient("host", 48830, host_dir) + client = GameClient("client", 48831, seed_client_dir) + failure = None + try: + host.spawn(); host.connect() + client.spawn(); client.connect() + make_pve2_fixture(host, client) + host.ok({"cmd": "save_game", "file": SAVE}) + save_path = os.path.join(host_dir, "xcom1", SAVE) + assert os.path.exists(save_path), f"fixture save was not written: {save_path}" + print("PASS fixture: saved a live PVE2 custom battle") + + host.shutdown(); client.shutdown() + host = GameClient("host", 48832, host_dir) + client = GameClient("client", 48833, + make_user_dir("saved_pve2_end_clean", options=OPTIONS)) + host.spawn(); host.connect() + client.spawn(); client.connect() + resume_saved_battle(host, client) + print("PASS resume: both machines restored gamemode 4 parallel battle") + + start_turn = battle(host).get("turn") + client.ok({"cmd": "battle_action", "action": "end_turn_button"}) + host.ok({"cmd": "battle_action", "action": "end_turn_button"}) + + # Dismiss each host-owned boundary exactly as a player does in the UI. + # The host click must mirror to the client; never dismiss the client + # independently, because that would hide a broken click_close packet. + deadline = time.time() + 180 + while time.time() < deadline: + hb, cb = battle(host), battle(client) + if (top(host) == "BattlescapeState" + and top(client) == "BattlescapeState" + and hb.get("side") == 0 and cb.get("side") == 0 + and hb.get("turn", 0) > start_turn + and hb.get("battleInit") and cb.get("battleInit")): + break + if top(host) == "NextTurnState": + client_had_screen = top(client) == "NextTurnState" + host.ok({"cmd": "dismiss_popup"}) + if client_had_screen: + host.wait_for( + "host click mirrored off the client End Turn screen", + lambda: top(client) != "NextTurnState" or None, + timeout=30, interval=0.2) + else: + # A desync notice or an ordinary informational popup is not the + # End Turn screen under test. Clear it symmetrically so it + # cannot prevent the post-turn battleInit handshake from running. + for gc in (host, client): + if top(gc) not in ("BattlescapeState", "NextTurnState"): + gc.cmd({"cmd": "dismiss_popup"}) + time.sleep(0.5) + else: + raise TimeoutError( + "saved PVE2 remained in the end-turn/AI boundary: " + f"host stack={session.states(host)} battle={battle_summary(host)}; " + f"client stack={session.states(client)} battle={battle_summary(client)}") + + print("PASS regression: saved PVE2 completed AI turn without a stuck End Turn screen") + except Exception as exc: + failure = exc + print(f"[FAIL] {exc}") + for tag, gc in (("host", host), ("client", client)): + try: + print(f" {tag} stack: {session.states(gc)}") + print(f" {tag} battle: {battle_summary(gc)}") + print(f" {tag} parallel: {parallel_summary(gc)}") + except Exception as debug_exc: + print(f" {tag} debug failed: {debug_exc}") + finally: + host.shutdown(); client.shutdown() + + if failure: + raise SystemExit(2) + print("ALL SAVED PVE2 END-TURN TESTS PASSED") + + +if __name__ == "__main__": + main()