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 4d3e2ab62..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; @@ -622,7 +630,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..58553566c 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") { @@ -11839,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. } @@ -12008,8 +12044,19 @@ 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; + // 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) + && (_game->getCoopMod()->getCoopCampaign() == true || customBattleResume)) { for (auto* st : _game->getStates()) { @@ -12030,6 +12077,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 +12493,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 +12509,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, @@ -14147,6 +14197,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/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..b3f5f02ae 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,32 @@ void LoadGameState::think() Json::Value done; done["state"] = "close_load_progress"; _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); + _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/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 81a2ea13e..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,6 +294,50 @@ 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. + # 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) + 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") + + 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 new file mode 100644 index 000000000..3864704cf --- /dev/null +++ b/tools/coop_test/test_skirmish_saved_battle_join.py @@ -0,0 +1,268 @@ +"""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() + + # 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: " + 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) + + 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"), ( + "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() 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()