Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/CoopMod/TestServer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<ConfirmCydoniaState>(_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
Expand Down
15 changes: 15 additions & 0 deletions src/CoopMod/connectionTCP.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -119,6 +120,7 @@ std::string mapData = "";
int _hostSpace;

ConfirmLandingState* _landing;
ConfirmCydoniaState* _cydonia;
NewBattleState* _battleState;
GeoscapeState* _geo;
Craft* _selectedCraft;
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/CoopMod/connectionTCP.h
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ class SavedGame;
class BattleUnit;
class VoteMenu;
class ConfirmLandingState;
class ConfirmCydoniaState;
class NewBattleState;
class GeoscapeState;
class MissionSite;
Expand Down Expand Up @@ -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();
Expand Down
75 changes: 73 additions & 2 deletions src/Geoscape/ConfirmCydoniaState.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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);
Expand All @@ -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));
}
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/Geoscape/ConfirmCydoniaState.h
Original file line number Diff line number Diff line change
Expand Up @@ -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();

};

Expand Down
122 changes: 122 additions & 0 deletions tools/coop_test/test_cydonia_coop_start.py
Original file line number Diff line number Diff line change
@@ -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()
Loading