GameCopier: fix three state-fidelity bugs in copied games - #11203
Conversation
Found with a differential fork-fidelity harness (copy a live game at a quiescent point, replay both to completion, compare per-turn state digests; 500 seeded AI-vs-AI Commander games per run): - Face-down exiled cards were rebuilt face-up from their paper card (foretell, 'exile face down' effects) — hidden information leaked into the copy, and the copied game's state visibly mismatched the original (45/60 static-mismatch repro seeds). Exile-zone copies now retain face-down and foretold state. - Player's field-managed effect cards (keyword, monarch, initiative, blessing, contraption sprocket, radiation, speed) were copied as zone contents but never wired to the new player's fields, so lazy getters re-created them on next use while the copied originals sat orphaned in the command zone — duplicate 'Keyword Effects' cards compounding with each copy generation (15/60 repro seeds), and a latent NPE during the copy itself (the mid-copy duplicate has no original to map for remembered-object fixup). New Player.copyEffectCardsToSnapshot follows the copyCommandersToSnapshot idiom; GameCopier's setBlessing call (the same duplication for the blessing effect card) is removed in its favor. - Copied cards were renumbered with fresh ids in zone-traversal order. Id order is AI-visible (Card.compareTo, id-keyed collections), so simulations on the copy deterministically diverge from how the same AI plays the original game (249/500 games diverged after a mid-game copy; 19/20 of those seeds play out identically after this fix). Copies now keep original card ids via the existing id-taking factory overloads; new Game.dangerouslySyncCardIdCounters aligns the fresh-id counters so later ids cannot collide. Also copy gamePieceType in the non-paper path. Three regression tests added to GameSimulationTest; each fails against the unfixed copier. Full forge-gui-desktop suite passes (285 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…1203), monitor standing entry; Anvil public Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tool4ever
left a comment
There was a problem hiding this comment.
if we're determining that it's reasonable to assign the same ID for simulation game copies too the main difference compared to GameSnapshot would no longer be a problem?
this essentially means the EXPERIMENTAL_RESTORE_SNAPSHOT guarded path should cover both use cases and a lot of duplicated code can finally be cleaned up, ensuring future fixes for those features can more easily be done together 🤔
dangerouslySyncCardIdCounters still makes sense though
|
Thanks — agreed on the direction. Now that copies keep original ids, the map-vs- Two data points from this investigation that support it: the snapshot path has sibling versions of two of the bugs fixed here — Proposal: keep this PR as the bug-fix layer for the currently-default path (the three fixes are differential-tested and independently useful), and I'll take the consolidation as a follow-up PR — the differential fidelity harness that found these bugs (copy-vs-original state digests over hundreds of seeded games + twin determinism replays) can gate the switch the same way. Happy to also fold the two snapshot-path fixes (foretold + effect-card wiring) into this PR if you'd prefer. |
…-Forge#11355 regression) SpellAbilityEffect.discard() writes the post-move collection back into the map it is handed (discardedMap.put at :906) so its DiscardedAll loop can read it. Upstream 28431f2 ("Recruit: add Effect", Card-Forge#11355, 2026-07-24) changed ConniveEffect to pass an immutable Map.of(...) and gave the new RecruitEffect the same shape, so every connive resolution throws UnsupportedOperationException. Our D4 rebase inherited it. Measured cost before the fix: 28 UnsupportedOperationException crashes in a 4,000-game arms read (0.7%), against 4 in all pre-rebase history, and concentrated in the five pool decks carrying connive cards (Illuminator Virtuoso, Ledger Shredder, Lethal Scheme) — a per-matchup bias, not uniform noise. That read is discarded. ConniveDiscardMapTest pins it, validated failing first (UOE from ConniveEffect:99 via SpellAbilityEffect:906; after: draw one discard one, hand 1 -> 1, graveyard 1). Suite 292 green. FORK-LOCAL AND DELIBERATELY NOT UPSTREAMED: Hanmac has taken responsibility for the regression and is fixing it. No caller of discard() reads the map back, so the maintainer fix (likely: make discard() keep its own local map, which restores Map.of() at the call sites) is behaviorally identical to this one — there is no divergence for the two engines to disagree along. Expect a conflict on these lines at the next rebase and DROP OURS in favour of upstream, the way the Card-Forge#11203 copier fixes came home. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ointer Zone.remove() never clears the removed card's zone field, so after a monarchy (or initiative) transfer the ex-holder's cached effect card keeps a stale zone reference; copyEffectCardsToSnapshot's pointer-based guard then fed it to find(), and every GameCopier.makeCopy of such a game threw 'Couldn't map The Monarch'. Found by the M4 D2 drill sweep (44/578 curated fork positions failed all K completions, deck-diffuse, monarch-transfer-correlated); the t2-16 sampled rollout pilot largely missed it. Guard now requires zone-list membership. Two differential tests, both validated failing first. Also: rollout copy/completion catches gain -Danvil.crash.trace stack traces (the silent (8,0) label pattern cost a diagnosis round). Desktop suite 299 green. Upstream candidate: natural follow-up to Card-Forge#11203 (same method); bundle with the getMonarchSet inverted ternary (Player.java:3433, NPE-when-null/null-when-set) spotted en route. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-Forge#11355 regression) SpellAbilityEffect.discard() writes the post-move collection back into the map it is handed (discardedMap.put at :906) so its DiscardedAll loop can read it. Upstream 28431f2 ("Recruit: add Effect", Card-Forge#11355, 2026-07-24) changed ConniveEffect to pass an immutable Map.of(...) and gave the new RecruitEffect the same shape, so every connive resolution throws UnsupportedOperationException. Our D4 rebase inherited it. Measured cost before the fix: 28 UnsupportedOperationException crashes in a 4,000-game arms read (0.7%), against 4 in all pre-rebase history, and concentrated in the five pool decks carrying connive cards (Illuminator Virtuoso, Ledger Shredder, Lethal Scheme) — a per-matchup bias, not uniform noise. That read is discarded. ConniveDiscardMapTest pins it, validated failing first (UOE from ConniveEffect:99 via SpellAbilityEffect:906; after: draw one discard one, hand 1 -> 1, graveyard 1). Suite 292 green. FORK-LOCAL AND DELIBERATELY NOT UPSTREAMED: Hanmac has taken responsibility for the regression and is fixing it. No caller of discard() reads the map back, so the maintainer fix (likely: make discard() keep its own local map, which restores Map.of() at the call sites) is behaviorally identical to this one — there is no divergence for the two engines to disagree along. Expect a conflict on these lines at the next rebase and DROP OURS in favour of upstream, the way the Card-Forge#11203 copier fixes came home. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ointer Zone.remove() never clears the removed card's zone field, so after a monarchy (or initiative) transfer the ex-holder's cached effect card keeps a stale zone reference; copyEffectCardsToSnapshot's pointer-based guard then fed it to find(), and every GameCopier.makeCopy of such a game threw 'Couldn't map The Monarch'. Found by the M4 D2 drill sweep (44/578 curated fork positions failed all K completions, deck-diffuse, monarch-transfer-correlated); the t2-16 sampled rollout pilot largely missed it. Guard now requires zone-list membership. Two differential tests, both validated failing first. Also: rollout copy/completion catches gain -Danvil.crash.trace stack traces (the silent (8,0) label pattern cost a diagnosis round). Desktop suite 299 green. Upstream candidate: natural follow-up to Card-Forge#11203 (same method); bundle with the getMonarchSet inverted ternary (Player.java:3433, NPE-when-null/null-when-set) spotted en route. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… leaves the game, null when set). The initiative twin two methods down is written correctly; this is its sibling defect, recorded on the upstream worklist since 2026-07-2x and deferred fork-side on boundary discipline - landing now at the D3 era boundary. Callers: Game monarch-leaves-game path. Upstream candidate (natural follow-up to Card-Forge#11203's method-sibling framing).
…-Forge#11355 regression) SpellAbilityEffect.discard() writes the post-move collection back into the map it is handed (discardedMap.put at :906) so its DiscardedAll loop can read it. Upstream 28431f2 ("Recruit: add Effect", Card-Forge#11355, 2026-07-24) changed ConniveEffect to pass an immutable Map.of(...) and gave the new RecruitEffect the same shape, so every connive resolution throws UnsupportedOperationException. Our D4 rebase inherited it. Measured cost before the fix: 28 UnsupportedOperationException crashes in a 4,000-game arms read (0.7%), against 4 in all pre-rebase history, and concentrated in the five pool decks carrying connive cards (Illuminator Virtuoso, Ledger Shredder, Lethal Scheme) — a per-matchup bias, not uniform noise. That read is discarded. ConniveDiscardMapTest pins it, validated failing first (UOE from ConniveEffect:99 via SpellAbilityEffect:906; after: draw one discard one, hand 1 -> 1, graveyard 1). Suite 292 green. FORK-LOCAL AND DELIBERATELY NOT UPSTREAMED: Hanmac has taken responsibility for the regression and is fixing it. No caller of discard() reads the map back, so the maintainer fix (likely: make discard() keep its own local map, which restores Map.of() at the call sites) is behaviorally identical to this one — there is no divergence for the two engines to disagree along. Expect a conflict on these lines at the next rebase and DROP OURS in favour of upstream, the way the Card-Forge#11203 copier fixes came home. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ointer Zone.remove() never clears the removed card's zone field, so after a monarchy (or initiative) transfer the ex-holder's cached effect card keeps a stale zone reference; copyEffectCardsToSnapshot's pointer-based guard then fed it to find(), and every GameCopier.makeCopy of such a game threw 'Couldn't map The Monarch'. Found by the M4 D2 drill sweep (44/578 curated fork positions failed all K completions, deck-diffuse, monarch-transfer-correlated); the t2-16 sampled rollout pilot largely missed it. Guard now requires zone-list membership. Two differential tests, both validated failing first. Also: rollout copy/completion catches gain -Danvil.crash.trace stack traces (the silent (8,0) label pattern cost a diagnosis round). Desktop suite 299 green. Upstream candidate: natural follow-up to Card-Forge#11203 (same method); bundle with the getMonarchSet inverted ternary (Player.java:3433, NPE-when-null/null-when-set) spotted en route. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… leaves the game, null when set). The initiative twin two methods down is written correctly; this is its sibling defect, recorded on the upstream worklist since 2026-07-2x and deferred fork-side on boundary discipline - landing now at the D3 era boundary. Callers: Game monarch-leaves-game path. Upstream candidate (natural follow-up to Card-Forge#11203's method-sibling framing).
Summary
GameCopierproduces copies that differ from the original game in threeways: face-down exiled cards come back face up, players' field-managed
effect cards get duplicated, and all cards are renumbered with fresh ids —
which changes how the AI plays the copy. This PR fixes all three, with
regression tests in
GameSimulationTest.Background: I am building a neural-network player on top of Forge
(non-commercial, GPL-aligned) and use
GameCopierfor state forking. Toqualify it, I built a differential fork-fidelity harness: play seeded
AI-vs-AI Commander games, copy the live game at a quiescent point
(empty stack, priority in a main phase), replay both the original and the
copy to completion with the same RNG state, and compare per-turn state
digests. Runs are 500 games over ~110 competitive Duel Commander decks
(~1,700 distinct cards). These fixes are the result; I'd like to keep
contributing what the harness finds.
The three bugs
1. Face-down exiled cards are rebuilt face up (
createCardCopyrebuilds from the paper card, which defaults to face up). Foretold cards
and "exile face down" effects leak hidden information into the copy and
the copied state visibly mismatches the original. This was 45 of the 60
state-corruption repro seeds in a 500-game run. Fix: exile-zone copies
retain face-down and foretold state.
2. Player effect cards duplicate across copies. A player's
field-managed effect cards (keyword, monarch, initiative, blessing,
contraption sprocket, radiation, speed) are copied as command-zone
contents but never wired to the new
Player's fields, so the lazygetters re-create them on next use while the copied originals sit
orphaned in the command zone — duplicate "Keyword Effects" cards that
compound with each copy generation (the other 15/60 repro seeds). Fix:
new
Player.copyEffectCardsToSnapshot, following the existingcopyCommandersToSnapshotidiom;GameCopier'ssetBlessingcall(the same duplication for the blessing card) is removed in its favor.
3. Copies renumber card ids, which changes AI behavior. Copied cards
get fresh ids in zone-traversal order, but id order is AI-visible:
Card.compareTois id-based and several AI paths iterate id-keyedcollections. The practical effect is that a simulation running on the
copy deterministically diverges from how the same AI plays the original
game — in the harness, 249/500 games played out differently after a
mid-game copy, purely from renumbering. Fix: copies keep their original
ids via the id-taking factory overloads that already exist on all four
copy paths; a new
Game.dangerouslySyncCardIdCountersaligns thefresh-id counters so ids handed out after the copy can't collide. Also
copies
gamePieceTypein the non-paper path.(Naming:
dangerouslySyncCardIdCountersis deliberately alarming sincemis-use would mint duplicate ids; happy to rename if you prefer.)
Measured effect (500-game differential runs, same seeds/decks)
The residual 13.4% reproduces deterministically and traces to the
documented this-turn copy TODOs in
GameCopier(
creatureAttackedThisTurn,thisTurnCast, counters-added tracking) —out of scope here; I have repro seeds and may follow up.
Tests
Three regression tests added to
GameSimulationTest:testGameCopyPreservesFaceDownExileStatetestGameCopyWiresPlayerEffectCardstestGameCopyPreservesCardIdsAll three fail against the unfixed copier (verified by reverting the three
source files and re-running). One extra finding from that check: with a
monarch or blessing effect card in play, the unfixed copier can NPE
outright during the copy (
Card.hasRemembered()on a null mapping —the duplicate effect card created mid-copy has no original to map to),
so bug 2 was also a latent crash, not just state corruption.
All pre-existing
forge-gui-desktoptests pass with the fix.Per the contributing guide's AI-agents note: this contribution was
substantially coded with Claude Code (the commit carries the co-author
trailer); the diagnosis, measurements, and review are mine.
🤖 Generated with Claude Code