Skip to content
Open
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
4 changes: 4 additions & 0 deletions src/CoopMod/TestServer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6324,6 +6324,10 @@ std::string TestServer::execute(const std::string& line)
ju["id"] = u->getId();
ju["faction"] = (int)u->getFaction();
ju["status"] = (int)u->getStatus();
// Classic auto-shot pacing regression probe: unlike liveness,
// the intermediate collapse frames prove UnitDieBState actually
// ran before the remaining rounds in the burst were replayed.
ju["fallPhase"] = u->getFallingPhase();
ju["isOut"] = u->isOut();
ju["health"] = u->getHealth();
ju["tu"] = u->getTimeUnits();
Expand Down
34 changes: 24 additions & 10 deletions src/CoopMod/connectionTCP.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3513,22 +3513,25 @@ void connectionTCP::updateCoopTask()
}
}

// coop (PRD-P11): the three per-action exemptions, hoisted so the
// coop (PRD-P11): the per-action exemptions, hoisted so the
// ordering rule below can see them. A packet that qualifies ONLY
// through one of these is the CLOSER of the chain currently holding
// the gate, and must never be held back by the ordering rule - see
// the carve-out where `subjectHeld` is computed.
// coop (parallel Phase 3, Sub-task A): the unit_death/after_unit_death
// disjuncts only ever qualify in classic - the parallel host ships
// coop (classic auto-shot pacing): only unit_death may open the
// client's authoritative UnitDieBState while a shot owns the gate.
// after_unit_death carries the host's FINAL DEAD status; admitting it
// while the killing ExplosionBState is parked makes the queued client
// UnitDieBState see an already-out unit and skip every collapse frame.
// It therefore follows the ordinary gate and applies after the replay.
// In parallel neither legacy death carrier normally arrives - the host ships
// unit_casualty instead (Phase 2a/2b), so the legacy trio never arrives
// on a parallel client's wire and these two clauses are permanently
// false there. Gated for clarity/hardening; classic keeps both,
// byte-identical. `abortPath` is unaffected (walk chains exist in both
// on a parallel client's wire. Gated for clarity/hardening;
// `abortPath` is unaffected (walk chains exist in both
// modes).
const bool chainCloser =
((stateString == "abortPath" && _coopWalkInit) ||
(!parallelTurnActive() && stateString == "unit_death" && _coopInitDeath) ||
(!parallelTurnActive() && stateString == "after_unit_death" && _coopInitDeath));
(!parallelTurnActive() && stateString == "unit_death" && _coopInitDeath));

// coop (PRD-I3 SEAM-2 HALF 2): a set_smoke_tile/set_fire_tile carrying
// `bnd:true` is the neutral->player boundary decay. It belongs to NO chain
Expand Down Expand Up @@ -3618,7 +3621,14 @@ void connectionTCP::updateCoopTask()
// of its closer means this machine has not started that chain at all and
// there is nothing yet for the closer to close.
bool closerOvertakesOpener = false;
if (chainCloser && subject >= 0 && !legacyOrder)
// coop (classic auto-shot pacing): the opener-order barrier was added
// for parallel chain/state ordering, but applying it to classic parks
// hit_unit behind the active projectile gate and then parks unit_death
// behind that hit. The subject-less hasHitUnit release can still pass,
// so the client starts the next round without ever having queued its
// UnitDieBState. Keep classic's original closer behaviour; parallel's
// ordered replay retains the barrier unchanged.
if (parallelTurnActive() && chainCloser && subject >= 0 && !legacyOrder)
{
if (const char* opener = coopChainOpener(stateString))
{
Expand Down Expand Up @@ -9151,7 +9161,11 @@ void connectionTCP::onTCPMessage(std::string stateString, Json::Value obj)
}
}

// Make sure the Battlescape does not get stuck...
// The authoritative classic UnitDieBState is now queued immediately after
// the current ExplosionBState. Release the explosion as soon as unit_death
// has been consumed, so the client starts collapsing immediately. The queued
// UnitDieBState remains ahead of ProjectileFlyBState, so the next client round
// cannot start before the collapse ends.
_hasHitUnit = -1;

}
Expand Down
171 changes: 171 additions & 0 deletions tools/coop_test/test_classic_autoshot_death_pacing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Classic co-op: a lethal early auto-shot round must collapse before later rounds.

The client parks the killing ExplosionBState with `_hasHitUnit == 1`. A classic
`unit_death` packet must pass the receive-order gate, queue UnitDieBState directly
after that explosion, and release the explosion immediately. The queued collapse
then runs before ProjectileFlyBState may create the remaining round(s). The final
`after_unit_death` state must not overtake that display sequence.

This is a red/green regression test. On the broken baseline, the opener-order
barrier parks classic `unit_death` behind `hit_unit`, while subject-less
`hasHitUnit` overtakes both; the next client round therefore starts before a
UnitDieBState was even queued. The test samples the client rapidly and proves
the required ordering with two independent observations: a real intermediate
collapse frame was displayed, and ammunition still remained to be consumed
when that collapse began. The broken baseline must fail; the corrected Classic
ordering must pass the same assertions.

Run: python tools/coop_test/test_classic_autoshot_death_pacing.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 test_battle_tripwire as TW

PORT = "48962"
STATUS_COLLAPSING = 5


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["id"] == uid), None)


def ammo_qty(gc, ammo_id):
items = gc.ok({"cmd": "battle_items"})["items"]
item = next((i for i in items if i["id"] == ammo_id), None)
return None if item is None else item["qty"]


def main():
opts = {"battleXcomSpeed": 1, "battleAlienSpeed": 1}
host = client = None
fail = None
try:
host = GameClient("host", 48960,
make_user_dir("classic_autoshot_host", options=opts))
client = GameClient("client", 48961,
make_user_dir("classic_autoshot_client", options=opts))
for gc in (host, client):
TW.write_battle_fixture(gc.user_dir)
host.spawn(); host.connect()
client.spawn(); client.connect()
TW.PORT = PORT
TW.bring_up_battle(host, client, seed=771931)

for gc, tag in ((host, "host"), (client, "client")):
bs = battle(gc)
assert bs.get("parallelActive") is False, \
f"{tag}: fixture is not Classic Turns: {bs}"
assert all("fallPhase" in u for u in bs["units"]), \
f"{tag}: battle_state has no fallPhase; executable predates this test"

driver, watcher, driver_tag, _, db = TW.pick_driver(host, client)
shooters = [u for u in db["units"] if u.get("faction") == 0
and u.get("selectable") and not u.get("isOut")]
victims = [u for u in db["units"] if u.get("faction") == 1
and not u.get("isOut")]
assert shooters and victims, "fixture has no controllable shooter or live hostile"
shooter, victim = shooters[0], victims[0]

# Put the target next to the shooter on both copies. At this range firing
# 1000 makes the first round deterministic while still exercising the real
# BA_AUTOSHOT/ExplosionBState/unit_death path. A rifle with a finite clip is
# used so the sample can prove whether another round was consumed yet;
# laser weapons report the permanent sentinel quantity 255.
placed = None
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1),
(1, 1), (-1, -1), (1, -1), (-1, 1)):
want = (shooter["x"] + dx, shooter["y"] + dy, shooter["z"])
results = [gc.cmd({"cmd": "battle_teleport", "unit": victim["id"],
"x": want[0], "y": want[1], "z": want[2]})
for gc in (host, client)]
if all(r.get("moved") for r in results):
placed = want
break
assert placed is not None, "no common adjacent tile for the deterministic shot"

armed = []
for gc in (host, client):
gc.ok({"cmd": "battle_action", "action": "set_stat",
"unit": shooter["id"], "stat": "firing", "value": 1000,
"refill": True})
gc.ok({"cmd": "battle_action", "action": "set_stat",
"unit": victim["id"], "health": 1})
armed.append(gc.ok({"cmd": "battle_give", "unit": shooter["id"],
"item": "STR_RIFLE", "ammo": "STR_RIFLE_CLIP",
"slot": "right",
"clear_hands": True}))
assert armed[0]["weaponId"] == armed[1]["weaponId"], \
f"weapon ids differ: {armed}"
weapon_id = armed[0]["weaponId"]
assert armed[0]["ammoId"] >= 0 and armed[0]["ammoId"] == armed[1]["ammoId"], \
f"loaded rifle clip ids differ or are missing: {armed}"
ammo_id = armed[0]["ammoId"]
initial_qty = ammo_qty(client, ammo_id)

shot = driver.ok({"cmd": "battle_fire", "unit": shooter["id"],
"target": victim["id"], "mode": "auto",
"weapon_id": weapon_id, "tu": 200})
assert shot.get("ammoId", -1) == ammo_id, \
f"rifle did not fire the staged clip {ammo_id}: {shot}"

frames = []
first_collapse_qty = None
deadline = time.time() + 45
saw_out = False
stable = 0
previous_final = None
while time.time() < deadline:
vu = unit(client, victim["id"])
assert vu is not None, "victim disappeared from client unit list"
qty = ammo_qty(client, ammo_id)
snap = (vu["status"], vu["fallPhase"], qty)
if not frames or frames[-1] != snap:
frames.append(snap)
if vu["status"] == STATUS_COLLAPSING and vu["fallPhase"] > 0:
if first_collapse_qty is None:
first_collapse_qty = qty
saw_out = saw_out or vu.get("isOut", False)
current_final = (vu["status"], vu["fallPhase"], qty)
final_pose = saw_out and vu["status"] != STATUS_COLLAPSING
stable = stable + 1 if final_pose and current_final == previous_final else 0
previous_final = current_final
if stable >= 20:
break
time.sleep(0.02)

final_qty = ammo_qty(client, ammo_id)
assert saw_out, f"auto shot did not kill the staged 1-HP victim; frames={frames}"
assert first_collapse_qty is not None, \
f"client displayed no intermediate UnitDieBState collapse frame: {frames}"
assert final_qty is not None and first_collapse_qty > final_qty, (
"client consumed every remaining auto-shot round before the collapse "
f"began (collapse qty={first_collapse_qty}, final qty={final_qty}, "
f"initial qty={initial_qty}, frames={frames})")
assert stable >= 20, "auto-shot/death chain did not settle"
print("PASS: Classic client collapsed the victim before consuming the "
f"remaining auto-shot rounds (ammo {initial_qty} -> "
f"{first_collapse_qty} during collapse -> {final_qty}).")
except Exception as exc:
fail = exc
print(f"[FAIL] {exc}")
finally:
if host is not None:
host.shutdown()
if client is not None:
client.shutdown()
sys.exit(2 if fail else 0)


if __name__ == "__main__":
main()
Loading