Skip to content

GameCopier: fix three state-fidelity bugs in copied games - #11203

Merged
tool4ever merged 1 commit into
Card-Forge:masterfrom
Tyrathalis:gamecopier-fidelity-fixes
Jul 12, 2026
Merged

GameCopier: fix three state-fidelity bugs in copied games#11203
tool4ever merged 1 commit into
Card-Forge:masterfrom
Tyrathalis:gamecopier-fidelity-fixes

Conversation

@Tyrathalis

Copy link
Copy Markdown
Contributor

Summary

GameCopier produces copies that differ from the original game in three
ways: 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 GameCopier for state forking. To
qualify 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 (createCardCopy
rebuilds 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 lazy
getters 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 existing
copyCommandersToSnapshot idiom; GameCopier's setBlessing call
(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.compareTo is id-based and several AI paths iterate id-keyed
collections. 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.dangerouslySyncCardIdCounters aligns the
fresh-id counters so ids handed out after the copy can't collide. Also
copies gamePieceType in the non-paper path.

(Naming: dangerouslySyncCardIdCounters is deliberately alarming since
mis-use would mint duplicate ids; happy to rename if you prefer.)

Measured effect (500-game differential runs, same seeds/decks)

metric before after
copies with corrupted state (bugs 1+2) 12% 0%
trajectories diverging after a mid-game copy (bug 3) 49.8% 13.4%

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:

  • testGameCopyPreservesFaceDownExileState
  • testGameCopyWiresPlayerEffectCards
  • testGameCopyPreservesCardIds

All 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-desktop tests 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

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>
Tyrathalis added a commit to Tyrathalis/anvil that referenced this pull request Jul 11, 2026
…1203), monitor standing entry; Anvil public

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@tool4ever tool4ever left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@Tyrathalis

Copy link
Copy Markdown
Contributor Author

Thanks — agreed on the direction. Now that copies keep original ids, the map-vs-findById distinction is the only load-bearing difference between the two paths, and GameCopier already delegates to GameSnapshot behind EXPERIMENTAL_RESTORE_SNAPSHOT, so consolidating there looks right.

Two data points from this investigation that support it: the snapshot path has sibling versions of two of the bugs fixed here — setForetold/setForetoldCostByEffect are commented out in setCardInCopiedGame, and only commanders get re-wired, so the seven field-managed effect cards (keyword/monarch/initiative/blessing/sprocket/radiation/speed) re-create lazily while the copied originals sit orphaned. That's exactly the fixes-diverge-across-paths failure mode. (copyEffectCardsToSnapshot went on Player beside copyCommandersToSnapshot so both paths can share it.) Remaining deltas before snapshot can own simulation copies: the delegation ignores advanceToPhase (existing TODO), and PRUNE_HIDDEN_INFO has no snapshot equivalent.

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.

@tool4ever tool4ever left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

acceptable

the Copier/Snapshot duplication is a longer technical debt (and getting rid of that should also help with your project)

@tool4ever
tool4ever merged commit 1922ce4 into Card-Forge:master Jul 12, 2026
2 checks passed
Tyrathalis added a commit to Tyrathalis/forge that referenced this pull request Jul 27, 2026
…-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>
@Tyrathalis
Tyrathalis deleted the gamecopier-fidelity-fixes branch July 30, 2026 17:11
Tyrathalis added a commit to Tyrathalis/forge that referenced this pull request Aug 1, 2026
…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>
Tyrathalis added a commit to Tyrathalis/forge that referenced this pull request Aug 12, 2026
…-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>
Tyrathalis added a commit to Tyrathalis/forge that referenced this pull request Aug 12, 2026
…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>
Tyrathalis added a commit to Tyrathalis/forge that referenced this pull request Aug 12, 2026
… 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).
Tyrathalis added a commit to Tyrathalis/forge that referenced this pull request Aug 21, 2026
…-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>
Tyrathalis added a commit to Tyrathalis/forge that referenced this pull request Aug 21, 2026
…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>
Tyrathalis added a commit to Tyrathalis/forge that referenced this pull request Aug 21, 2026
… 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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants