From 5ad4649f504b5afa4aaa9dcd35b70b50f96fe7c2 Mon Sep 17 00:00:00 2001 From: xcomcoopdev Date: Sun, 30 Aug 2026 08:17:59 +0300 Subject: [PATCH] fix: synchronize Cydonia mission start in coop campaigns --- src/CoopMod/TestServer.cpp | 43 ++++++++ src/CoopMod/connectionTCP.cpp | 15 +++ src/CoopMod/connectionTCP.h | 2 + src/Geoscape/ConfirmCydoniaState.cpp | 75 ++++++++++++- src/Geoscape/ConfirmCydoniaState.h | 2 + tools/coop_test/test_cydonia_coop_start.py | 122 +++++++++++++++++++++ 6 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 tools/coop_test/test_cydonia_coop_start.py diff --git a/src/CoopMod/TestServer.cpp b/src/CoopMod/TestServer.cpp index 11caae04e7..cfec163295 100644 --- a/src/CoopMod/TestServer.cpp +++ b/src/CoopMod/TestServer.cpp @@ -41,6 +41,7 @@ #include "../Geoscape/MonthlyReportState.h" #include "../Geoscape/MissionDetectedState.h" #include "../Geoscape/ConfirmLandingState.h" +#include "../Geoscape/ConfirmCydoniaState.h" #include "../Geoscape/CraftPatrolState.h" #include "../Savegame/AlienBase.h" #include "../Ufopaedia/ArticleState.h" @@ -4510,6 +4511,48 @@ std::string TestServer::execute(const std::string& line) resp["ok"] = true; } } + else if (cmd == "open_cydonia") + { + // Exercise the real final-mission confirmation state without requiring a + // months-long research setup. The requested craft still supplies the real + // campaign roster, equipment and BattlescapeGenerator input. + int craftId = req.get("craft_id", -1).asInt(); + Craft* craft = nullptr; + SavedGame* sg = _game->getSavedGame(); + if (sg) + { + for (auto* base : *sg->getBases()) + { + for (auto* candidate : *base->getCrafts()) + { + if (candidate->getId() == craftId) + { + craft = candidate; + break; + } + } + if (craft) break; + } + } + if (!craft) + resp["error"] = "craft id not found"; + else + { + _game->pushState(new ConfirmCydoniaState(craft)); + resp["ok"] = true; + } + } + else if (cmd == "confirm_cydonia") + { + ConfirmCydoniaState* cc = findState(_game); + if (!cc) + resp["error"] = "no ConfirmCydoniaState on stack"; + else + { + cc->btnYesClick(nullptr); + resp["ok"] = true; + } + } else if (cmd == "coop_mission_start") { // SEPARATE-mode two-world-merge battle entry: the host confirms the diff --git a/src/CoopMod/connectionTCP.cpp b/src/CoopMod/connectionTCP.cpp index 51b282f49f..0d05e34b57 100644 --- a/src/CoopMod/connectionTCP.cpp +++ b/src/CoopMod/connectionTCP.cpp @@ -52,6 +52,7 @@ #include "../Menu/NewGameState.h" #include "../Menu/LoadGameState.h" #include "../Geoscape/GeoscapeState.h" +#include "../Geoscape/ConfirmCydoniaState.h" #include "../Geoscape/Globe.h" #include "../Geoscape/BaseNameState.h" #include "../Geoscape/BuildNewBaseState.h" @@ -119,6 +120,7 @@ std::string mapData = ""; int _hostSpace; ConfirmLandingState* _landing; +ConfirmCydoniaState* _cydonia; NewBattleState* _battleState; GeoscapeState* _geo; Craft* _selectedCraft; @@ -11538,6 +11540,12 @@ void connectionTCP::setClientSoldiers() { _landing->startCoopMission(); } + // Cydonia has its own confirmation state and never visits ConfirmLandingState. + // Resume the final-mission generator after the normal SEPARATE craft merge. + else if (_cydonia && getCoopCampaign() == true) + { + _cydonia->startCoopMission(); + } // coop battle (pve) else if (_battleState) { @@ -11903,6 +11911,13 @@ void connectionTCP::joinDirectLanUDP(std::string ipaddress, std::string str_port void connectionTCP::setConfirmLandingState(ConfirmLandingState* landing) { _landing = landing; + _cydonia = nullptr; +} + +void connectionTCP::setConfirmCydoniaState(ConfirmCydoniaState* cydonia) +{ + _cydonia = cydonia; + _landing = nullptr; } // coop diff --git a/src/CoopMod/connectionTCP.h b/src/CoopMod/connectionTCP.h index d79d261353..bbbb19304f 100644 --- a/src/CoopMod/connectionTCP.h +++ b/src/CoopMod/connectionTCP.h @@ -186,6 +186,7 @@ class SavedGame; class BattleUnit; class VoteMenu; class ConfirmLandingState; +class ConfirmCydoniaState; class NewBattleState; class GeoscapeState; class MissionSite; @@ -546,6 +547,7 @@ class connectionTCP // coop // battle states void setConfirmLandingState(ConfirmLandingState* landing); + void setConfirmCydoniaState(ConfirmCydoniaState* cydonia); void setNewBattleState(NewBattleState* battlesate); void setGeoscapeState(GeoscapeState* base_geo); NewBattleState* getNewBattleState(); diff --git a/src/Geoscape/ConfirmCydoniaState.cpp b/src/Geoscape/ConfirmCydoniaState.cpp index e02d35096c..5d699cd2f5 100644 --- a/src/Geoscape/ConfirmCydoniaState.cpp +++ b/src/Geoscape/ConfirmCydoniaState.cpp @@ -26,8 +26,14 @@ #include "../Battlescape/BriefingState.h" #include "../Savegame/SavedBattleGame.h" #include "../Savegame/SavedGame.h" +#include "../Savegame/Craft.h" +#include "../Savegame/Base.h" +#include "../Savegame/Soldier.h" +#include "../Savegame/Vehicle.h" #include "../Mod/AlienDeployment.h" #include "../Engine/Options.h" +#include "../CoopMod/connectionTCP.h" +#include "../CoopMod/CoopState.h" namespace OpenXcom { @@ -82,8 +88,66 @@ ConfirmCydoniaState::~ConfirmCydoniaState() */ void ConfirmCydoniaState::btnYesClick(Action *) { + if (connectionTCP::getCoopStatic()) + { + _game->getCoopMod()->setSelectedCraft(_craft); + _game->getCoopMod()->setConfirmCydoniaState(this); + + if (_game->getCoopMod()->isSharedCampaign()) + { + // SHARED owns one world, so generate Cydonia once on the host and stream + // that authoritative battle instead of running the separate-world merge. + _game->getCoopMod()->setHost(true); + for (auto* soldier : *_craft->getBase()->getSoldiers()) + { + if (soldier->getCraft() != _craft) + continue; + int owner = soldier->getOwnerPlayerId(); + soldier->setCoop((owner == 0 || owner == 999) ? 0 : 1); + soldier->setCoopBase(-1); + } + for (auto* vehicle : *_craft->getVehicles()) + { + vehicle->setCoop(0); + vehicle->setCoopBase(-1); + } + startCoopMission(); + return; + } + + if (!_game->getCoopMod()->getHost()) + { + // The existing separate-campaign merge is host-authoritative, so hand + // authority to the player who selected Cydonia before starting it. + Json::Value root; + root["state"] = "changeHost"; + _game->getCoopMod()->sendTCPPacketData(root.toStyledString()); + _game->getCoopMod()->setHost(true); + } + + _game->pushState(new CoopState(88)); + return; + } + _game->popState(); _game->popState(); + startCoopMission(); +} + +void ConfirmCydoniaState::startCoopMission() +{ + // The shared path can be re-entered by delayed network callbacks. Never + // replace an already-streamed Mars map with a second random generation. + if (_game->getCoopMod()->isSharedCampaign() && _game->getSavedGame() + && _game->getSavedGame()->getSavedBattle()) + { + return; + } + + if (connectionTCP::getCoopStatic()) + { + _game->getCoopMod()->coopInventory = true; + } SavedBattleGame *bgame = new SavedBattleGame(_game->getMod(), _game->getLanguage()); _game->getSavedGame()->setBattleGame(bgame); @@ -101,8 +165,15 @@ void ConfirmCydoniaState::btnYesClick(Action *) bgen.setCraft(_craft); bgen.run(); - _game->pushState(new BriefingState(_craft)); - + if (connectionTCP::getCoopStatic()) + { + BriefingState* briefing = new BriefingState(_craft); + briefing->setupCoop(); + } + else + { + _game->pushState(new BriefingState(_craft)); + } } /** diff --git a/src/Geoscape/ConfirmCydoniaState.h b/src/Geoscape/ConfirmCydoniaState.h index af08d44328..7c0a84296c 100644 --- a/src/Geoscape/ConfirmCydoniaState.h +++ b/src/Geoscape/ConfirmCydoniaState.h @@ -47,6 +47,8 @@ class ConfirmCydoniaState : public State void btnNoClick(Action *action); /// Handler for clicking the Cydonia mission button. void btnYesClick(Action *action); + /// Generates Cydonia after the separate-campaign craft merge has completed. + void startCoopMission(); }; diff --git a/tools/coop_test/test_cydonia_coop_start.py b/tools/coop_test/test_cydonia_coop_start.py new file mode 100644 index 0000000000..1b355ae17b --- /dev/null +++ b/tools/coop_test/test_cydonia_coop_start.py @@ -0,0 +1,122 @@ +"""Regression: Cydonia must stream its Mars map to both campaign players. + +ConfirmCydoniaState is a separate final-mission entry point; it does not pass +through ConfirmLandingState. Cover both campaign models so this path cannot +silently fall back to a host-only battle again. + +Run: python tools/coop_test/test_cydonia_coop_start.py +Exit 0 = pass; 2 = failure. +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from harness import GameClient, make_user_dir +import session + + +def _states(gc): + return gc.cmd({"cmd": "get_state"})["states"] + + +def _has(gc, name): + return any(name in state for state in _states(gc)) + + +def _base(gc): + return next(b for b in gc.ok({"cmd": "geo_state"})["bases"] + if not b.get("coopBase") and not b.get("coopIcon")) + + +def _craft_and_soldiers(gc): + base = _base(gc) + craft = next(c for c in base["crafts"] if "SKYRANGER" in c["type"]) + roster = gc.ok({"cmd": "get_soldiers"})["bases"] + soldiers = next(b["soldiers"] for b in roster + if not b.get("coopBaseFlag")) + return craft["id"], [s["id"] for s in soldiers] + + +def _seat(gc, craft_id, soldier_ids): + for sid in soldier_ids: + gc.ok({"cmd": "craft_assign", "craft_id": craft_id, + "soldier_id": sid, "on": False}) + for sid in soldier_ids[:2]: + gc.ok({"cmd": "craft_assign", "craft_id": craft_id, + "soldier_id": sid, "on": True}) + + +def _aboard(gc, craft_id): + return sorted(s["id"] for b in gc.ok({"cmd": "get_soldiers"})["bases"] + for s in b["soldiers"] if s["craftId"] == craft_id) + + +def run_mode(mode, test_ports, coop_port): + print(f"\n===== Cydonia {mode.upper()} =====") + host = GameClient("host", test_ports[0], make_user_dir(f"cydonia_{mode}_host")) + client = GameClient("client", test_ports[1], make_user_dir(f"cydonia_{mode}_client")) + try: + host.spawn(); host.connect() + client.spawn(); client.connect() + session.new_campaign(host, client, port=str(coop_port), campaign_mode=mode) + + host_craft, host_roster = _craft_and_soldiers(host) + _seat(host, host_craft, host_roster) + if mode == "coop": + client_craft, client_roster = _craft_and_soldiers(client) + _seat(client, client_craft, client_roster) + else: + # Give the shared squad one unit from each seat and wait until the + # replicated ownership/assignment commands have settled. + for gc in (host, client): + gc.ok({"cmd": "set_soldier_owner", "soldier_id": host_roster[0], "owner": 0}) + gc.ok({"cmd": "set_soldier_owner", "soldier_id": host_roster[1], "owner": 1}) + host.wait_for("shared squad seated", + lambda: (_aboard(host, host_craft) == sorted(host_roster[:2])) or None, + timeout=30) + + host.ok({"cmd": "open_cydonia", "craft_id": host_craft}) + host.wait_for("Cydonia confirmation", + lambda: _has(host, "ConfirmCydoniaState") or None) + host.ok({"cmd": "confirm_cydonia"}) + + battles = [] + for gc in (host, client): + battles.append(gc.wait_for( + f"{gc.name} loaded Cydonia", + lambda gc=gc: (lambda b: b if b.get("inBattle") else None)( + gc.cmd({"cmd": "battle_state"})), + timeout=180, interval=1.0)) + gc.wait_for(f"{gc.name} briefing", + lambda gc=gc: _has(gc, "BriefingState") or None, + timeout=120, interval=0.5) + + assert battles[0]["missionType"] == battles[1]["missionType"], battles + assert battles[0]["mapSizeXYZ"] == battles[1]["mapSizeXYZ"], battles + assert battles[0]["mapFingerprint"] == battles[1]["mapFingerprint"], battles + print(f"PASS {mode}: both players loaded {battles[0]['missionType']} " + f"with identical Mars map fingerprint {battles[0]['mapFingerprint']}") + finally: + host.shutdown(); client.shutdown() + + +def main(): + failures = [] + for mode, ports, coop_port in ( + ("coop", (48930, 48931), 48130), + ("shared", (48932, 48933), 48132)): + try: + run_mode(mode, ports, coop_port) + except Exception as exc: + print(f"[FAIL] {mode}: {exc}") + failures.append(f"{mode}: {exc}") + + if failures: + print("\nCydonia co-op regression failures:", failures) + sys.exit(2) + print("\nCydonia co-op start passed in SEPARATE and SHARED campaigns.") + + +if __name__ == "__main__": + main()