Add a live coop test harness (autonomous two-client testing)#30
Add a live coop test harness (autonomous two-client testing)#30NonPolynomialTim wants to merge 10 commits into
Conversation
An in-game command server plus Python drivers that spawn real game
instances, drive them through menus/saves/lobby/geoscape/battle, read live
state, and assert host/client stay in sync. Runs unattended, needs no
external mod and no pre-existing save, and builds on a stock checkout.
- src/CoopMod/TestServer.{cpp,h}: in-game command server (~45 handlers),
active only when OXC_TEST_PORT is set (inert in normal play). A socket IO
thread does I/O only; a per-frame pump in Game::run() executes commands on
the main thread against real game objects, so state access is race-free.
Handlers invoke real State methods rather than faking SDL input.
- tools/coop_test/: harness.py (GameClient + hermetic make_user_dir), 7
drivers, and a README.
- Wiring: TestServer::pump() call in Game::run(), read-only Game::getStates(),
CMake / vcxproj(.filters) registration, a few public-ised State entry points,
and Geoscape base-driving hooks.
Builds on a stock checkout: handlers for coop features not in this repo yet
(soldier transfer, multi-rendezvous server browser, TX-queue drop counter)
are detected at compile time with C++17 __has_include (plus a harness-owned
macro for the one bare global that can't be probed). Absent features compile
out and answer "<feature> not built"; they switch on automatically if the
feature later lands. No feature flags in feature code.
Hermetic: make_user_dir() writes a minimal options.cfg pinning the stock
xcom1 master, no external mods, intro/audio/mouse-capture off, 640x400
window; data resolves from the exe dir. No local config read, no save
needed - tests bootstrap a fresh campaign each run.
Verified: full serial MSBuild Release|x64 links clean (0 errors); boot_check
comes up on xcom1 v8.6.0 only; the suite spawns and connects both instances
from isolated dirs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@xcomcoopdev I've been developing this and using it to create automated bug repros, as well as to let Claude run through scenarios autonomously. It has been extremely handy and has saved me a ton of time, and I figured I'd open a PR here in case you wanted to have it available here too. I also don't know if you saw my offer to set up automated builds here (because the PR was already closed Idk if it sends notifications or not): #20 (comment), but I also plan on building out a much wider and more robust set of tests to be able to catch regressions in my changes and I'd be happy to wire that into the automated builds as well to prevent regressions. An automated test suite like this would also help when syncing with the OXCE official repo to make sure that the merge didn't break anything, so you'd be able to sync more often and with less stress |
|
@xcomcoopdev thank you for push access! Do you have any guidance on what sorts of PRs you'd prefer I didn't merge without you looking at it first? I'm thinking large, architectural changes like the soldier transfer PR (bc it touches the save architecture) and like the test harness PR I would leave until you had a chance to look at them, but bugfixes I would merge myself? |
|
Hi, I looked through those 4 pull requests, so you can approve them now. The "main" branch is protected, so I’m not completely sure what permissions you have there, but you can try. You should be able to create your own testing branch for nightly builds. |
|
Or I can just go ahead and approve those 4 pull requests now... 😅 |
|
There are some merge conflicts in this pull request. Should I resolve them, or do you want to handle them? |
|
I can handle those! |
Hi, in my opinion you can merge PRs yourself as long as the changes are documented and the implementation works. I’ll try to review your code changes weekly, and I’ll check and test everything before publishing anything to ModDB and/or mod.io. |
Soldier transfer and the multi-rendezvous server browser are now on origin/main, so their __has_include gates enable those handlers. Resolved the wiring conflicts by union (CMakeLists, vcxproj, Game.cpp include, .gitignore); Game.h getStates() now comes from upstream. Re-added the harness test accessors that upstream's feature merge did not carry, so the now-enabled handlers compile: - ServerList::getServerCombo() - DisableableComboBox::getOptionCount() / getOptionLabel() (+ _labels) Build: full serial MSBuild Release|x64, 0 errors; TestServer relinked against the real feature API. TX-queue drop counter stays gated off (not on origin/main). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The optional coop features are now on main (soldier ownership transfer,
multi-rendezvous server browser) or completed by this PR (TX-queue drop
counter), so the __has_include / OXC_HARNESS_HAS_* conditional compilation
in TestServer.cpp is no longer needed. Handlers are now unconditional.
- TestServer.cpp: drop the feature-detection block and every #if/#else
guard; include the feature headers directly.
- connectionTCP.{h,cpp}: add g_txDropCount, an atomic incremented at the
two existing "TX queue full, dropping packet" sites, exposed for the
coop_stats command (pairs with the conflation fix already on main).
- README: drop the feature-gated-tests section.
Build: full serial MSBuild Release|x64, 0 errors. Validated: test_transfer_fresh
passes end-to-end (soldier transfer host->client). test_server_browser now runs
the real handler but needs rendezvous config to assert (hermetic dirs have none).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test_server_browser previously assumed a rendezvous.json with a reachable "Official" server, which needs real server keys we don't commit. Rewrite it to self-provision a throwaway config (via the OXC_RENDEZVOUS_CONFIG env override) listing two UNREACHABLE localhost servers with placeholder public keys (32 zero bytes), so it runs hermetically with no secrets and no live server. Asserts the offline/disabled path: combobox visible (>=2 servers), every unreachable server shown "(offline)" and disabled. The happy path (a reachable server showing enabled) needs real keys and is left to manual per-release testing. Validated: PASS - visible=True, both servers offline + disabled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New tools/coop_test/geo.py, importable by any test:
- wait_both_ready(host, client): gate until both players are settled on a live
coop geoscape (no CoopState WAIT / map-download dialog), killing the post-load
idle window before a test drives.
- skip_realtime(host, client, seconds, ...): advance N wall-clock seconds while
keeping both on the geoscape and auto-closing any dialog (UFO/event/monthly).
- skip_ingame_time(host, client, minutes, ...): same, bounded by in-game time.
Both skips take an optional `interest` predicate (popup('MissionDetectedState'),
geo_when(lambda g: ...), or any callable(gc)) so they stop early when the event
you care about fires, leaving that dialog open. Plus drain_popups / on_geoscape
/ top_state / both_ready.
test_geoscape_sync: use wait_both_ready after bringup, and drain popups +
re-assert speed during the advance phase so a modal UFO/mission dialog can no
longer pause the clock and stall the run. Also drop the funds-gap from the
DESYNC verdict: coop funds are per-player (independent economies, never synced
peer-to-peer), so a gap is expected, not a desync. Now verdicts clean.
Remove test_txq_flood.py: it reproduces an already-fixed bug (conflation, PR
xcomcoopdev#26) and needs send-thread-throttle instrumentation that isn't (and shouldn't
be) upstream. The g_txDropCount telemetry stays. README updated.
Validated: test_geoscape_sync verdict clean, exit 0, both instances advanced,
0 desync events / crashes / rtt spikes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Accidentally committed via `git add -A` in the previous commit. PR_DESCRIPTION.md is a scratch file; the server_browser*.png are generated by test_server_browser at runtime. Untrack both and gitignore them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
geo.py: add a TimeWatchdog that kills the sessions and raises StuckDialogError if the in-game clock stalls (a dialog that can't be cleared / re-pushes), wired into skip_realtime / skip_ingame_time / the month advance. No more silent hangs. TestServer dismiss_popup: handle UfoDetectedState (btnCancelClick = acknowledge + close, no intercept) and add a generic popState fallback for any other unknown geoscape popup, while protecting the CoopState WAIT dialog. "Skip all dialogs" is now robust to new popup types. New month_report command returns the end-of-month score + per-country funding/activity from the SavedGame. test_geoscape_sync phase B: advance 40 in-game days at the fastest speed, capturing events deterministically while the clock is frozen on a detection popup, and the month-end report via the monthsPassed transition. Cross-validate that host and client agree on events seen, score, and per-country funding changes (absolute funds are independent per player, so excluded). Phase A trimmed to a short RTT baseline (idle heartbeat is FPS-bound, not speed-bound). Finding: the run reveals host and client detect DIFFERENT UFOs/mission sites (counts and types differ) -> a real coop geoscape-sync bug (UFO spawns are meant to be shared). The test correctly fails on it; investigation to follow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
geo_state now emits the coop cross-instance id (Ufo::getCoopUfoId / MissionSite::getCoopMissionId, both ctor-randomized on the host and copied to the client via the sync packet). test_geoscape_sync keys events by that id instead of the local getId(), which differs per instance for the same synced entity. Exact baseline of the known spawn-sync bug: over 40 in-game days the host sees 7 UFO/mission events, the client only 2 (1 common). Confirms the conflated target_positions snapshot drops nearly all transient spawns to the client. Fix (reliable FIFO spawn/despawn lane) follows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The geoscape heartbeat (target_positions) is sent via the last-write-wins conflation slot (added to stop the TX-queue flood). Conflation elides intermediate snapshots, so a UFO or mission site that appears and disappears between two delivered snapshots was never reconstructed on the client -> host and client geoscapes diverged (worst at high time-compression). Fix: positions are conflatable, but membership (which UFOs/missions exist) is not. GeoscapeState now also sends the snapshot on the reliable FIFO lane (sendTCPPacketData) whenever the UFO/mission coop-id set changes, detected via connectionTCP::geoMembershipChanged (tracks _lastGeoUfoIds/_lastGeoMissionIds). Membership changes rarely, so this can't reintroduce the flood. The client processes it through the existing target_positions handler (idempotent create -by-coop-id), so no client change is needed. Verified: at 5min/tick the host and client geo_state UFO coop-id sets matched on 24/24 samples (0 mismatch); before the fix the client saw ~2 of the host's 7 spawns over 40 days. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The event check compared detection events (captured at UfoDetectedState), but detection depends on each player's radar/base coverage, so host and client legitimately detect different UFOs even when the UFOs are perfectly in sync - a false DESYNC. Rewrite phase B to compare UFO/mission EXISTENCE: every poll, diff the coop-id sets of all savegame UFOs / mission sites on both instances; an entity present on only one side past a 4s lag tolerance is a real desync. Drop the radar-based detection-lag check. Keep the end-of-month score/funding cross-check. With the reliable-FIFO spawn/despawn fix in place this verdicts clean over 40 in-game days at the fastest speed (0 desync events); before the fix it flagged the missing spawns. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a live coop test harness (autonomous two-client testing)
Why
There's currently no automated way to test coop end-to-end: every change to the netcode is validated by hand, two people clicking through a session. This PR adds a live test harness, an in-game command server plus Python drivers that spawn real game instances, drive them through menus/saves/lobby/geoscape/battle, and assert that host and client stay in sync. It runs unattended, needs no external mod and no pre-existing save, and builds on a stock checkout.
What the harness is
An in-game command server (
src/CoopMod/TestServer.{h,cpp}, active only when theOXC_TEST_PORTenv var is set, so zero footprint in normal play) that a Python driver talks to over newline-delimited JSON on127.0.0.1:<port>. It lets a script read game state and drive a real running client, and lets those interactions be baked into automated functional tests across two live coop instances.flowchart LR subgraph DRV["Python driver · tools/coop_test/"] direction TB GC["GameClient<br/>cmd() / ok() / wait_for()"] T["test_*.py<br/>baked cmds + asserts"] end subgraph HOST["HOST · OpenXcom.exe · OXC_TEST_PORT=47801"] direction TB IO["Socket IO thread (SDL_net)<br/>never touches game state"] Q["_inbox / _outbox<br/>(mutex)"] P["pump()<br/>main thread, per frame"] EX["execute()<br/>~45 handlers"] G["Real game objects<br/>Game · SavedGame<br/>State · BattlescapeGame"] IO --> Q --> P --> EX --> G G -.->|reply| Q end subgraph CLI["CLIENT · OpenXcom.exe · 47802"] direction TB G2["same internals<br/>IO → pump →<br/>execute → game"] end GC -->|"JSON + newline"| IO GC -->|"JSON + newline"| G2 G <-->|"real coop sync<br/>connectionTCP :47900"| G2Key property: the socket thread does I/O only; every command executes on the main thread via the per-frame
pump(), so all state access is race-free. Handlers invoke the realStatemethods (Profile::buttonOK,BuildNewBaseState::placeAt,UnitWalkBState, …) rather than faking SDL input, so a command exercises the exact path a human click would. The two game instances sync to each other over the realconnectionTCPcoop link, which is the actual product under test; the tests assert host-vs-client agreement to catch replication desyncs.What's in this PR
src/CoopMod/TestServer.{cpp,h}: the in-game command server (~45 command handlers covering session/introspection, lobby bootstrap, geoscape play, battlescape combat, transfer/UI).tools/coop_test/: the Python drivers plusharness.py(theGameClientsocket wrapper and hermeticmake_user_dir).TestServer::pump()call inGame::run(), a read-onlyGame::getStates()accessor, CMake / vcxproj(.filters) registration, and a few public-isedStateentry points so the harness can drive real handlers instead of synthesizing input..agents/docs/coop-test-harness.md(architecture and command catalog).All of it is gated behind
OXC_TEST_PORT; with the var unset the server never starts and the added surface is inert.Builds cleanly on a stock checkout
A few handlers drive coop features that aren't in this repo yet (soldier ownership transfer, a multi-rendezvous server browser, a TX-queue drop counter). Rather than
#ifdefthe feature code or stub things out by hand, the harness detects each feature at compile time with C++17__has_include. Detection lives entirely inTestServer.cpp; nothing is added to any other file:__has_include("TransferSoldierMenu.h" && "TransferNoticeState.h")__has_include("../Interface/DisableableComboBox.h")OXC_HARNESS_HAS_TX_DROP_COUNTER(harness macro, default 0), see noteNote: that counter is a namespace-scope global with no dedicated header, so
__has_includecan't see it and SFINAE can't probe it (referencing an undeclared name is a hard error, not a substitution failure), so it uses a harness-owned macro that is off by default.When a feature is absent (as here), its handlers compile out and answer
"<feature> not built"; the harness builds and runs. If such a feature later lands in this repo,__has_includere-fires at build time and the matching handlers switch on automatically, with no manual toggles and no feature flags in feature code.Runs on any machine (hermetic)
make_user_dir()writes a fresh minimaloptions.cfgthat pins the stockxcom1master (no external mods), with intro/audio/mouse-capture off and a small 640×400 window (so it doesn't grab focus while running). OpenXcom defaults every unspecified key and resolves data (UFO/TFTD/standard/common) from the exe's own dir. No local config is read; no pre-existing save is required. The tests bootstrap a brand-new campaign each run.Verification
Release|x64returns0 Error(s);OpenXcom.exelinks with no unresolved externals (confirms the gated feature symbols don't leak when compiled out).boot_checkon a fresh generated config brings up active mods[xcom1 v8.6.0]only, display 640×400, data loaded, 0 errors.boot_checktest_geoscape_synctest_transfer_fresh/test_bug_fixes/test_transfer_rollbacksoldier-transfer feature not built(feature not in this repo)test_server_browsertest_txq_floodThe four feature-gated results are expected on this repo; those tests light up when their feature is present.
boot_checkpasses, andtest_geoscape_syncalready earns its keep by flagging a sync divergence.How to run
Notes / limitations
subprocess/STARTUPINFOwindow placement); the in-game server itself is portable.OXC_TEST_PORTis set.