Skip to content

fix(engine): qualify zone-change occurrences by turn - #7075

Merged
matthewevans merged 21 commits into
mainfrom
ship/fix-turn-occurrence-provenance-7065
Aug 7, 2026
Merged

fix(engine): qualify zone-change occurrences by turn#7075
matthewevans merged 21 commits into
mainfrom
ship/fix-turn-occurrence-provenance-7065

Conversation

@matthewevans

@matthewevans matthewevans commented Aug 7, 2026

Copy link
Copy Markdown
Member

Fixes #7065.

Zone-change occurrence identity now includes its recording turn, preventing a deferred trigger from aliasing the reset per-turn index after the next-turn transition. The change preserves legacy serialized batched trigger keys, updates replay validation, and adds cross-turn regression coverage.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed zone-change tracking across turn boundaries, preventing valid events from being skipped or duplicated.
    • Improved replay validation to reject zone changes recorded for the wrong turn.
    • Preserved zone-change history and trigger behavior when restoring saved game state.
    • Added compatibility for older saved data and improved persisted event recovery.
  • Tests

    • Added coverage for turn-boundary deduplication, replay validation, and saved-state restoration.

@matthewevans
matthewevans enabled auto-merge August 7, 2026 02:56
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9cc4cabb-4193-47a1-96e4-18664a2f8422

📥 Commits

Reviewing files that changed from the base of the PR and between 2e258e2 and 860b58b.

📒 Files selected for processing (7)
  • crates/engine/src/game/restrictions.rs
  • crates/engine/src/game/triggers.rs
  • crates/engine/src/game/triggers_dedup_regression_tests.rs
  • crates/engine/src/game/zones.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/types/resolution.rs
  • crates/engine/tests/integration/loop_shortcut.rs
🚧 Files skipped from review as they are similar to previous changes (7)
  • crates/engine/src/game/triggers.rs
  • crates/engine/src/game/restrictions.rs
  • crates/engine/src/types/resolution.rs
  • crates/engine/src/game/triggers_dedup_regression_tests.rs
  • crates/engine/src/game/zones.rs
  • crates/engine/tests/integration/loop_shortcut.rs
  • crates/engine/src/types/game_state.rs

📝 Walkthrough

Walkthrough

Zone-change records now store turn identity with their per-turn index. Trigger deduplication uses both values. Replay validates turn identity. Persistence loading migrates legacy keys and reconciles live zone-change occurrences across resolution formats.

Changes

Zone-change provenance

Layer / File(s) Summary
Recording and replay provenance
crates/engine/src/game/{game_object.rs,restrictions.rs,zones.rs,...}, crates/engine/src/types/{game_state.rs,resolved_commands.rs}, crates/engine/tests/integration/*
ZoneChangeRecord stores recorded_turn_number. Recording retains updated turn and index values. Replay rejects mismatched turns. Fixtures and replay tests include the new field.
Turn-aware trigger deduplication
crates/engine/src/game/triggers.rs, crates/engine/src/game/triggers_dedup_regression_tests.rs
Deferred and batched trigger markers use (recorded_turn_number, turn_zone_change_index) keys. Regression tests cover turn-boundary index reuse.
Persistence reconciliation and migration
crates/engine/src/types/{game_state.rs,resolution.rs}, crates/engine/tests/integration/loop_shortcut.rs
Persistence loading migrates legacy keys, reconciles live ZoneChanged occurrences, validates references, and restores v1 and v2 resolution payloads.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PersistenceLoader
  participant ResolutionStateWire
  participant GameState
  participant TriggerState
  PersistenceLoader->>ResolutionStateWire: decode persisted resolution
  ResolutionStateWire->>GameState: reconcile zone-change occurrences
  GameState->>TriggerState: restore turn/index trigger keys
  TriggerState-->>GameState: validate replay references
Loading

Possibly related issues

  • phase-rs/phase#7066: The issue covers persisted zone-change reconciliation and preservation of turn/index provenance.

Possibly related PRs

Suggested labels: bug

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary fix: qualifying zone-change occurrences by turn.
Linked Issues check ✅ Passed The changes address issue #7065 by adding turn identity to zone-change occurrence matching and preserving this identity across replay and persistence.
Out of Scope Changes check ✅ Passed The changes remain within scope by updating zone-change identity, replay, persistence migration, and related regression tests.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ship/fix-turn-occurrence-provenance-7065

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ast-grep (0.45.0)
crates/engine/src/game/triggers.rs

ast-grep timed out on this file


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Generated for head 860b58b0cdc18e32ffaa69c1466011f669d80c15.

Parse changes introduced by this PR

✓ No card-parse changes detected.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🧹 Nitpick comments (3)
crates/engine/src/types/game_state.rs (2)

7819-7877: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Replace the guard chain with an Ordering match to remove the unreachable! panic from the persistence path.

The four Some(...) guards partition recorded_turn against current_turn, so the trailing Some(_) => unreachable!(...) arm is dead today. It is still a panic inside a deserialization boundary. If a future edit narrows one guard, restore fails with a process panic instead of an Err. Matching on recorded_turn.cmp(&current_turn) makes the partition compiler-checked and drops the arm.

♻️ Proposed restructure
+    use std::cmp::Ordering;
     let (recorded_turn, index) = match recorded_turn {
         Some(0) if current_turn != 0 => reconcile_current_turn_zone_changed_record(
             record,
             current_turn,
             &fingerprint,
             occurrences,
         )?,
-        Some(turn) if turn < current_turn => {
+        Some(turn) => match turn.cmp(&current_turn) {
+            Ordering::Less => {
                 let index = index.ok_or_else(|| {
                     "prior-turn ZoneChanged record is missing its occurrence index".to_string()
                 })?;
                 (turn, index)
-        }
-        Some(turn) if turn > current_turn => {
+            }
+            Ordering::Greater => {
                 return Err("ZoneChanged record is stamped from a future turn".to_string());
-        }
-        Some(turn) if turn == current_turn => {
+            }
+            Ordering::Equal => {
                 if let Some(index) = index {
                     ...
                 }
-        }
+            }
+        },
         ...
-        Some(_) => unreachable!("future turns return above"),
     };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/types/game_state.rs` around lines 7819 - 7877, Refactor the
recorded_turn handling to match on recorded_turn.cmp(&current_turn) using
Ordering variants, while preserving the existing behavior for missing, prior,
current, and future turns. Remove the guard-based Some(...) partition and the
trailing unreachable!("future turns return above") arm, ensuring future-turn
cases return the same Err without any panic path.

Source: Coding guidelines


7935-7957: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The live-carrier allow-list is stringly typed and has no drift guard.

LIVE_EVENT_CARRIER_FIELDS lists GameState field names as string literals. If a later change adds a live field that carries a ZoneChanged event, reconciliation silently skips it and that event restores with a stale or defaulted occurrence key. Nothing fails at compile time.

The paired test persisted_zone_change_traverses_direct_queue_and_stack_carriers asserts keys.len() >= 9. That aggregate count does not prove each listed carrier was visited: one carrier holding several events satisfies it while another carrier contributes nothing. Assert per carrier instead, so a dropped or misspelled field name fails the test.

Consider deriving the list from a single typed source, or add a test that serializes a state with exactly one event per carrier and asserts the visited count equals LIVE_EVENT_CARRIER_FIELDS.len().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/types/game_state.rs` around lines 7935 - 7957, The
LIVE_EVENT_CARRIER_FIELDS allow-list and its reconciliation test lack protection
against missing or misspelled carriers. Update
persisted_zone_change_traverses_direct_queue_and_stack_carriers to construct a
state with exactly one ZoneChanged event in every carrier named by
LIVE_EVENT_CARRIER_FIELDS and assert each carrier is visited, with the total
visited count equal to LIVE_EVENT_CARRIER_FIELDS.len(); if feasible, replace the
string list with a typed single source of truth.

Source: Path instructions

crates/engine/src/game/effects/token.rs (1)

1165-1170: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider validating command.entry_turn against state.turn_number, matching the zone-change replay guard.

apply_resolved_zone_change now rejects a replay whose zone_change_record.recorded_turn_number differs from state.turn_number (RecordedTurnMismatch). This token-birth applier records an equivalent ledger row through the same authority, but performs no turn check. record_zone_change stamps state.turn_number unconditionally, so a replay that runs against a state at a different turn writes a ledger row with a turn the command never recorded, and no error is raised. The command already carries entry_turn, so the comparison is available.

🛡️ Proposed guard
+    if command.entry_turn != state.turn_number {
+        return Err(ResolvedTokenCreationReplayInvariantError::EntryTurnMismatch {
+            expected: command.entry_turn,
+            found: state.turn_number,
+        });
+    }
     let mut entry_record = state
         .objects
         .get(&object_id)
         .expect("the token was materialized above")
         .snapshot_for_zone_change(object_id, None, Zone::Battlefield);
     crate::game::restrictions::record_zone_change(state, &mut entry_record);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/game/effects/token.rs` around lines 1165 - 1170, Validate
the token-birth command’s entry turn against state.turn_number before creating
the ledger record in the token applier containing entry_record and
record_zone_change. Reject mismatches using the same RecordedTurnMismatch
behavior as apply_resolved_zone_change, and only call record_zone_change after
validation succeeds.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/src/types/game_state.rs`:
- Around line 8010-8026: Update validate_restored_zone_change_replay_keys to
handle valid prior-turn batched replay keys: either ensure
batched_zone_change_trigger_fired is cleared when the turn boundary discards
zone_changes_this_turn, or skip validated legacy entries whose recorded turn
predates state.turn_number. Preserve strict ledger validation for current-turn
keys.
- Around line 7734-7766: Ensure legacy batched trigger migration runs before
every ResolutionStateWire deserialization, including trusted envelopes, raw
saves, and GameStateDecode::decode_persisted_resolution_state. Prefer adding it
at the shared persistence chokepoint, or invoke
migrate_legacy_batched_zone_change_trigger_fired at the start of
reconcile_persisted_zone_change_occurrences before
reindex_persisted_batched_zone_change_trigger_keys validates three-field tuples,
so historical two-field entries gain their recorded turn.

---

Nitpick comments:
In `@crates/engine/src/game/effects/token.rs`:
- Around line 1165-1170: Validate the token-birth command’s entry turn against
state.turn_number before creating the ledger record in the token applier
containing entry_record and record_zone_change. Reject mismatches using the same
RecordedTurnMismatch behavior as apply_resolved_zone_change, and only call
record_zone_change after validation succeeds.

In `@crates/engine/src/types/game_state.rs`:
- Around line 7819-7877: Refactor the recorded_turn handling to match on
recorded_turn.cmp(&current_turn) using Ordering variants, while preserving the
existing behavior for missing, prior, current, and future turns. Remove the
guard-based Some(...) partition and the trailing unreachable!("future turns
return above") arm, ensuring future-turn cases return the same Err without any
panic path.
- Around line 7935-7957: The LIVE_EVENT_CARRIER_FIELDS allow-list and its
reconciliation test lack protection against missing or misspelled carriers.
Update persisted_zone_change_traverses_direct_queue_and_stack_carriers to
construct a state with exactly one ZoneChanged event in every carrier named by
LIVE_EVENT_CARRIER_FIELDS and assert each carrier is visited, with the total
visited count equal to LIVE_EVENT_CARRIER_FIELDS.len(); if feasible, replace the
string list with a typed single source of truth.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a27d1d15-53d8-4a4c-bdcc-1a658ced3374

📥 Commits

Reviewing files that changed from the base of the PR and between 452216a and 2e258e2.

📒 Files selected for processing (19)
  • crates/engine/src/game/derived_views.rs
  • crates/engine/src/game/effects/token.rs
  • crates/engine/src/game/filter.rs
  • crates/engine/src/game/game_object.rs
  • crates/engine/src/game/meld.rs
  • crates/engine/src/game/merge.rs
  • crates/engine/src/game/restrictions.rs
  • crates/engine/src/game/stack.rs
  • crates/engine/src/game/triggers.rs
  • crates/engine/src/game/triggers_dedup_regression_tests.rs
  • crates/engine/src/game/zones.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/types/resolution.rs
  • crates/engine/src/types/resolved_commands.rs
  • crates/engine/tests/integration/cr733_resolved_zone_change.rs
  • crates/engine/tests/integration/issue_3277_captain_nghathrod_eliminated_opponent.rs
  • crates/engine/tests/integration/issue_5332_gandalf_trigger_doubling.rs
  • crates/engine/tests/integration/loop_shortcut.rs
  • crates/engine/tests/integration/madame_null_integration.rs

Comment thread crates/engine/src/types/game_state.rs
Comment thread crates/engine/src/types/game_state.rs
@matthewevans
matthewevans force-pushed the ship/fix-turn-occurrence-provenance-7065 branch from 2e258e2 to 860b58b Compare August 7, 2026 18:15
@matthewevans
matthewevans added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit 61f559e Aug 7, 2026
13 checks passed
@matthewevans
matthewevans deleted the ship/fix-turn-occurrence-provenance-7065 branch August 7, 2026 18:59
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.

engine: deferred_triggers survives the turn boundary while turn_zone_change_index resets — cross-turn occurrence-witness aliasing

1 participant