From 76e6a28f74e9090989488219189b44111382d1da Mon Sep 17 00:00:00 2001 From: Bentley Davis <10065854+NonPolynomialTim@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:32:21 -0400 Subject: [PATCH] fix(coop): host re-validate craft capacity (#121) In SHARED either player can edit any craft, so the client-side capacity gate is not enough: a stale-view request that passes the initiator's gate reached the host, which applied it without re-checking -> an over-capacity craft that can desync or fault a later deployment. - Factor CraftWeaponsState's four inline capacity checks into a pure static equipCapacityError() (byte-identical math) and call it from both lstWeaponsClick and craftRearmValidate. - soldierArmorValidate now re-runs Craft::validateArmorChange host-side, mirroring SoldierArmorState::lstArmorClick. Harness fidelity: harnessEquip/harnessSetArmor now run the client gate (surfaced as resp["gate"]); a new `force` param bypasses it to model a stale replica whose gate passed. New regression test test_shared_capacity_host_gate.py drives an over-capacity craft_rearm / soldier_armor from a replica with force and asserts the host REJECTS it (shared_fail reason) with both worlds left unchanged and equal. Co-Authored-By: Claude Opus 4.8 --- src/Basescape/CraftWeaponsState.cpp | 125 ++++---- src/Basescape/CraftWeaponsState.h | 14 +- src/Basescape/SoldierArmorState.cpp | 13 +- src/Basescape/SoldierArmorState.h | 7 +- src/CoopMod/SharedEcon.cpp | 31 +- src/CoopMod/TestServer.cpp | 59 +++- .../test_shared_capacity_host_gate.py | 285 ++++++++++++++++++ 7 files changed, 443 insertions(+), 91 deletions(-) create mode 100644 tools/coop_test/test_shared_capacity_host_gate.py diff --git a/src/Basescape/CraftWeaponsState.cpp b/src/Basescape/CraftWeaponsState.cpp index a8e6bd854..ffa588c4c 100644 --- a/src/Basescape/CraftWeaponsState.cpp +++ b/src/Basescape/CraftWeaponsState.cpp @@ -171,45 +171,49 @@ void CraftWeaponsState::lstWeaponsClick(Action *) const RuleCraftWeapon* refWeapon = _weapons[_lstWeapons->getSelectedRow()]; const RuleCraftWeapon* currWeapon = current ? current->getRules() : nullptr; + + std::string capError = equipCapacityError(_game->getMod(), _craft, refWeapon, currWeapon); + if (!capError.empty()) + { + _game->popState(); + _game->pushState(new ErrorMessageState( + tr(capError), + _palette, + _game->getMod()->getInterface("craftWeapons")->getElement("errorMessage")->color, + "BACK14.SCR", + _game->getMod()->getInterface("craftWeapons")->getElement("errorPalette")->color) + ); + return; + } + + equipSelectedWeapon(_weapons[_lstWeapons->getSelectedRow()]); + _game->popState(); +} + +/** + * Pure capacity gate shared by the UI (lstWeaponsClick) and the SHARED host validator + * (issue #121: craftRearmValidate). Replicates vanilla's four craft-capacity checks for + * mounting @a refWeapon (nullptr = dismount) in place of @a currWeapon (nullptr = empty + * slot), from the weapons' getBonusStats() deltas. Returns "" if the change is within + * every limit, else the STR_ error id vanilla would show. The math is byte-identical to + * the old inline block, so host and client always agree. + */ +std::string CraftWeaponsState::equipCapacityError(Mod *mod, Craft *craft, + const RuleCraftWeapon *refWeapon, const RuleCraftWeapon *currWeapon) +{ { int refCapBonus1 = refWeapon ? refWeapon->getBonusStats().soldiers : 0; int currCapBonus1 = currWeapon ? currWeapon->getBonusStats().soldiers : 0; int diff1 = (refCapBonus1 - currCapBonus1); - if (diff1) - { - if ((_craft->getMaxUnitsRaw() - _craft->getSpaceUsed() + diff1) < 0) - { - _game->popState(); - _game->pushState(new ErrorMessageState( - tr("STR_NOT_ENOUGH_CARGO_SPACE"), - _palette, - _game->getMod()->getInterface("craftWeapons")->getElement("errorMessage")->color, - "BACK14.SCR", - _game->getMod()->getInterface("craftWeapons")->getElement("errorPalette")->color) - ); - return; - } - } + if (diff1 && (craft->getMaxUnitsRaw() - craft->getSpaceUsed() + diff1) < 0) + return "STR_NOT_ENOUGH_CARGO_SPACE"; } { int refCapBonus2 = refWeapon ? refWeapon->getBonusStats().vehicles : 0; int currCapBonus2 = currWeapon ? currWeapon->getBonusStats().vehicles : 0; int diff2 = (refCapBonus2 - currCapBonus2); - if (diff2) - { - if ((_craft->getMaxVehiclesAndLargeSoldiersRaw() - _craft->getNumVehiclesAndLargeSoldiers() + diff2) < 0) - { - _game->popState(); - _game->pushState(new ErrorMessageState( - tr("STR_NOT_ENOUGH_HWP_CAPACITY"), - _palette, - _game->getMod()->getInterface("craftWeapons")->getElement("errorMessage")->color, - "BACK14.SCR", - _game->getMod()->getInterface("craftWeapons")->getElement("errorPalette")->color) - ); - return; - } - } + if (diff2 && (craft->getMaxVehiclesAndLargeSoldiersRaw() - craft->getNumVehiclesAndLargeSoldiers() + diff2) < 0) + return "STR_NOT_ENOUGH_HWP_CAPACITY"; } { int refCapBonus3 = refWeapon ? refWeapon->getBonusStats().maxItems : 0; @@ -225,58 +229,25 @@ void CraftWeaponsState::lstWeaponsClick(Action *) double totalItemStorageSize = 0.0; if (diff3 || diff4_b) { - for (auto& itemType : _game->getMod()->getItemsList()) + for (auto& itemType : mod->getItemsList()) { - RuleItem* rule = _game->getMod()->getItem(itemType); + RuleItem* rule = mod->getItem(itemType); Unit* isVehicle = rule->getVehicleUnit(); - int cQty = 0; - if (isVehicle) + if (!isVehicle) { - cQty = _craft->getVehicleCount(itemType); - } - else - { - cQty = _craft->getItems()->getItem(rule); + int cQty = craft->getItems()->getItem(rule); totalItems += cQty; totalItemStorageSize += cQty * rule->getSize(); } } } - if (diff3) - { - if ((_craft->getMaxItemsRaw() - totalItems + diff3) < 0) - { - _game->popState(); - _game->pushState(new ErrorMessageState( - tr("STR_NOT_ENOUGH_STORAGE_SPACE_1"), - _palette, - _game->getMod()->getInterface("craftWeapons")->getElement("errorMessage")->color, - "BACK14.SCR", - _game->getMod()->getInterface("craftWeapons")->getElement("errorPalette")->color) - ); - return; - } - } - if (diff4_b) - { - if ((_craft->getMaxStorageSpaceRaw() - totalItemStorageSize + diff4) < 0.0) - { - _game->popState(); - _game->pushState(new ErrorMessageState( - tr("STR_NOT_ENOUGH_STORAGE_SPACE_2"), - _palette, - _game->getMod()->getInterface("craftWeapons")->getElement("errorMessage")->color, - "BACK14.SCR", - _game->getMod()->getInterface("craftWeapons")->getElement("errorPalette")->color) - ); - return; - } - } + if (diff3 && (craft->getMaxItemsRaw() - totalItems + diff3) < 0) + return "STR_NOT_ENOUGH_STORAGE_SPACE_1"; + if (diff4_b && (craft->getMaxStorageSpaceRaw() - totalItemStorageSize + diff4) < 0.0) + return "STR_NOT_ENOUGH_STORAGE_SPACE_2"; } - - equipSelectedWeapon(_weapons[_lstWeapons->getSelectedRow()]); - _game->popState(); + return ""; } /** @@ -329,13 +300,21 @@ void CraftWeaponsState::equipSelectedWeapon(RuleCraftWeapon* selRule) * "None"/dismount row) via the SAME store path a list click drives. Returns false * if that weapon is not on this screen's list. Does not pop the state. */ -bool CraftWeaponsState::harnessEquip(const std::string& weaponType) +bool CraftWeaponsState::harnessEquip(const std::string& weaponType, bool bypassGate, std::string& blockedBy) { + blockedBy.clear(); for (size_t i = 0; i < _weapons.size(); ++i) { std::string t = _weapons[i] ? _weapons[i]->getType() : ""; if (t == weaponType) { + if (!bypassGate) + { + CraftWeapon* current = _craft->getWeapons()->at(_weapon); + const RuleCraftWeapon* currWeapon = current ? current->getRules() : nullptr; + blockedBy = equipCapacityError(_game->getMod(), _craft, _weapons[i], currWeapon); + if (!blockedBy.empty()) return true; // found, but the client gate blocked it + } equipSelectedWeapon(_weapons[i]); return true; } diff --git a/src/Basescape/CraftWeaponsState.h b/src/Basescape/CraftWeaponsState.h index 6c252dcf8..d8e1af4ab 100644 --- a/src/Basescape/CraftWeaponsState.h +++ b/src/Basescape/CraftWeaponsState.h @@ -25,6 +25,7 @@ namespace OpenXcom class Base; class Craft; +class Mod; class TextButton; class Window; class Text; @@ -61,8 +62,19 @@ class CraftWeaponsState : public State void lstWeaponsClick(Action *action); /// Handler for middle clicking the Weapons list. void lstWeaponsMiddleClick(Action *action); + /// Pure capacity gate, shared by the UI click handler and the SHARED host validator + /// (issue #121): returns "" if mounting @a refWeapon (nullptr = dismount) in place of + /// @a currWeapon (nullptr = empty slot) keeps @a craft within all four capacity limits, + /// else the STR_ error id the client shows. Host and client run identical math so the + /// host can re-check a request whose (possibly stale) replica gate already passed. + static std::string equipCapacityError(Mod *mod, Craft *craft, + const RuleCraftWeapon *refWeapon, const RuleCraftWeapon *currWeapon); /// Harness (PRD-J09 GAP-5b): drive the real weapon-mount store path for one type. - bool harnessEquip(const std::string& weaponType); + /// Returns false only when @a weaponType is not a mountable armament here. With + /// @a bypassGate false the client capacity gate runs first and, if it blocks, fills + /// @a blockedBy with its STR_ error and leaves the mount unmade; bypassGate models a + /// stale replica whose gate passed, letting the request reach the host un-gated. + bool harnessEquip(const std::string& weaponType, bool bypassGate, std::string& blockedBy); }; } diff --git a/src/Basescape/SoldierArmorState.cpp b/src/Basescape/SoldierArmorState.cpp index 2b058906d..9fa71f356 100644 --- a/src/Basescape/SoldierArmorState.cpp +++ b/src/Basescape/SoldierArmorState.cpp @@ -348,14 +348,25 @@ void SoldierArmorState::applyArmorSelection(Armor* next) * store path a list click drives. Returns false if that armor is not on the list. * Does not pop the state. */ -bool SoldierArmorState::harnessSetArmor(const std::string& armorType) +bool SoldierArmorState::harnessSetArmor(const std::string& armorType, bool bypassGate, std::string& blockedBy) { + blockedBy.clear(); for (const auto& armorItem : _armors) { if (armorItem.type == armorType) { Armor* next = _game->getMod()->getArmor(armorType, false); if (!next) return false; + if (!bypassGate) + { + Soldier* soldier = _base->getSoldiers()->at(_soldier); + Craft* craft = soldier->getCraft(); + if (craft && !craft->validateArmorChange(soldier->getArmor()->getSize(), next->getSize())) + { + blockedBy = "STR_NOT_ENOUGH_CRAFT_SPACE"; + return true; // found, but the client gate blocked it + } + } applyArmorSelection(next); return true; } diff --git a/src/Basescape/SoldierArmorState.h b/src/Basescape/SoldierArmorState.h index 566ff3cfe..239d03e23 100644 --- a/src/Basescape/SoldierArmorState.h +++ b/src/Basescape/SoldierArmorState.h @@ -100,7 +100,12 @@ class SoldierArmorState : public State /// Handler for clicking the Name arrow. void sortNameClick(Action *action); /// Harness (PRD-J09 GAP-5b): drive the real armor-change store path for one type. - bool harnessSetArmor(const std::string& armorType); + /// Returns false only when @a armorType is not on the soldier's armor list. With + /// @a bypassGate false the client craft-space gate (Craft::validateArmorChange) runs + /// first and, if it blocks, fills @a blockedBy with its STR_ error and leaves the + /// change unmade; bypassGate models a stale replica whose gate passed (issue #121), + /// letting the request reach the host un-gated. + bool harnessSetArmor(const std::string& armorType, bool bypassGate, std::string& blockedBy); }; } diff --git a/src/CoopMod/SharedEcon.cpp b/src/CoopMod/SharedEcon.cpp index a31358cc4..f2966bf9b 100644 --- a/src/CoopMod/SharedEcon.cpp +++ b/src/CoopMod/SharedEcon.cpp @@ -79,6 +79,7 @@ #include #include "../Basescape/BaseView.h" +#include "../Basescape/CraftWeaponsState.h" // issue #121: shared craft-weapon capacity gate #include "../Geoscape/GeoscapeState.h" #include "../Geoscape/ConfirmLandingState.h" #include "../Geoscape/Globe.h" @@ -1958,13 +1959,23 @@ bool craftRearmValidate(Game* game, const Json::Value& payload, Base* base, int int slot = payload.get("slot", -1).asInt(); if (slot < 0 || slot >= (int)craft->getWeapons()->size()) { failReason = "bad weapon slot"; return false; } std::string wtype = payload.get("weapon", "").asString(); + const RuleCraftWeapon* selRule = nullptr; if (!wtype.empty()) { - const RuleCraftWeapon* w = game->getMod()->getCraftWeapon(wtype, false); - if (!w) { failReason = "unknown craft weapon"; return false; } - if (!craft->getRules()->isValidWeaponSlot((size_t)slot, w->getWeaponType())) + selRule = game->getMod()->getCraftWeapon(wtype, false); + if (!selRule) { failReason = "unknown craft weapon"; return false; } + if (!craft->getRules()->isValidWeaponSlot((size_t)slot, selRule->getWeaponType())) { failReason = "weapon not valid for slot"; return false; } } + + // issue #121: re-run the SAME four capacity gates the client's CraftWeaponsState + // enforces, against the host-authoritative craft. The client gate can pass on a + // stale replica view; the host is the single authority, so it must reject an + // over-capacity swap here rather than apply it and desync/deploy-fault later. + CraftWeapon* current = craft->getWeapons()->at(slot); + const RuleCraftWeapon* curRule = current ? current->getRules() : nullptr; + std::string capErr = CraftWeaponsState::equipCapacityError(game->getMod(), craft, selRule, curRule); + if (!capErr.empty()) { failReason = capErr; return false; } return true; } @@ -2020,8 +2031,18 @@ bool soldierArmorValidate(Game* game, const Json::Value& payload, Base* base, in { cost = 0; if (!base) { failReason = "base not found"; return false; } - if (!findSoldier(base, payload.get("soldierId", -1).asInt())) { failReason = "soldier not found"; return false; } - if (!game->getMod()->getArmor(payload.get("armor", "").asString(), false)) { failReason = "unknown armor"; return false; } + Soldier* s = findSoldier(base, payload.get("soldierId", -1).asInt()); + if (!s) { failReason = "soldier not found"; return false; } + Armor* next = game->getMod()->getArmor(payload.get("armor", "").asString(), false); + if (!next) { failReason = "unknown armor"; return false; } + + // issue #121: re-run the client's craft-space gate (SoldierArmorState::lstArmorClick) + // against the host-authoritative world. If the soldier rides a craft, a larger armor + // must still fit; the client gate can pass on a stale replica view, so the host is the + // backstop that keeps a shared craft from being pushed over capacity. + Craft* craft = s->getCraft(); + if (craft && !craft->validateArmorChange(s->getArmor()->getSize(), next->getSize())) + { failReason = "STR_NOT_ENOUGH_CRAFT_SPACE"; return false; } return true; } diff --git a/src/CoopMod/TestServer.cpp b/src/CoopMod/TestServer.cpp index 76ff14b76..b6da4546a 100644 --- a/src/CoopMod/TestServer.cpp +++ b/src/CoopMod/TestServer.cpp @@ -1107,11 +1107,15 @@ bool TestServer::executeShared10(const std::string& cmd, const Json::Value& req, // the shared base stores). In SHARED (after the fix) this routes a // craft_rearm shared_cmd host-side; before it, it mutates THIS machine's // base stores locally - the pre-battle store drift GAP-5b closes. - // Params: weapon, slot (default 0), optional base + craft_id. + // Params: weapon, slot (default 0), optional base + craft_id, optional force. + // force=true bypasses the client capacity gate (issue #121: models a stale + // replica whose gate passed), so the request reaches the host un-gated and + // the host validator is the one under test. std::string weapon = req.get("weapon", "").asString(); int slot = req.get("slot", 0).asInt(); std::string baseName = req.get("base", "").asString(); int craftId = req.get("craft_id", -1).asInt(); + bool force = req.get("force", false).asBool(); Base* target = nullptr; if (_game->getSavedGame()) for (auto* b : *_game->getSavedGame()->getBases()) @@ -1130,11 +1134,28 @@ bool TestServer::executeShared10(const std::string& cmd, const Json::Value& req, else { CraftWeaponsState* cws = new CraftWeaponsState(target, idx, (size_t)slot); - bool moved = cws->harnessEquip(weapon); + std::string blockedBy; + bool found = cws->harnessEquip(weapon, force, blockedBy); delete cws; - resp["moved"] = moved; - resp["ok"] = moved; - if (!moved) resp["error"] = "weapon not on craft armament list: " + weapon; + if (!found) + { + resp["moved"] = false; + resp["ok"] = false; + resp["error"] = "weapon not on craft armament list: " + weapon; + } + else if (!blockedBy.empty()) + { + // The client capacity gate blocked it (no request sent). Faithful to a + // real player's screen; the test asserts on resp["gate"]. + resp["moved"] = false; + resp["ok"] = false; + resp["gate"] = blockedBy; + } + else + { + resp["moved"] = true; + resp["ok"] = true; + } } } else if (cmd == "soldier_armor") @@ -1143,10 +1164,13 @@ bool TestServer::executeShared10(const std::string& cmd, const Json::Value& req, // through the REAL SoldierArmorState store path (returns the old armor's // store item, consumes the new one). In SHARED (after the fix) this routes a // soldier_armor shared_cmd; before it, it mutates THIS machine's base stores. - // Params: soldier_id, armor, optional base. + // Params: soldier_id, armor, optional base, optional force. force=true bypasses + // the client craft-space gate (issue #121: models a stale replica whose gate + // passed), so the host validator is the one under test. std::string armor = req.get("armor", "").asString(); int soldierId = req.get("soldier_id", -1).asInt(); std::string baseName = req.get("base", "").asString(); + bool force = req.get("force", false).asBool(); Base* target = nullptr; if (_game->getSavedGame()) for (auto* b : *_game->getSavedGame()->getBases()) @@ -1165,11 +1189,26 @@ bool TestServer::executeShared10(const std::string& cmd, const Json::Value& req, else { SoldierArmorState* sas = new SoldierArmorState(target, sidx, SA_GEOSCAPE); - bool moved = sas->harnessSetArmor(armor); + std::string blockedBy; + bool found = sas->harnessSetArmor(armor, force, blockedBy); delete sas; - resp["moved"] = moved; - resp["ok"] = moved; - if (!moved) resp["error"] = "armor not on soldier list: " + armor; + if (!found) + { + resp["moved"] = false; + resp["ok"] = false; + resp["error"] = "armor not on soldier list: " + armor; + } + else if (!blockedBy.empty()) + { + resp["moved"] = false; + resp["ok"] = false; + resp["gate"] = blockedBy; + } + else + { + resp["moved"] = true; + resp["ok"] = true; + } } } else if (cmd == "craft_deequip_armor") diff --git a/tools/coop_test/test_shared_capacity_host_gate.py b/tools/coop_test/test_shared_capacity_host_gate.py new file mode 100644 index 000000000..5ec13958d --- /dev/null +++ b/tools/coop_test/test_shared_capacity_host_gate.py @@ -0,0 +1,285 @@ +"""issue #121: the SHARED host validators must RE-CHECK craft capacity. + +In SHARED either player can command/edit any craft, so the client-side capacity gate +is not enough: player B's client validates against B's (possibly stale) local view, the +request passes B's gate, reaches the host, and pre-fix the host applied it WITHOUT re- +checking -> an over-capacity craft that can desync or fault a later deployment. + +This exercises both host validators with a throwaway mod (activated on BOTH machines): + + 1. craftRearmValidate - a craft weapon whose getBonusStats() demands 100 cargo the + Interceptor (0 unit capacity) cannot give -> STR_NOT_ENOUGH_- + CARGO_SPACE. + 2. soldierArmorValidate - a Skyranger with zero large-unit capacity, a soldier aboard, + then asked to wear a size-2 (2x2) armor that cannot fit -> + STR_NOT_ENOUGH_CRAFT_SPACE (Craft::validateArmorChange). + +Each gate is checked twice: + + CLIENT GATE (faithful) - drive the REAL screen path WITHOUT `force`: the harness now + runs the same client gate a player's screen runs, so the + request is refused locally and never sent (resp["gate"]). + HOST GATE (the fix) - drive it WITH `force`, which bypasses the client gate exactly + as a stale replica whose local view passed would. The request + reaches the host un-gated; the host must REJECT it (shared_fail + with the STR_ reason) and leave BOTH worlds unchanged / equal. + Pre-fix the host applied the over-capacity change and the two + worlds diverged. + +Run: python tools/coop_test/test_shared_capacity_host_gate.py + python tools/coop_test/test_shared_capacity_host_gate.py weapon + python tools/coop_test/test_shared_capacity_host_gate.py armor +""" + +import os +import shutil +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import shared_fixture + +# --- the throwaway mod ------------------------------------------------------ +# A craft weapon that costs 100 cargo (a capacity PENALTY no stock craft weapon has), a +# single-seat Skyranger, and a size-2 Personal Armor. None of this ships with OXCE, and +# the capacity gates are dead code on stock data (stock craft weapons carry no bonus +# stats and every stock soldier armor is size 1), so - like test_shared_missile_bombardment +# - we generate the ruleset into the harness's isolated user dirs and activate it on both. +CAPKILLER = "STR_COOP_TEST_CAPKILLER" # the over-capacity craft weapon +LAUNCHER = "STR_STINGRAY_LAUNCHER" # a real stock item, reused as its launcher +ARMOR = "STR_PERSONAL_ARMOR_UC" # patched to size 2 by the mod +ARMOR_ITEM = "STR_PERSONAL_ARMOR" +DEFAULT_ARMOR = "STR_NONE_UC" +STOCK = 2 + +METADATA = """\ +name: "Coop capacity host-gate test" +version: 1.0 +description: "Test-only: an over-capacity craft weapon, a 1-seat Skyranger, a size-2 armor." +author: coop harness + +master: xcom1 +""" + +RULESET = f"""\ +craftWeapons: + - type: {CAPKILLER} + launcher: {LAUNCHER} + weaponType: 0 + stats: + soldiers: -100 +crafts: + - type: STR_SKYRANGER + vehicles: 0 # zero large-unit capacity -> a 2x2 armor never fits +armors: + - type: {ARMOR} + size: 2 + corpseBattle: # a 2x2 armor needs size*size battle corpse items + - STR_CORPSE_ARMOR + - STR_CORPSE_ARMOR + - STR_CORPSE_ARMOR + - STR_CORPSE_ARMOR + loftempsSet: [3, 3, 3, 3] # ... and size*size LOFT templates +""" + + +def _make_mod(root): + mod = os.path.join(root, "Coop_Capacity_HostGate_Test") + os.makedirs(os.path.join(mod, "Ruleset")) + with open(os.path.join(mod, "metadata.yml"), "w", encoding="utf-8") as f: + f.write(METADATA) + with open(os.path.join(mod, "Ruleset", "capacity.rul"), "w", encoding="utf-8") as f: + f.write(RULESET) + return mod + + +# --- helpers ---------------------------------------------------------------- + +def _base0(gc): + for b in gc.ok({"cmd": "geo_state"})["bases"]: + if not b.get("coopBase") and not b.get("coopIcon"): + return b + raise AssertionError("no real base") + + +def _base_items(gc, item): + return _base0(gc)["items"].get(item, 0) + + +def _interceptor(gc, craft_id): + for c in _base0(gc)["crafts"]: + if c["id"] == craft_id: + return c + return None + + +def _skyranger(gc): + for c in _base0(gc)["crafts"]: + if "SKYRANGER" in c["type"]: + return c + raise AssertionError("no skyranger") + + +def _soldier(gc, sid): + for b in gc.ok({"cmd": "get_soldiers"})["bases"]: + for s in b["soldiers"]: + if s["id"] == sid: + return s + return None + + +def _stats(gc): + return gc.ok({"cmd": "shared_stats"}) + + +def _reset_stats(host, client): + host.ok({"cmd": "shared_reset_stats"}) + client.ok({"cmd": "shared_reset_stats"}) + + +def _dismiss(gc): + # Drop the host-rejection popup so it does not block the clock advance. + try: + gc.ok({"cmd": "coop_dialog_back"}) + except Exception: + pass + + +# ====================================================================== +# 1. craftRearmValidate: an over-capacity craft weapon on an Interceptor. +# ====================================================================== +def test_weapon(ports, mod): + js = shared_fixture.bring_up("jcaphgw", ports, mods=[mod]) + host, client = js.host, js.client + try: + # Seed identical worlds: an EMPTY interceptor + the launcher stock on both. + craft_ids = set() + for gc in (host, client): + r = gc.ok({"cmd": "spawn_craft", "type": "STR_INTERCEPTOR", "weapon": "STR_NONE"}) + craft_ids.add(r["craft_id"]) + gc.ok({"cmd": "give_items", "item": LAUNCHER, "count": STOCK}) + assert len(craft_ids) == 1, f"spawn_craft gave different ids per machine: {craft_ids}" + cid = craft_ids.pop() + js.assert_world_equal("bootstrap + empty interceptor + launchers") + + base0 = _base_items(host, LAUNCHER) + assert _interceptor(host, cid)["weaponLoadout"][0] == "", "premise: slot 0 not empty" + print(f"PASS setup: empty interceptor slot 0, {base0} launchers, world equal") + + # --- CLIENT GATE (faithful): the harness now runs the client capacity gate, so + # a normal (un-forced) request is refused locally and never sent. + r = client.cmd({"cmd": "craft_rearm", "weapon": CAPKILLER, "slot": 0, "craft_id": cid}) + assert not r.get("ok") and not r.get("moved"), f"client gate did not block: {r}" + assert r.get("gate") == "STR_NOT_ENOUGH_CARGO_SPACE", f"unexpected client gate: {r}" + assert _interceptor(host, cid)["weaponLoadout"][0] == "", "world changed on a blocked (unsent) rearm" + print(f"PASS client-gate: over-capacity rearm refused locally '{r['gate']}', nothing sent") + + # --- HOST GATE (the fix): force past the client gate (a stale-replica request), + # the host must REJECT it and leave both worlds unchanged. + _reset_stats(host, client) + r = client.ok({"cmd": "craft_rearm", "weapon": CAPKILLER, "slot": 0, "craft_id": cid, "force": True}) + assert r.get("moved"), f"forced rearm not sent: {r}" + client.wait_for("client received the host's rejection", + lambda: (_stats(client)["failCount"] >= 1) or None, + timeout=30, interval=0.5) + cs = _stats(client) + assert cs["lastFail"] == "STR_NOT_ENOUGH_CARGO_SPACE", f"unexpected host reason: {cs}" + # world UNCHANGED on both sides: slot still empty, launcher stock intact. + for name, gc in (("host", host), ("client", client)): + assert _interceptor(gc, cid)["weaponLoadout"][0] == "", \ + f"{name}: host applied an over-capacity rearm: {_interceptor(gc, cid)['weaponLoadout']}" + assert _base_items(gc, LAUNCHER) == base0, f"{name}: launcher stock moved on a rejected rearm" + print(f"PASS host-gate: host rejected the forced over-capacity rearm '{cs['lastFail']}', " + "both worlds unchanged") + _dismiss(client) + + js.assert_world_equal("after rejected over-capacity rearm (worlds equal)") + js.finish() + print("ALL CRAFT-REARM HOST-GATE TESTS PASSED") + finally: + js.shutdown() + + +# ====================================================================== +# 2. soldierArmorValidate: a size-2 armor that no longer fits a full craft. +# ====================================================================== +def test_armor(ports, mod): + js = shared_fixture.bring_up("jcaphga", ports, mods=[mod]) + host, client = js.host, js.client + try: + for gc in (host, client): + gc.ok({"cmd": "give_items", "item": ARMOR_ITEM, "count": STOCK}) + js.assert_world_equal("bootstrap + personal-armor stock") + + # The mod zeroed the Skyranger's large-unit capacity. Board ONE (host-owned) + # soldier so a switch to the size-2 armor is validated against the craft. + cid = _skyranger(host)["id"] + roster = host.ok({"cmd": "get_soldiers"})["bases"][0]["soldiers"] + sid = next(s["id"] for s in roster if s["owner"] == 0) + host.ok({"cmd": "craft_assign", "craft_id": cid, "soldier_id": sid, "on": True}) + for gc, tag in ((host, "host"), (client, "client")): + gc.wait_for(f"{tag} soldier aboard the skyranger", + lambda gc=gc: (_soldier(gc, sid)["craftId"] == cid) or None, + timeout=40, interval=0.5) + assert _soldier(host, sid)["armor"] == DEFAULT_ARMOR, "premise: soldier not in default armor" + base0 = _base_items(host, ARMOR_ITEM) + print(f"PASS setup: soldier {sid} aboard the skyranger (no large-unit room), " + f"{base0} armor in stock, world equal") + + # --- CLIENT GATE (faithful). + r = client.cmd({"cmd": "soldier_armor", "soldier_id": sid, "armor": ARMOR}) + assert not r.get("ok") and not r.get("moved"), f"client gate did not block: {r}" + assert r.get("gate") == "STR_NOT_ENOUGH_CRAFT_SPACE", f"unexpected client gate: {r}" + assert _soldier(host, sid)["armor"] == DEFAULT_ARMOR, "world changed on a blocked (unsent) armor swap" + print(f"PASS client-gate: over-capacity armor swap refused locally '{r['gate']}', nothing sent") + + # --- HOST GATE (the fix). + _reset_stats(host, client) + r = client.ok({"cmd": "soldier_armor", "soldier_id": sid, "armor": ARMOR, "force": True}) + assert r.get("moved"), f"forced armor swap not sent: {r}" + client.wait_for("client received the host's rejection", + lambda: (_stats(client)["failCount"] >= 1) or None, + timeout=30, interval=0.5) + cs = _stats(client) + assert cs["lastFail"] == "STR_NOT_ENOUGH_CRAFT_SPACE", f"unexpected host reason: {cs}" + for name, gc in (("host", host), ("client", client)): + assert _soldier(gc, sid)["armor"] == DEFAULT_ARMOR, \ + f"{name}: host applied an over-capacity armor swap: {_soldier(gc, sid)['armor']}" + assert _base_items(gc, ARMOR_ITEM) == base0, f"{name}: armor stock moved on a rejected swap" + print(f"PASS host-gate: host rejected the forced over-capacity armor swap '{cs['lastFail']}', " + "both worlds unchanged") + _dismiss(client) + + js.assert_world_equal("after rejected over-capacity armor swap (worlds equal)") + js.finish() + print("ALL SOLDIER-ARMOR HOST-GATE TESTS PASSED") + finally: + js.shutdown() + + +SECTIONS = { + "weapon": (test_weapon, (48980, 48981, 48280)), + "armor": (test_armor, (48982, 48983, 48282)), +} + + +def main(): + which = sys.argv[1] if len(sys.argv) > 1 else None + tmp = tempfile.mkdtemp(prefix="coop_caphgate_mod_") + try: + mod = _make_mod(tmp) + if which: + fn, ports = SECTIONS[which] + fn(ports, mod) + else: + for name in ("weapon", "armor"): + fn, ports = SECTIONS[name] + print(f"\n==== issue #121 host-gate section: {name} ====") + fn(ports, mod) + print("\nALL SHARED CAPACITY HOST-GATE TESTS PASSED") + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +if __name__ == "__main__": + main()