From 37fb357209a53ba119c9d658dae9199128d0d88b Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Sat, 1 Aug 2026 16:29:19 -0500 Subject: [PATCH 1/6] fix(engine): one resume-safe record/emit lifecycle for token battlefield entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR 400.7 + CR 403.3. A `TokenEntryEventEmission::Suppress` token's battlefield entry was recorded by the finalize tail BEFORE `BecomeCopy` resolved and its content refreshed only in `handle_copy_target_choice`'s fully-unpaused tail, so every mid-entry pause kept a pre-copy row — and four of the five pause returns in `finish_copy_target_choice_entry` never reached the deferred emit at all. Measured on the maintainer's named path (Embalm Vizier of Many Faces copying Painter's Servant, whose mandatory as-enters `NamedChoice` returns from `finish_copy_target_choice_entry`): both ledgers held a pre-copy `Vizier of Many Faces` 0/0 row and no entry event was emitted. Replace the split record-here / refresh-there route with one postponed lifecycle: - The `Suppress` finalize tail RECORDS NOTHING. It parks the entry on `GameState::pending_token_battlefield_entry` (serde-persisted, so it survives arbitrary client round trips — CR 614.12a puts the as-enters choice before the permanent enters). - `token::flush_pending_token_battlefield_entry` is the sole consumer. It takes the parked value with `Option::take_if` and, over that one owned value, calls `restrictions::record_zone_change` — the single writer of BOTH CR 400.7 ledgers — and pushes the CR 603.6a entry pair. "Recorded but never emitted", "emitted but never recorded", and the duplicate battlefield-entry row are unrepresentable rather than guarded. - `emit_recorded_token_entry_events` is deleted. Its `(None, Some(_))` arm — which appended a duplicate `battlefield_entries_this_turn` row, reported by CodeRabbit — no longer has a representation: nothing reads a previously-written row back. Three convergence points feed that one authority, and none subsumes another (each is revert-probed): - `finish_copy_target_choice_entry`, for the unpaused route, whose action ends on a stale `CopyTargetChoice` and so would otherwise realize one client round trip late; - the `EmitCommittedCopyTokenEntry` post-action, inside the CR 616.1 counter drain, ahead of that action's trigger scan; - `token::realize_settled_token_battlefield_entry`, one gate (settled `WaitingFor::Priority` + the token still on the battlefield) called from two sites in `engine.rs`: inside `apply_action` immediately before `run_post_action_pipeline`, so the realized pair is trigger-scanned in its own action (CR 603.6a — Soul Warden observes the copy token), and again at the action boundary as the backstop for handlers that return an `ActionResult` straight out of the reducer match. The settled gate is pause-shape-agnostic by construction: it names the settled state, the complement of "any pause", so no `WaitingFor` variant appears in it. That is what covers the as-enters class whose continuation raises a SECOND pause (11 Tribute creatures and the `RevealHand` chains), which a resume-arm hook would have missed. Known partial, measured and pinned: `handle_tribute_choice` builds its `ActionResult` directly out of the reducer match, so the two-pause class realizes at the boundary — after the trigger scan — and its ETB observers do not fire (Soul Warden delta 0). Both ledgers and the emit are correct there; this is strictly better than the previous behaviour, which emitted nothing at all for that class, and it is a property of the reducer's direct-return handlers rather than of this lifecycle. Regressions drive the real cast pipeline and pin all four routes, each with a reach-guard on the exact prompt sequence: the Painter's Servant mandatory-choice pause (post-copy identity on both ledgers exactly once, a non-empty emitted pair carrying the recorder-assigned index, and the ETB observer at +1), the Fanatic of Xenagos two-pause class, the CR 616.1 counter-ordering pause, and the unpaused route; plus a positive control on the untouched `Emit` path. Reported by matthewevans (#6851 review) and CodeRabbit; both verified before fixing. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/effects/counters.rs | 24 +- crates/engine/src/game/effects/token.rs | 510 +++++++++- crates/engine/src/game/elimination.rs | 10 + crates/engine/src/game/engine.rs | 19 +- crates/engine/src/game/engine_replacement.rs | 63 +- crates/engine/src/game/scenario_db.rs | 3 + crates/engine/src/game/turns.rs | 6 + crates/engine/src/types/game_state.rs | 29 +- .../fixtures/cr733/authority_matrix.json.gz | Bin 41149 -> 41369 bytes .../integration/token_zone_change_index.rs | 922 +++++++++++++++++- 10 files changed, 1506 insertions(+), 80 deletions(-) diff --git a/crates/engine/src/game/effects/counters.rs b/crates/engine/src/game/effects/counters.rs index 057ff7b941..2aaf1c44c8 100644 --- a/crates/engine/src/game/effects/counters.rs +++ b/crates/engine/src/game/effects/counters.rs @@ -713,14 +713,22 @@ fn apply_pending_counter_post_action( remaining_count, events, ), - PendingCounterPostAction::EmitCommittedCopyTokenEntry { - object_id, - name, - source_id, - } => { - super::token::push_committed_token_entry_events( - state, object_id, name, source_id, events, - ); + PendingCounterPostAction::EmitCommittedCopyTokenEntry { object_id } => { + // CR 400.7 + CR 616.1: the ETB-counter ordering choice is answered and `BecomeCopy` + // has run (or, on the pre-`BecomeCopy` commit pause, the copy chain was abandoned and + // this is as realized as that route gets), so realize the entry inside the drain — + // before the rest of this action, whether or not that action settles. + // + // MEASURED redundancy, stated rather than implied: when the drain's action DOES settle + // to `Priority` (the Faithful Watchdog fixture in + // `tests/integration/token_zone_change_index.rs`, and every route the current card pool + // reaches), `token::realize_settled_token_battlefield_entry` inside `apply_action` + // would realize it anyway, still ahead of the CR 603.2 trigger scan — deleting this + // call alone flips no test. It is kept for a drain that does NOT settle in its own + // action, where this is the only in-action realization point. `false` means an earlier + // convergence point already realized it (structurally idempotent, `Option::take_if`), + // which is not an error. + let _ = super::token::flush_pending_token_battlefield_entry(state, object_id, events); if !state.last_created_token_ids.contains(&object_id) { state.last_created_token_ids.push(object_id); } diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index c23de1d81e..96cc4d1441 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -20,7 +20,7 @@ use crate::types::events::GameEvent; use crate::types::game_state::{ DelayedTrigger, GameState, LiminalEntry, LiminalTokenAbilityInjection, PendingCopyTokenBatch, PendingCounterAddition, PendingCounterPostAction, PendingEffectResolutionEvent, - TokenEntryEventEmission, WaitingFor, + PendingTokenBattlefieldEntry, TokenEntryEventEmission, WaitingFor, }; use crate::types::identifiers::{CardId, ObjectId, ObjectIncarnationRef, TrackedSetId}; use crate::types::keywords::{Keyword, WardCost}; @@ -1699,8 +1699,9 @@ pub(crate) fn finalize_committed_liminal_token_entry_from_action( } } crate::game::layers::mark_layers_entered(state, object_id); - // CR 403.3 battlefield-entry bookkeeping is done by `record_zone_change` inside - // `push_committed_token_entry_events` below — recording it here too double-counts. + // CR 403.3 battlefield-entry bookkeeping is done by `record_zone_change`, reached from the + // `entry_events` match below (directly on the `Emit` route, via the parked entry's flush on + // the `Suppress` route) — recording it here too double-counts. crate::game::restrictions::record_token_created(state, object_id); if enters_attacking { @@ -1717,8 +1718,46 @@ pub(crate) fn finalize_committed_liminal_token_entry_from_action( }; } - if matches!(entry_events, TokenEntryEventEmission::Emit) { - push_committed_token_entry_events(state, object_id, name, source_id, events); + // CR 400.7 + CR 403.3 + CR 614.12a: the entry RECORD and the entry EVENTS are one indivisible + // operation over one snapshot, and both wait until the object IS the thing that entered. + // `Emit` means it already is (nothing is deferred on that route). `Suppress` means it is not + // yet — `BecomeCopy` has not run and any mandatory as-enters choice is unanswered — so the + // whole entry is PARKED on `GameState` and realized later by + // `flush_pending_token_battlefield_entry`. Recording here instead would write CR 400.7's "the + // state at the moment of the move" from a pre-copy 0/0 Shapeshifter. + match entry_events { + TokenEntryEventEmission::Emit => { + push_committed_token_entry_events(state, object_id, name, source_id, events); + } + TokenEntryEventEmission::Suppress => { + // Overwriting a live parked entry would silently lose its CR 400.7 row AND both of its + // entry events — the precise failure mode this lifecycle exists to remove. A + // `debug_assert!` alone does not remove it: it compiles out in release, unlike the + // `pending_liminal_entry_resume` precedent in `engine_replacement.rs`, which returns an + // `Err` in every profile. So realize the outgoing entry FIRST (data preserved in every + // profile), and keep the assert as the debug-profile tripwire, because an entry + // realized here is realized from a snapshot taken at a moment nobody designed for. + // Exactly one liminal copy entry can be in flight today: the multi-token continuation + // runs only after `finish_copy_target_choice_entry` returned `Ok(None)`, i.e. after the + // copy-completion convergence point already flushed. Measured: zero fires across the + // engine suite. + let stranded = state + .pending_token_battlefield_entry + .as_ref() + .map(|pending| pending.object_id); + if let Some(stranded_id) = stranded { + flush_pending_token_battlefield_entry(state, stranded_id, events); + } + debug_assert!( + stranded.is_none(), + "CR 400.7: parking a token battlefield entry over a live pending one: {stranded:?}" + ); + state.pending_token_battlefield_entry = Some(PendingTokenBattlefieldEntry { + object_id, + name, + source_id, + }); + } } if matches!(sacrifice_at, Some(Duration::UntilEndOfCombat)) { let sacrifice_token = DelayedTrigger { @@ -1761,6 +1800,11 @@ pub(crate) fn finalize_committed_liminal_token_entry_from_action( /// /// Callers must NOT also call `record_battlefield_entry` — `record_zone_change` does it, and a /// second call double-counts `battlefield_entries_this_turn`. +/// +/// This is the `TokenEntryEventEmission::Emit` half of the lifecycle: the object is already fully +/// realized when the finalize tail runs, so record and emit happen inline. The `Suppress` half +/// parks the entry and realizes it through [`flush_pending_token_battlefield_entry`], which pairs +/// the same two authorities in the same order. pub(crate) fn push_committed_token_entry_events( state: &mut GameState, object_id: ObjectId, @@ -1768,18 +1812,147 @@ pub(crate) fn push_committed_token_entry_events( source_id: ObjectId, events: &mut Vec, ) { - let entry = state + let record = record_committed_token_entry(state, object_id); + push_token_entry_events_for_record(record, object_id, name, source_id, events); +} + +/// CR 400.7 + CR 403.3: record a token's battlefield entry through +/// [`crate::game::restrictions::record_zone_change`] — the single authority that assigns this +/// turn's zone-change index and performs the CR 403.3 battlefield-entry bookkeeping — and emit +/// NOTHING. +/// +/// Split out of [`push_committed_token_entry_events`] because the record is *state*, not an +/// event; both of its callers ([`push_committed_token_entry_events`] and +/// [`flush_pending_token_battlefield_entry`]) pair it with the emit in the same breath. +/// +/// Returns the recorded zone change with its index assigned, so the caller emits the row it just +/// wrote instead of recording a second time (which would double-count +/// `battlefield_entries_this_turn`). `None` when the object is already gone. +pub(crate) fn record_committed_token_entry( + state: &mut GameState, + object_id: ObjectId, +) -> Option { + let mut zone_change_record = state .objects .get(&object_id) - .map(|token| token.snapshot_for_zone_change(object_id, None, Zone::Battlefield)); - if let Some(mut zone_change_record) = entry { - zone_change_record.turn_zone_change_index = - crate::game::restrictions::record_zone_change(state, zone_change_record.clone()); + .map(|token| token.snapshot_for_zone_change(object_id, None, Zone::Battlefield))?; + zone_change_record.turn_zone_change_index = + crate::game::restrictions::record_zone_change(state, zone_change_record.clone()); + Some(zone_change_record) +} + +/// CR 400.7 + CR 403.3 + CR 614.12a: realize a postponed token battlefield entry — record it +/// through `record_zone_change` and emit its entry pair — at the first instant the object IS the +/// thing that entered. Record and emit are ONE indivisible operation over ONE owned value, so no +/// route can perform half of it. Returns `false` when no entry is parked for `object_id`. +/// +/// Idempotence is structural: [`Option::take_if`] consumes the parked value, so a second call for +/// the same object is a no-op and the duplicate-row class is unrepresentable rather than guarded. +/// +/// LOOK-BACK WINDOW (owned, not hidden): between the commit and this flush the token is on the +/// battlefield with ZERO rows on either CR 400.7 / CR 403.3 ledger, and on a paused route that +/// window spans one or more client round-trips. `game/quantity.rs`'s zone-change scans and +/// `restrictions::battlefield_entry_matches_filter` therefore answer "0 entered this turn" for it +/// during the window. That is inherent to postponing, and it is the lesser error: recording early +/// answers "1" with the WRONG object (a 0/0 pre-copy Shapeshifter), which silently mis-answers +/// "each Zombie that entered this turn" rather than under-counting an entry that, per CR 614.12a, +/// has not finished happening. +/// +/// SBA SCOPE — what the rules do and do NOT guarantee about the window. CR 704.3 checks +/// state-based actions only when a player would get priority, and CR 704.4 says they pay no +/// attention to what happens during the resolution of a spell or ability, so nothing can remove the +/// token while the entry is PAUSED on a replacement/choice prompt. Neither rule covers the action +/// that finally settles: that action runs its own SBA pass inside `run_post_action_pipeline`, with +/// the entry still parked. That is exactly why [`realize_settled_token_battlefield_entry`] is +/// called from inside `apply_action` BEFORE that pipeline — a copy realized with toughness 0 gets +/// its CR 400.7 row written and its pair emitted before CR 704.5f can bury it. +/// [`record_committed_token_entry`]'s `None` arm remains the fail-safe for an object that is gone +/// by flush time. +pub(crate) fn flush_pending_token_battlefield_entry( + state: &mut GameState, + object_id: ObjectId, + events: &mut Vec, +) -> bool { + let Some(pending) = state + .pending_token_battlefield_entry + .take_if(|pending| pending.object_id == object_id) + else { + return false; + }; + let record = record_committed_token_entry(state, pending.object_id); + push_token_entry_events_for_record( + record, + pending.object_id, + pending.name, + pending.source_id, + events, + ); + true +} + +/// CR 400.7 + CR 603.6a: realize a parked token battlefield entry once the action carrying it has +/// SETTLED — `WaitingFor::Priority`, the complement of "any pause", so the gate is pause-shape +/// agnostic by construction instead of enumerating prompt variants. +/// +/// ONE gate, TWO call sites in `engine.rs`, both settled-action convergence points: +/// +/// * inside `apply_action`, immediately before `engine_priority::run_post_action_pipeline` — so the +/// entry pair is in the event set that action's CR 603.2 / CR 603.6a trigger scan reads. This is +/// what makes the copy token's ETB observers ("whenever another creature enters") fire, and it +/// also puts the CR 400.7 row on the ledger before that pipeline's SBA pass (CR 704.3) can bury a +/// 0-toughness copy under CR 704.5f. +/// * in `apply_action_boundary_core`, after `apply_action` returned — the backstop for handlers +/// that build an `ActionResult` straight out of the reducer match and never reach that pipeline +/// (`handle_tribute_choice` is the reachable one). The entry is still realized on both ledgers +/// and still emitted, but AFTER the trigger scan, so that class's ETB observers do not fire. That +/// partial is tracked separately; it is strictly better than dropping the entry entirely. +/// +/// Order between the two is irrelevant: the flush's `Option::take_if` makes the second call — and +/// any call after the two in-resolution convergence points in `engine_replacement.rs` / +/// `counters.rs` — a no-op. +/// +/// CR 704.5f: when the token is no longer on the battlefield at the settling point, the parked +/// entry is DROPPED — no row, no pair — rather than emitting a battlefield-entry event for an +/// object that is not there, which would make ETB triggers fire for a permanent that has already +/// left. The cost is a lost CR 400.7 row for an entry that did happen. After the in-`apply_action` +/// call above, the only way to reach this branch is a settling action that never runs the pipeline +/// AND removes the token within itself; no production route is known to do both. +pub(crate) fn realize_settled_token_battlefield_entry( + state: &mut GameState, + events: &mut Vec, +) { + if !matches!(state.waiting_for, WaitingFor::Priority { .. }) { + return; + } + let Some(pending_id) = state + .pending_token_battlefield_entry + .as_ref() + .map(|pending| pending.object_id) + else { + return; + }; + if state.battlefield.contains(&pending_id) { + flush_pending_token_battlefield_entry(state, pending_id, events); + } else { + state.pending_token_battlefield_entry = None; + } +} + +/// The event half of a token battlefield entry, shared by the immediate (`Emit`) and postponed +/// (`Suppress` + flush) routes so the emitted pair is defined exactly once. +fn push_token_entry_events_for_record( + record: Option, + object_id: ObjectId, + name: String, + source_id: ObjectId, + events: &mut Vec, +) { + if let Some(record) = record { events.push(GameEvent::ZoneChanged { object_id, from: None, to: Zone::Battlefield, - record: Box::new(zone_change_record), + record: Box::new(record), }); } events.push(GameEvent::TokenCreated { @@ -3724,6 +3897,321 @@ mod tests { (state, events) } + // ── CR 403.3: the entry RECORD is not gated on event emission ──────── + + /// CR 400.7 + CR 403.3 rows for `object_id`, as `(battlefield_entry_rows, zone_change_rows)`. + fn ledger_rows(state: &GameState, object_id: ObjectId) -> (usize, usize) { + ( + state + .battlefield_entries_this_turn + .iter() + .filter(|record| record.object_id == object_id) + .count(), + state + .zone_changes_this_turn + .iter() + .filter(|record| { + record.object_id == object_id && record.to_zone == Zone::Battlefield + }) + .count(), + ) + } + + /// Build a battlefield token and run the liminal finalize tail over it under `emission`, + /// returning the resulting `(state, token_id, emitted_events)` so callers can inspect the + /// ledgers, the parked entry, and any later flush. + fn finalize_liminal_entry_under( + emission: TokenEntryEventEmission, + ) -> (GameState, ObjectId, Vec) { + let mut state = GameState::new_two_player(42); + let controller = PlayerId(0); + let source_id = ObjectId(1); + let object_id = create_object( + &mut state, + CardId(0), + controller, + "Record Probe".to_string(), + Zone::Battlefield, + ); + let mut events = Vec::new(); + assert!(finalize_committed_liminal_token_entry_from_action( + &mut state, + PendingCounterPostAction::FinalizeCommittedLiminalTokenEntry { + object_id, + name: "Record Probe".to_string(), + source_id, + controller, + enters_attacking: false, + attach_to: None, + sacrifice_at: None, + created_ids: Vec::new(), + ability_injection: LiminalTokenAbilityInjection::ResolvedToken, + entry_events: emission, + }, + &mut events, + )); + (state, object_id, events) + } + + /// CR 400.7 + CR 614.12a: `Suppress` means the object is NOT yet the thing that entered — + /// `BecomeCopy` has not run and any mandatory as-enters choice is unanswered — so the record + /// and the events are parked TOGETHER and realized later, as one operation, from a snapshot + /// taken at flush. Recording here instead writes CR 400.7's "state at the moment of the move" + /// from a pre-copy 0/0 Shapeshifter, which is the defect this lifecycle replaces. + /// + /// REVERT-PROBE (discriminating, RUN): replace the `Suppress` park with + /// `record_committed_token_entry(state, object_id);` ⇒ the row counts here read `(1, 1)` and + /// the pending assertion fails, while `suppress_does_not_emit_the_entry_pair` below still + /// passes — isolating the flip to the record, not the events. + #[test] + fn suppressed_liminal_entry_parks_instead_of_recording() { + let (state, object_id, _events) = + finalize_liminal_entry_under(TokenEntryEventEmission::Suppress); + assert_eq!( + ledger_rows(&state, object_id), + (0, 0), + "CR 614.12a: a Suppress-route token writes NEITHER ledger until it is realized" + ); + assert_eq!( + state.pending_token_battlefield_entry, + Some(PendingTokenBattlefieldEntry { + object_id, + name: "Record Probe".to_string(), + source_id: ObjectId(1), + }), + "the whole entry is parked on GameState so it survives any number of round trips" + ); + } + + /// The other half of the pin: `Suppress` really does withhold the events, so the test above + /// is measuring a park with no emit rather than an emit that happened anyway. + #[test] + fn suppress_does_not_emit_the_entry_pair() { + let (_state, _object_id, events) = + finalize_liminal_entry_under(TokenEntryEventEmission::Suppress); + assert!( + !events.iter().any(|event| matches!( + event, + GameEvent::ZoneChanged { .. } | GameEvent::TokenCreated { .. } + )), + "Suppress withholds both entry events; got {events:?}" + ); + } + + /// CR 400.7 + CR 603.6a: the flush is the single realization authority — it records through + /// `record_zone_change` AND emits the pair, once. A second call is structurally a no-op + /// (`Option::take_if` consumed the parked value), which is what makes the duplicate-row class + /// unrepresentable rather than guarded. + /// + /// REVERT-PROBE (discriminating, RUN): swap `take_if` for a non-consuming + /// `as_ref().filter(..).cloned()` ⇒ the second flush returns `true`, appends a second row to + /// each ledger and a second event pair, failing the idempotence half while the first-flush + /// assertions stay green. + #[test] + fn flushing_a_parked_entry_records_and_emits_exactly_once() { + let (mut state, object_id, _events) = + finalize_liminal_entry_under(TokenEntryEventEmission::Suppress); + let mut events = Vec::new(); + assert!( + flush_pending_token_battlefield_entry(&mut state, object_id, &mut events), + "the parked entry is realized by its first flush" + ); + assert_eq!( + ledger_rows(&state, object_id), + (1, 1), + "realization writes exactly one row on each ledger" + ); + assert_eq!( + ( + events + .iter() + .filter(|event| matches!(event, GameEvent::ZoneChanged { .. })) + .count(), + events + .iter() + .filter(|event| matches!(event, GameEvent::TokenCreated { .. })) + .count(), + ), + (1, 1), + "realization emits the entry pair exactly once; got {events:?}" + ); + assert!(state.pending_token_battlefield_entry.is_none()); + + let mut second = Vec::new(); + assert!( + !flush_pending_token_battlefield_entry(&mut state, object_id, &mut second), + "a second flush finds nothing parked" + ); + assert_eq!( + ledger_rows(&state, object_id), + (1, 1), + "a second flush adds no row" + ); + assert!(second.is_empty(), "a second flush emits nothing"); + } + + /// The parked entry is bound to ONE object identity: a flush for a different object must not + /// consume it. Without this, an unrelated token's realization would emit this token's entry. + #[test] + fn flushing_a_foreign_object_id_is_a_no_op() { + let (mut state, object_id, _events) = + finalize_liminal_entry_under(TokenEntryEventEmission::Suppress); + let foreign = ObjectId(object_id.0 + 1_000); + let mut events = Vec::new(); + assert!(!flush_pending_token_battlefield_entry( + &mut state, + foreign, + &mut events + )); + assert_eq!(ledger_rows(&state, object_id), (0, 0)); + assert_eq!(ledger_rows(&state, foreign), (0, 0)); + assert!(events.is_empty()); + assert!( + state + .pending_token_battlefield_entry + .as_ref() + .is_some_and(|pending| pending.object_id == object_id), + "the binding survives a foreign flush untouched" + ); + } + + /// CR 704.5f fail-safe: if the object is gone when the flush runs, `record_committed_token_entry` + /// has nothing to snapshot, so no CR 400.7 row is written and no `ZoneChanged` is emitted. + /// (`TokenCreated` still reports the creation that did happen.) + #[test] + fn flushing_after_the_object_left_the_battlefield_records_nothing() { + let (mut state, object_id, _events) = + finalize_liminal_entry_under(TokenEntryEventEmission::Suppress); + state.objects.remove(&object_id); + state.battlefield.retain(|id| *id != object_id); + let mut events = Vec::new(); + assert!(flush_pending_token_battlefield_entry( + &mut state, + object_id, + &mut events + )); + assert_eq!( + ledger_rows(&state, object_id), + (0, 0), + "a vanished object gets no CR 400.7 row" + ); + assert!( + !events + .iter() + .any(|event| matches!(event, GameEvent::ZoneChanged { .. })), + "no phantom entry event is emitted; got {events:?}" + ); + } + + /// The settled-action GATE that both `engine.rs` convergence points share + /// ([`realize_settled_token_battlefield_entry`]), exercised over its three arms — including the + /// CR 704.5f drop branch, which no production drive reaches (see that function's doc comment). + /// Helper-level by construction: the two production entry points are covered by the Painter / + /// Fanatic / Watchdog integration drives, which measure WHERE it is called from. + #[test] + fn the_settled_gate_realizes_only_a_settled_action_and_drops_a_departed_token() { + // (i) Mid-prompt: the action has not settled, so nothing is realized. + let (mut state, object_id, _events) = + finalize_liminal_entry_under(TokenEntryEventEmission::Suppress); + state.waiting_for = WaitingFor::MeldPairChoice { + player: PlayerId(0), + choices: Vec::new(), + }; + let mut events = Vec::new(); + realize_settled_token_battlefield_entry(&mut state, &mut events); + assert_eq!(ledger_rows(&state, object_id), (0, 0)); + assert!(events.is_empty()); + assert!( + state.pending_token_battlefield_entry.is_some(), + "an unsettled action leaves the entry parked for a later round trip" + ); + + // (ii) Settled with the token still on the battlefield: realized, once. + state.waiting_for = WaitingFor::Priority { + player: PlayerId(0), + }; + realize_settled_token_battlefield_entry(&mut state, &mut events); + assert_eq!(ledger_rows(&state, object_id), (1, 1)); + assert!(state.pending_token_battlefield_entry.is_none()); + assert_eq!( + events + .iter() + .filter(|event| matches!(event, GameEvent::ZoneChanged { .. })) + .count(), + 1, + "the settled action carries the entry pair; got {events:?}" + ); + + // (iii) CR 704.5f: settled, but the token has left the battlefield ⇒ the parked entry is + // DROPPED — no row and, unlike a direct flush, no `TokenCreated` for an object that + // is not there. + let (mut departed, departed_id, _events) = + finalize_liminal_entry_under(TokenEntryEventEmission::Suppress); + departed.battlefield.retain(|id| *id != departed_id); + departed.waiting_for = WaitingFor::Priority { + player: PlayerId(0), + }; + let mut departed_events = Vec::new(); + realize_settled_token_battlefield_entry(&mut departed, &mut departed_events); + assert_eq!(ledger_rows(&departed, departed_id), (0, 0)); + assert!(departed_events.is_empty()); + assert!(departed.pending_token_battlefield_entry.is_none()); + } + + /// Serde: the parked entry round-trips, and a `GameState` JSON written before this field + /// existed still loads (the `#[serde(default)]` save-compat claim). + #[test] + fn pending_token_battlefield_entry_round_trips() { + let mut state = GameState::new_two_player(42); + state.pending_token_battlefield_entry = Some(PendingTokenBattlefieldEntry { + object_id: ObjectId(7), + name: "Record Probe".to_string(), + source_id: ObjectId(1), + }); + let encoded = serde_json::to_string(&state).expect("GameState serializes"); + let decoded: GameState = serde_json::from_str(&encoded).expect("GameState deserializes"); + assert_eq!( + decoded.pending_token_battlefield_entry, + state.pending_token_battlefield_entry + ); + + let mut without: serde_json::Value = + serde_json::from_str(&encoded).expect("the encoded state is JSON"); + assert!( + without + .as_object_mut() + .expect("GameState encodes as a JSON object") + .remove("pending_token_battlefield_entry") + .is_some(), + "the key must be present to begin with, or the removal below proves nothing" + ); + let legacy: GameState = + serde_json::from_value(without).expect("a save without the key still loads"); + assert!(legacy.pending_token_battlefield_entry.is_none()); + } + + /// The double-count guard for the `Emit` arm: recording in the finalize tail AND inside + /// `push_committed_token_entry_events` would put two rows on the ledger. Exactly one — and + /// nothing is parked, because that route's object is already fully realized. + #[test] + fn emitted_liminal_entry_records_exactly_one_row() { + let (state, object_id, events) = + finalize_liminal_entry_under(TokenEntryEventEmission::Emit); + let (entries, zone_rows) = ledger_rows(&state, object_id); + assert_eq!(entries, 1, "Emit records battlefield entry exactly once"); + assert_eq!(zone_rows, 1, "Emit records the zone change exactly once"); + assert!( + state.pending_token_battlefield_entry.is_none(), + "the Emit route parks nothing" + ); + assert!( + events + .iter() + .any(|event| matches!(event, GameEvent::TokenCreated { .. })), + "Emit still emits the entry pair; got {events:?}" + ); + } + #[test] fn controller_owned_token_ignores_scoped_player() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index 55c51e786a..3ebe80b672 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -1070,6 +1070,13 @@ fn abandon_source_bound_resolution_prompt(state: &mut GameState, player: PlayerI crate::game::stack::clear_resolving_stack_entry(state); state.resolution_source_relatch = None; state.deferred_entry_events.clear(); + // The prompt and its ability continuation are abandoned, so no realization point will ever be + // reached for a token battlefield entry parked by this resolution. Leaving the `Option` live + // would let a later token's park trip the fail-loud overwrite assert, and would let the + // action-boundary backstop write a CR 400.7 row for a resolution that no longer exists. If the + // token itself survives the abandonment its entry row is lost — the same loss the + // `deferred_entry_events.clear()` above already accepts for that entry's trigger replay. + state.pending_token_battlefield_entry = None; state.waiting_for = WaitingFor::Priority { player: players::next_player(state, player), }; @@ -1100,6 +1107,9 @@ fn abandon_change_zone_family_for_controller(state: &mut GameState, player: Play crate::game::stack::clear_resolving_stack_entry(state); state.resolution_source_relatch = None; state.deferred_entry_events.clear(); + // Same reasoning as `abandon_source_bound_resolution_prompt`: the owning resolution is gone, + // so a parked token battlefield entry has no realization point left. + state.pending_token_battlefield_entry = None; state.waiting_for = WaitingFor::Priority { player: players::next_player(state, player), }; diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index e9edd68565..a149219500 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -366,13 +366,23 @@ fn apply_action_boundary_core( *state = boundary_snapshot; return Err(err); } - let result = match apply_action(state, semantic_owner, action, stack_resolution_limit) { + let mut result = match apply_action(state, semantic_owner, action, stack_resolution_limit) { Ok(result) => result, Err(err) => { *state = boundary_snapshot; return Err(err); } }; + // CR 400.7 + CR 403.3 + CR 614.12a: an as-enters choice (and any continuation it raises) can + // span an arbitrary number of client round-trips of ANY `WaitingFor` shape, so realization of a + // parked token battlefield entry is keyed on the action having SETTLED, not on prompt shape. + // `apply_action` realizes it itself on every route that reaches `run_post_action_pipeline` (see + // `realize_settled_token_battlefield_entry`, which is where the ETB-observer-correct placement + // lives). This call is the BACKSTOP for the handlers that return an `ActionResult` straight out + // of the reducer match and never reach that pipeline: they still realize the entry here, one + // trigger scan too late, rather than stranding it. `Option::take_if` makes it a no-op after any + // earlier convergence point. + effects::token::realize_settled_token_battlefield_entry(state, &mut result.events); Ok(RawActionApplication { result, journal_start, @@ -8938,6 +8948,13 @@ fn apply_action( // the action's result, not the pre-action state (fixes stale TargetSelection // after CancelCast). state.waiting_for = waiting_for.clone(); + // CR 603.2 + CR 603.6a: a token battlefield entry postponed by an as-enters choice is + // realized HERE, before the trigger scan, so this action's `events` carry the entry pair + // the scan reads — otherwise the copy token enters with no observer ("whenever another + // creature enters") ever seeing it. Also ahead of the pipeline's CR 704.3 SBA pass, so the + // CR 400.7 row survives a copy that enters with 0 toughness. Same gate as the + // action-boundary backstop, one authority. + effects::token::realize_settled_token_battlefield_entry(state, &mut events); let wf = engine_priority::run_post_action_pipeline( state, &mut events, diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index 0d1166724a..f5aa986170 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -1719,10 +1719,11 @@ pub(super) fn handle_copy_target_choice( else { unreachable!("meld resume returned above") }; - let entry_events = state - .liminal_entries - .get(&source_id) - .map(|entry| (entry.name.clone(), entry.source_id)); + // The entry's `name` / `source_id` now ride the parked + // `GameState::pending_token_battlefield_entry` that the `Suppress` commit installs, so + // only the liminal entry's PRESENCE matters here: it is what says this commit will park + // an entry that later needs realizing. + let has_liminal_entry = state.liminal_entries.contains_key(&source_id); let copy_continuation = state.liminal_entries.get(&source_id).and_then(|entry| { entry.copy_resume.as_ref().and_then(|copy| { (entry.remaining_count > 0).then(|| { @@ -1777,24 +1778,23 @@ pub(super) fn handle_copy_target_choice( // CR 403.3 + CR 603.6a: the commit applies the token's enter-with-counters, which can // PAUSE on a CR 616.1 ordering choice between two AddCounter replacements. On that pause // the only stashed post-action is the entry finalization, and its `Suppress` emission mode - // means the finalize tail neither emits the entry events nor (since the entry record is now - // written by `record_zone_change` inside `push_committed_token_entry_events`) records the - // entry at all — the token would enter invisibly. Hand the emit down as a post-finalize - // action so the paused path still performs the entry EMIT the unpaused one performs below. - // Only the emit: on a pause this function returns at the `commit_liminal_token_entry_*` - // call below, so the unpaused tail's CR 614.12a `BecomeCopy` chain, - // `finish_copy_target_choice_entry`, and the copy continuation do not run on that route. - // That abandonment is pre-existing and is not what this hand-down addresses. Dropped - // unused when the commit does not pause. - let paused_entry_emit: Vec = entry_events - .clone() - .map( - |(name, event_source_id)| PendingCounterPostAction::EmitCommittedCopyTokenEntry { - object_id: source_id, - name, - source_id: event_source_id, - }, - ) + // PARKS the whole entry (record + events) on `GameState` instead of realizing it. Hand the + // realization down as a post-finalize action so the paused path performs the same CR 400.7 + // record and CR 603.6a emit the unpaused one performs below. + // + // Realizing it INSIDE the counter drain (rather than leaving it to the action-boundary + // backstop) is what keeps this route's ETB observers firing: the post-action runs before + // that action's `run_post_action_pipeline` trigger scan, so the emitted pair is scanned. + // + // On a pause this function returns at the `commit_liminal_token_entry_*` call below, so + // the unpaused tail's CR 614.12a `BecomeCopy` chain, `finish_copy_target_choice_entry`, + // and the copy continuation do not run on that route. THAT abandonment — of the copy + // chain and continuation, not of the entry lifecycle — is pre-existing and is not what + // this hand-down addresses. Dropped unused when the commit does not pause. + let paused_entry_emit: Vec = has_liminal_entry + .then_some(PendingCounterPostAction::EmitCommittedCopyTokenEntry { + object_id: source_id, + }) .into_iter() .collect(); if !super::effects::token::commit_liminal_token_entry_with_post_actions( @@ -1812,12 +1812,10 @@ pub(super) fn handle_copy_target_choice( // exceptions (CR 707.9b). let _ = effects::resolve_ability_chain(state, &ability, events, 0); let mut counter_pause_post_actions = Vec::new(); - if let Some((name, event_source_id)) = entry_events.clone() { + if has_liminal_entry { counter_pause_post_actions.push( PendingCounterPostAction::EmitCommittedCopyTokenEntry { object_id: source_id, - name, - source_id: event_source_id, }, ); } @@ -1841,15 +1839,6 @@ pub(super) fn handle_copy_target_choice( )? { return Ok(waiting_for); } - if let Some((name, event_source_id)) = entry_events { - super::effects::token::push_committed_token_entry_events( - state, - source_id, - name, - event_source_id, - events, - ); - } if let Some((owner, copy, enter_tapped, enter_with_counters, remaining_count)) = copy_continuation { @@ -1966,6 +1955,12 @@ fn finish_copy_target_choice_entry( return Ok(Some(waiting_for)); } } + // CR 400.7 + CR 403.3 + CR 614.12a: the copy is realized and every mandatory as-enters + // choice is answered — the first instant the token IS the thing that entered. Placed before + // the replay/batch-drain/aura blocks so their pause returns cannot strand a parked entry. + // `false` here means an earlier convergence point already realized it (structurally + // idempotent, `Option::take_if`), which is not an error. + let _ = super::effects::token::flush_pending_token_battlefield_entry(state, source_id, events); crate::game::layers::mark_layers_full(state); // CR 614.12a + CR 707.9: The battlefield-entry `ZoneChanged` event was // captured into `state.deferred_entry_events` when `CopyTargetChoice` was diff --git a/crates/engine/src/game/scenario_db.rs b/crates/engine/src/game/scenario_db.rs index a81a9993d3..64b9d43092 100644 --- a/crates/engine/src/game/scenario_db.rs +++ b/crates/engine/src/game/scenario_db.rs @@ -38,6 +38,9 @@ fn abandon_as_enters_choice_for_scenario_setup( return false; } state.deferred_entry_events.clear(); + // The abandoned as-enters prompt owns any token battlefield entry parked by this setup, and + // nothing will reach a realization point for it once the prompt is dropped. + state.pending_token_battlefield_entry = None; state.waiting_for = WaitingFor::Priority { player: controller }; true } diff --git a/crates/engine/src/game/turns.rs b/crates/engine/src/game/turns.rs index 3bfa98b926..d266944f75 100644 --- a/crates/engine/src/game/turns.rs +++ b/crates/engine/src/game/turns.rs @@ -1158,6 +1158,12 @@ pub fn start_next_turn(state: &mut GameState, events: &mut Vec) { state.zone_changes_this_turn.clear(); state.batched_zone_change_trigger_fired.clear(); state.battlefield_entries_this_turn.clear(); + // CR 400.7: defence in depth for the two ledgers above. A parked token battlefield entry is + // realized within the action that settles, and every prompt-abandonment path clears it, so none + // should reach a turn boundary. One that did would write its row onto the NEXT turn's freshly + // cleared ledger — an "entered this turn" answer for an entry that happened last turn. Mirrors + // the `deferred_entry_events` clears in `elimination.rs` / `scenario_db.rs`. + state.pending_token_battlefield_entry = None; // CR 701.26 + CR 603.4: reset per-object tap counts so "first time it became // tapped this turn" intervening-ifs start fresh each turn. state.object_tap_count_this_turn.clear(); diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index c9b89bc4e0..4273bc2a39 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -5168,6 +5168,17 @@ pub enum TokenEntryEventEmission { Suppress, } +/// CR 400.7: the three values a postponed token battlefield entry needs at flush time. The +/// characteristics are NOT stored — they are re-snapshotted from the live object at flush, which +/// is the whole point of postponing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PendingTokenBattlefieldEntry { + pub object_id: ObjectId, + /// The `TokenCreated` display name (the token's OWN name, not the copied source's). + pub name: String, + pub source_id: ObjectId, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum PendingCounterPostAction { EmitEffectResolved { @@ -5251,10 +5262,12 @@ pub enum PendingCounterPostAction { enter_with_counters: Vec<(CounterType, u32)>, remaining_count: u32, }, + /// CR 400.7 + CR 616.1: realize the token battlefield entry parked in + /// `GameState::pending_token_battlefield_entry` once the ETB-counter ordering choice has + /// drained. It carries only the object identity — the entry's `name` / `source_id` live on the + /// parked record, and its characteristics are re-snapshotted from the live object at flush. EmitCommittedCopyTokenEntry { object_id: ObjectId, - name: String, - source_id: ObjectId, }, /// CR 701.42 + CR 707.9: finish a meld instruction after a copy-as-enters /// choice whose entry counters paused on their own replacement choice. @@ -12691,6 +12704,15 @@ pub struct GameState { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub deferred_entry_events: Vec, + /// CR 400.7 + CR 403.3 + CR 614.12a: a token that was committed to the battlefield with its + /// entry EVENTS suppressed and whose CR 400.7 record has NOT been written yet, because the + /// object is not yet the thing that entered — `BecomeCopy` has not run and/or a mandatory + /// as-enters choice (CR 614.12a) is unanswered. Parked here so it survives an arbitrary number + /// of client round-trips; realized by the single authority + /// `crate::game::effects::token::flush_pending_token_battlefield_entry`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pending_token_battlefield_entry: Option, + // Layer system // CONSERVATIVE: deserialized snapshots (e.g. the WASM-export repro) rebuild // fully on first flush. The previous `bool` field serialized as `true` @@ -17813,6 +17835,7 @@ impl GameState { post_replacement_token_choice_applied: None, post_replacement_token_substitution_count: None, deferred_entry_events: Vec::new(), + pending_token_battlefield_entry: None, layers_dirty: LayersDirty::full(), static_gate_truth: im::HashMap::new(), trigger_index: TriggerIndex::default(), @@ -19361,6 +19384,7 @@ fn _gamestate_partition_is_total(s: &GameState) { replacement_may_cost_paused: _, post_replacement_token_choice_applied: _, deferred_entry_events: _, + pending_token_battlefield_entry: _, layers_dirty: _, static_gate_truth: _, trigger_index: _, @@ -19673,6 +19697,7 @@ impl PartialEq for GameState { && self.priority_pass_count == other.priority_pass_count && self.pending_replacement == other.pending_replacement && self.deferred_entry_events == other.deferred_entry_events + && self.pending_token_battlefield_entry == other.pending_token_battlefield_entry && self.layers_dirty == other.layers_dirty // `static_gate_truth` is INTENTIONALLY excluded: unlike // `layers_dirty`/`public_state_dirty` (which encode pending work), diff --git a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz index 1579d2a48b26f5fcc3d32365e353e4b1309fcdd1..e55178196e6ca45040c160ffd0e43f2e01242337 100644 GIT binary patch delta 20960 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
  • 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, + /// How many `TokenCreated` events this action emitted for the token. + tokens_created: usize, + /// CR 400.7 rows for the token on `zone_changes_this_turn` after this action. + zone_rows: usize, + /// CR 403.3 rows for the token on `battlefield_entries_this_turn` after this action. + entry_rows: usize, + /// Whether an entry is still parked awaiting realization after this action. + parked: bool, +} + +#[derive(Debug)] +struct CopyEntryDrive { + prompts: Vec, + steps: Vec, + token: Option, +} + +impl CopyEntryDrive { + fn token(&self) -> ObjectId { + self.token.unwrap_or_else(|| { + panic!( + "the Embalm token must reach its copy-target prompt; prompts seen = {:?}", + self.prompts + ) + }) + } +} + +/// Put a graveyard Vizier of Many Faces with its synthesized Embalm ability in play, and stage the +/// {3}{U}{U} it costs into P0's pool. +fn stage_embalm_vizier(scenario: &mut GameScenario) -> ObjectId { + let vizier = scenario + .add_creature_to_graveyard(P0, "Vizier of Many Faces", 0, 0) + .with_mana_cost(ManaCost::Cost { + generic: 3, + shards: vec![ManaCostShard::Blue], + }) + .from_oracle_text_with_keywords(&["Embalm"], VIZIER_ORACLE) + .id(); + scenario.with_mana_pool( + P0, + [ + ManaType::Blue, + ManaType::Blue, + ManaType::Colorless, + ManaType::Colorless, + ManaType::Colorless, + ] + .into_iter() + .map(|m| ManaUnit::new(m, ObjectId(0), false, vec![])) + .collect(), + ); + vizier +} + +fn token_entry_step( + runner: &GameRunner, + token: Option, + answered: String, + events: &[GameEvent], +) -> CopyEntryStep { + let matches_token = |id: ObjectId| token == Some(id); + CopyEntryStep { + answered, + zone_changed_indices: events + .iter() + .filter_map(|event| match event { + GameEvent::ZoneChanged { record, to, .. } + if matches_token(record.object_id) && *to == Zone::Battlefield => + { + Some(record.turn_zone_change_index) + } + _ => None, + }) + .collect(), + tokens_created: events + .iter() + .filter(|event| { + matches!(event, GameEvent::TokenCreated { object_id, .. } if matches_token(*object_id)) + }) + .count(), + zone_rows: runner + .state() + .zone_changes_this_turn + .iter() + .filter(|record| matches_token(record.object_id) && record.to_zone == Zone::Battlefield) + .count(), + entry_rows: runner + .state() + .battlefield_entries_this_turn + .iter() + .filter(|record| matches_token(record.object_id)) + .count(), + parked: runner.state().pending_token_battlefield_entry.is_some(), + } +} + +/// Activate the graveyard Vizier's Embalm ability and answer every prompt the resulting token +/// entry raises, recording each answer's effect on the two CR 400.7 / CR 403.3 ledgers. +/// +/// `copy_target` names the battlefield creature the copy-target prompt must pick; `None` DECLINES +/// the "enter as a copy" replacement, which routes the entry through `TokenEntryEventEmission::Emit` +/// instead (the positive control). Later `ReplacementChoice` prompts are the CR 616.1 ETB-counter +/// ordering choice and always take the first ordering. +fn drive_embalm_copy( + runner: &mut GameRunner, + vizier: ObjectId, + copy_target: Option<&str>, +) -> CopyEntryDrive { + let embalm_index = runner.state().objects[&vizier] + .abilities + .iter() + .position(|ability| matches!(&*ability.effect, Effect::CopyTokenOf { .. })) + .expect("the synthesized Embalm ability is on the graveyard Vizier"); + runner + .act(GameAction::ActivateAbility { + source_id: vizier, + ability_index: embalm_index, + }) + .expect("activate Embalm"); + + let mut drive = CopyEntryDrive { + prompts: Vec::new(), + steps: Vec::new(), + token: None, + }; + let mut replacements_answered = 0_usize; + for _ in 0..64 { + let (label, action) = match runner.state().waiting_for.clone() { + WaitingFor::ManaPayment { .. } | WaitingFor::Priority { .. } => { + // Settled: the entry finished (the copy route knows its token id; the declined + // route never gets one) and nothing is left resolving. Anything further would be + // the turn advancing, which clears the per-turn ledgers under the assertions. + let entry_done = drive.token.is_some() || copy_target.is_none(); + if entry_done && runner.state().stack.is_empty() { + break; + } + runner.act(GameAction::PassPriority).expect("pass priority"); + continue; + } + WaitingFor::ReplacementChoice { candidates, .. } => { + // The FIRST replacement choice is Vizier's own optional "enter as a copy" + // (index 1 declines it); any later one is the CR 616.1 ordering between two + // ETB-counter replacements, where either ordering reaches this seam. + let index = usize::from(replacements_answered == 0 && copy_target.is_none()); + replacements_answered += 1; + ( + format!("ReplacementChoice({})", candidates.len()), + GameAction::ChooseReplacement { index }, + ) + } + WaitingFor::CopyTargetChoice { + source_id, + valid_targets, + .. + } => { + let wanted = copy_target.expect("declining must not raise a copy-target prompt"); + let target = *valid_targets + .iter() + .find(|id| { + runner + .state() + .objects + .get(id) + .is_some_and(|object| object.name == wanted) + }) + .unwrap_or_else(|| panic!("{wanted} must be a legal copy target")); + drive.token = Some(source_id); + ( + "CopyTargetChoice".to_string(), + GameAction::ChooseTarget { + target: Some(TargetRef::Object(target)), + }, + ) + } + WaitingFor::NamedChoice { options, .. } => ( + format!("NamedChoice({})", options.len()), + GameAction::ChooseOption { + choice: options + .first() + .expect("a mandatory named choice offers at least one option") + .clone(), + }, + ), + // CR 702.104a: decline the tribute so the companion "if tribute wasn't paid" trigger + // also runs — the longest continuation this class produces. + WaitingFor::TributeChoice { .. } => ( + "TributeChoice".to_string(), + GameAction::DecideOptionalEffect { accept: false }, + ), + other => { + drive.prompts.push(format!("{other:?}")); + break; + } + }; + let result = runner + .act(action) + .unwrap_or_else(|err| panic!("answering {label} failed: {err:?}")); + drive.prompts.push(label.clone()); + let step = token_entry_step(runner, drive.token, label, &result.events); + drive.steps.push(step); + } + runner.advance_until_stack_empty(); + drive +} + +/// Both per-turn ledgers' single row for `token`, panicking (with the drive's prompt trace) when +/// either is missing. +fn entry_rows( + runner: &GameRunner, + token: ObjectId, + drive: &CopyEntryDrive, +) -> (String, Option, String) { + let zone_row = runner + .state() + .zone_changes_this_turn + .iter() + .find(|record| record.object_id == token && record.to_zone == Zone::Battlefield) + .unwrap_or_else(|| { + panic!( + "the realized copy token must have a CR 400.7 zone-change row; prompts = {:?}", + drive.prompts + ) + }); + let battlefield_row = runner + .state() + .battlefield_entries_this_turn + .iter() + .find(|record| record.object_id == token) + .unwrap_or_else(|| { + panic!( + "the realized copy token must have a CR 403.3 battlefield-entry row; prompts = {:?}", + drive.prompts + ) + }); + ( + zone_row.name.clone(), + zone_row.power, + battlefield_row.name.clone(), + ) +} + +fn ledger_index(runner: &GameRunner, token: ObjectId) -> usize { + runner + .state() + .zone_changes_this_turn + .iter() + .position(|record| record.object_id == token && record.to_zone == Zone::Battlefield) + .expect("the entry is on the CR 400.7 ledger") +} + +/// CR 400.7 + CR 403.3 + CR 614.12a — the maintainer's named failure path. Embalm Vizier of Many +/// Faces copying Painter's Servant: the copy carries Painter's MANDATORY "as this creature enters, +/// choose a color" replacement, so the entry pauses on a `NamedChoice` that spans a client round +/// trip. Both ledgers must describe the REALIZED copy exactly once, and the entry pair must be +/// emitted exactly once, on the action that finally settles. +/// +/// REVERT-PROBE (discriminating, RUN): delete the +/// `token::realize_settled_token_battlefield_entry` call in `engine::apply_action_boundary_core` +/// AND the one in `engine::apply_action` ⇒ the `ChooseOption` step carries no entry events and both +/// ledgers stay at 0 rows, failing the four post-flush assertions, while +/// `suppressed_liminal_copy_token_entry_is_recorded_once` (convergence point (a)) and +/// `..._realizes_through_an_etb_counter_ordering_pause` (convergence point (b)) stay green. +/// +/// SECOND REVERT-PROBE, isolating WHERE the settled action realizes it (discriminating, RUN): +/// delete only the `apply_action` call, keeping the boundary backstop ⇒ every ledger and emit +/// assertion below stays green and ONLY the Soul Warden assertion flips 1 → 0, because the backstop +/// appends the entry pair after `run_post_action_pipeline` has already scanned this action's events +/// for triggers. +#[test] +fn suppressed_liminal_copy_token_entry_realizes_through_a_mandatory_as_enters_choice() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let vizier = stage_embalm_vizier(&mut scenario); + scenario.add_creature_from_oracle(P0, "Painter's Servant", 1, 3, PAINTERS_SERVANT_ORACLE); + scenario.add_creature_from_oracle(P0, "Soul Warden", 1, 1, SOUL_WARDEN_ORACLE); + let mut runner = scenario.build(); + let life_start = life_of_p0(runner.state()); + + let drive = drive_embalm_copy(&mut runner, vizier, Some("Painter's Servant")); + // POSITIVE reach-guard: the mandatory as-enters pause was actually reached. Without it every + // assertion below could be about a route that never postponed anything. + assert_eq!( + drive.prompts, + vec![ + "ReplacementChoice(2)".to_string(), + "CopyTargetChoice".to_string(), + "NamedChoice(5)".to_string(), + ], + "the copy's own CR 614.12a colour choice must pause the entry" + ); + let token = drive.token(); + + // (1) PRE-FLUSH NEGATIVE, paired with the reach-guard above: at the `NamedChoice` pause the + // entry is postponed — no row on either ledger, no event emitted, and the entry is parked. + let copy_step = &drive.steps[1]; + assert_eq!( + (copy_step.zone_rows, copy_step.entry_rows), + (0, 0), + "the entry is postponed until the copy IS the thing that entered (CR 614.12a)" + ); + assert_eq!( + ( + copy_step.zone_changed_indices.len(), + copy_step.tokens_created + ), + (0, 0), + "nothing is emitted for the token while its as-enters choice is unanswered" + ); + assert!( + copy_step.parked, + "the postponed entry is parked on GameState so it survives the round trip" + ); + + // (2) DISCRIMINATOR: the realizing action writes ONE row on EACH ledger, describing the + // copied creature — not the 0/0 pre-copy Shapeshifter the head recorded here. + let settled = &drive.steps[2]; + assert_eq!( + (settled.zone_rows, settled.entry_rows), + (1, 1), + "the realized entry lands on both CR 400.7 / CR 403.3 ledgers exactly once" + ); + assert!( + !settled.parked, + "the parked entry is consumed by its realization" + ); + let (zone_name, zone_power, battlefield_name) = entry_rows(&runner, token, &drive); + assert_eq!( + zone_name, "Painter's Servant", + "the recorded entry names the copied creature, not the pre-copy Shapeshifter" + ); + assert_eq!( + zone_power, + Some(1), + "the recorded entry carries the copied power, not the 0/0 the token had before BecomeCopy" + ); + assert_eq!( + battlefield_name, zone_name, + "both CR 403.3 ledgers are written by the one record_zone_change call, so they agree" + ); + + // (3) The emit rides the SAME action that realized the entry, exactly once, carrying the + // recorder-assigned CR 603.2c dedup key. + assert_eq!( + settled.tokens_created, 1, + "the entry pair is emitted exactly once, on the realizing action" + ); + assert_eq!( + settled.zone_changed_indices, + vec![ledger_index(&runner, token)], + "the emitted ZoneChanged carries the index the recorder assigned" + ); + + // (4) DISCRIMINATOR for WHERE the settled action realizes it (CR 603.2 + CR 603.6a): the pair + // is emitted from inside `apply_action`, ahead of `run_post_action_pipeline`, so this + // action's trigger scan sees the token enter and the board's ETB observers fire. Realizing + // at the action BOUNDARY instead (after the reducer returned) leaves this at 0 — that is + // the maintainer's own named path, and it is the assertion that pins it. + assert_eq!( + life_of_p0(runner.state()) - life_start, + 1, + "Soul Warden observes the realized copy token entering; prompts = {:?}", + drive.prompts + ); +} + +/// CR 400.7 + CR 614.12a + CR 702.104a — the SECOND-PAUSE class. Fanatic of Xenagos's as-enters +/// `Choose(Opponent)` continuation raises a `TributeChoice`, so the entry spans TWO client round +/// trips of two different prompt shapes. This is the shape a fix hung off any single prompt +/// variant's resume arm cannot see. +/// +/// REVERT-PROBE (discriminating, RUN): same as the Painter test — delete the +/// `token::realize_settled_token_battlefield_entry` calls in `engine.rs` ⇒ 0 rows on both ledgers +/// and no emit. +/// +/// KNOWN PARTIAL, pinned below rather than left unasserted: this class settles through +/// `handle_tribute_choice`, which builds its `ActionResult` directly in the reducer match and never +/// reaches `run_post_action_pipeline`, so the entry is realized by the action-BOUNDARY backstop — +/// after that action's trigger scan. Its ETB observers therefore do not fire (assertion (4) below +/// measures 0, where the Painter class measures 1). Not a regression: before this lifecycle the +/// class emitted nothing at all and recorded a pre-copy row. The fix is to give the direct-return +/// handlers the same pipeline the rest of the reducer uses; when that lands, assertion (4) flips to +/// 1 and this test must be updated — a FAILURE here is a fix, not a regression. +#[test] +fn suppressed_liminal_copy_token_entry_realizes_through_an_as_enters_choice_with_a_second_pause() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let vizier = stage_embalm_vizier(&mut scenario); + scenario + .add_creature(P0, "Fanatic of Xenagos", 3, 3) + .from_oracle_text_with_keywords(&["Trample", "Tribute"], FANATIC_OF_XENAGOS_ORACLE); + scenario.add_creature_from_oracle(P0, "Soul Warden", 1, 1, SOUL_WARDEN_ORACLE); + let mut runner = scenario.build(); + let life_start = life_of_p0(runner.state()); + + let drive = drive_embalm_copy(&mut runner, vizier, Some("Fanatic of Xenagos")); + // POSITIVE reach-guard: the SECOND pause was reached. A fixture that stopped at the + // `NamedChoice` would exercise the same route as the Painter test. + assert_eq!( + drive.prompts, + vec![ + "ReplacementChoice(2)".to_string(), + "CopyTargetChoice".to_string(), + "NamedChoice(1)".to_string(), + "TributeChoice".to_string(), + ], + "the tribute continuation must raise a SECOND pause after the as-enters choice" + ); + let token = drive.token(); + + // (1) PRE-FLUSH NEGATIVE at BOTH intermediate pauses, paired with the reach-guard above. + for step in &drive.steps[1..3] { + assert_eq!( + (step.zone_rows, step.entry_rows), + (0, 0), + "nothing is recorded at the {:?} pause", + step.answered + ); + assert_eq!( + (step.zone_changed_indices.len(), step.tokens_created), + (0, 0), + "nothing is emitted at the {:?} pause", + step.answered + ); + assert!( + step.parked, + "the entry stays parked across the {:?} pause", + step.answered + ); + } + + // (2) DISCRIMINATOR: post-copy identity survives TWO round trips, once per ledger. + let settled = &drive.steps[3]; + assert_eq!( + (settled.zone_rows, settled.entry_rows), + (1, 1), + "the realized entry lands on both ledgers exactly once after two pauses" + ); + assert!( + !settled.parked, + "the parked entry is consumed by its realization" + ); + let (zone_name, zone_power, battlefield_name) = entry_rows(&runner, token, &drive); + assert_eq!(zone_name, "Fanatic of Xenagos"); + assert_eq!(zone_power, Some(3)); + assert_eq!(battlefield_name, zone_name); + + // (3) The emit rides the action that finally settled. + assert_eq!( + settled.tokens_created, 1, + "the entry pair is emitted exactly once, on the action that settled" + ); + assert_eq!( + settled.zone_changed_indices, + vec![ledger_index(&runner, token)], + "the emitted ZoneChanged carries the index the recorder assigned" + ); + + // (4) The KNOWN PARTIAL, measured instead of left silent (see the doc comment). The tribute + // answer settles through `handle_tribute_choice`'s direct `ActionResult` return, so the + // entry is realized by the action-boundary backstop, AFTER this action's trigger scan — + // the same fixture on the Painter route (which does reach `run_post_action_pipeline`) + // measures 1, and `declined_copy_replacement_records_the_token_entry_without_parking_it` + // measures 1 on the `Emit` route, so a Soul Warden that simply never fires in this harness + // is ruled out and this 0 is the real gap, not a blind instrument. + assert_eq!( + life_of_p0(runner.state()) - life_start, + 0, + "documented gap: this class realizes at the action boundary, after the trigger scan, so \ + the token's ETB observers do not fire; prompts = {:?}", + drive.prompts + ); +} + +/// CR 400.7 + CR 616.1 — convergence point (b). Copying Faithful Watchdog ("enters with three +/// +1/+1 counters") while Hardened Scales and Branching Evolution both want to modify that counter +/// event forces the CR 616.1 ordering choice, which pauses the entry INSIDE the counter pipeline. +/// Realizing there (rather than at the action boundary) is what puts the entry pair into `events` +/// before this action's trigger scan, so the token's ETB observers still fire. +/// +/// REVERT-PROBE (discriminating, RUN): delete BOTH in-action realization points — the flush call in +/// `counters::apply_pending_counter_post_action`'s `EmitCommittedCopyTokenEntry` arm AND +/// `token::realize_settled_token_battlefield_entry` inside `engine::apply_action` ⇒ the Soul Warden +/// assertion flips 1 → 0 while every ledger and emit assertion above stays green (the action +/// -boundary backstop still writes the rows and emits in the same action, just after the trigger +/// scan) — which is exactly why the observer assertion is this test's discriminator. +/// +/// MEASURED, and NOT what the earlier revision of this comment claimed: deleting the `counters.rs` +/// call ALONE now flips nothing, because this fixture's counter-order answer settles to `Priority` +/// and `apply_action` realizes the entry before the trigger scan regardless. The two points are +/// redundant on this route; `counters.rs` still owns a drain that does not settle in its own +/// action. +#[test] +fn suppressed_liminal_copy_token_entry_realizes_through_an_etb_counter_ordering_pause() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let vizier = stage_embalm_vizier(&mut scenario); + scenario + .add_creature(P0, "Faithful Watchdog", 0, 0) + .with_plus_counters(3) + .from_oracle_text_with_keywords(&["Vigilance"], FAITHFUL_WATCHDOG_ORACLE); + scenario.add_enchantment_from_oracle(P0, "Hardened Scales", HARDENED_SCALES_ORACLE); + scenario.add_enchantment_from_oracle(P0, "Branching Evolution", BRANCHING_EVOLUTION_ORACLE); + scenario.add_creature_from_oracle(P0, "Soul Warden", 1, 1, SOUL_WARDEN_ORACLE); + let mut runner = scenario.build(); + let life_start = life_of_p0(runner.state()); + + let drive = drive_embalm_copy(&mut runner, vizier, Some("Faithful Watchdog")); + // POSITIVE reach-guard: the SECOND `ReplacementChoice` is the CR 616.1 ordering pause. Without + // it this fixture would be the unpaused route the (a) test already covers. + assert_eq!( + drive.prompts, + vec![ + "ReplacementChoice(2)".to_string(), + "CopyTargetChoice".to_string(), + "ReplacementChoice(2)".to_string(), + ], + "two competing +1/+1 counter replacements must raise the CR 616.1 ordering choice" + ); + let token = drive.token(); + + // (1) The entry is postponed across the counter pause, exactly as across a named choice. + let copy_step = &drive.steps[1]; + assert_eq!( + (copy_step.zone_rows, copy_step.entry_rows), + (0, 0), + "nothing is recorded while the CR 616.1 ordering choice is open" + ); + assert!( + copy_step.parked, + "the entry is parked across the counter pause" + ); + + // (2) The counter-order answer realizes it, once per ledger, post-copy. + let settled = &drive.steps[2]; + assert_eq!( + (settled.zone_rows, settled.entry_rows), + (1, 1), + "the realized entry lands on both ledgers exactly once" + ); + assert_eq!( + settled.tokens_created, 1, + "the entry pair rides the counter-order answer" + ); + let (zone_name, _zone_power, battlefield_name) = entry_rows(&runner, token, &drive); + assert_eq!(zone_name, "Faithful Watchdog"); + assert_eq!(battlefield_name, zone_name); + + // (3) DISCRIMINATOR for convergence point (b): the pair is emitted BEFORE this action's + // trigger scan, so a board ETB observer sees the token enter (CR 603.2). + assert_eq!( + life_of_p0(runner.state()) - life_start, + 1, + "Soul Warden observes the copy token entering (flushing at the action boundary instead \ + appends the pair after the trigger scan ⇒ 0)" + ); +} + +/// POSITIVE CONTROL (CR 603.6a): declining the "enter as a copy" replacement routes the same +/// fixture through `TokenEntryEventEmission::Emit`, which records and emits inline at the finalize +/// tail and never parks anything. Proves the instrument the tests above use is not blind — the +/// same drive, the same assertions, a different lifecycle half. +#[test] +fn declined_copy_replacement_records_the_token_entry_without_parking_it() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let vizier = stage_embalm_vizier(&mut scenario); + scenario.add_creature_from_oracle(P0, "Painter's Servant", 1, 3, PAINTERS_SERVANT_ORACLE); + scenario.add_creature_from_oracle(P0, "Soul Warden", 1, 1, SOUL_WARDEN_ORACLE); + let mut runner = scenario.build(); + let life_start = life_of_p0(runner.state()); + + let drive = drive_embalm_copy(&mut runner, vizier, None); + // POSITIVE reach-guard: the enter-as-a-copy replacement really was offered and declined. + assert_eq!( + drive.prompts, + vec!["ReplacementChoice(2)".to_string()], + "declining the copy replacement raises no copy-target prompt" + ); + assert!( + drive.steps.iter().all(|step| !step.parked), + "the Emit route never parks an entry" + ); + assert!( + runner.state().pending_token_battlefield_entry.is_none(), + "no entry is left parked once the drive settles" + ); + + // The Embalm token entered under its OWN identity, once per ledger. It is a 0/0 Shapeshifter + // copy of Vizier with no copy target chosen, so CR 704.5f puts it into the graveyard right + // after — the ENTRY still happened and is still recorded, which is the point. + let entry = runner.state().battlefield_entries_this_turn.to_vec(); + assert_eq!( + entry.len(), + 1, + "the declined route records exactly one battlefield entry (the Embalm token's)" + ); + let token = entry[0].object_id; + assert_eq!( + entry[0].name, "Vizier of Many Faces", + "the Emit route records the token's OWN identity" + ); + assert_eq!( + runner + .state() + .zone_changes_this_turn + .iter() + .filter(|record| record.object_id == token && record.to_zone == Zone::Battlefield) + .count(), + 1, + "the Emit-route token is recorded on the CR 400.7 ledger exactly once" + ); + assert_eq!( + runner + .state() + .battlefield_entries_this_turn + .iter() + .filter(|record| record.object_id == token) + .count(), + 1, + "the Emit-route token is recorded on the CR 403.3 ledger exactly once" + ); + assert_eq!( + life_of_p0(runner.state()) - life_start, + 1, + "Soul Warden observes the plain Embalm token entering — the instrument is not blind" + ); +} + +/// CR 603.2c — a postponed entry must not collide with a normally-recorded one. The realized copy +/// token and a plain `Effect::Token` batch minted in the SAME turn (the `Emit` path, through +/// `push_committed_token_entry_events` → `record_committed_token_entry` → `record_zone_change`) +/// must occupy DISTINCT `turn_zone_change_index` values, because the batched zone-change replay +/// guard dedups on that index. +/// +/// The second producer is deliberately NOT `token_copy.rs`'s `record_battlefield_entry` sites: +/// those never reach `record_zone_change`, so they have no `zone_changes_this_turn` row to compare +/// against and the assertion would be vacuous. +/// +/// REVERT-PROBE (discriminating, RUN): delete the `record_zone_change` call inside +/// `token::record_committed_token_entry` (push onto `zone_changes_this_turn` directly, leaving the +/// snapshot's `0` placeholder) ⇒ the copy token and the minted tokens all report index `0` and the +/// distinctness assertion fails. +#[test] +fn a_realized_copy_token_entry_and_a_same_turn_token_batch_take_distinct_indices() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let vizier = stage_embalm_vizier(&mut scenario); + let painter = scenario + .add_creature_from_oracle(P0, "Painter's Servant", 1, 3, PAINTERS_SERVANT_ORACLE) + .id(); + let mut runner = scenario.build(); + + let drive = drive_embalm_copy(&mut runner, vizier, Some("Painter's Servant")); + // POSITIVE reach-guard: the postponed route ran, so the index below is a REALIZED entry's. + assert_eq!( + drive.prompts, + vec![ + "ReplacementChoice(2)".to_string(), + "CopyTargetChoice".to_string(), + "NamedChoice(5)".to_string(), + ], + ); + let token = drive.token(); + let copy_index = ledger_index(&runner, token); + let turn_start = runner.state().turn_number; + + let minted = mint_token_batch(runner.state_mut(), painter, 2); + assert_eq!( + runner.state().turn_number, + turn_start, + "both producers are in the SAME turn (the dedup ledger is per-turn)" + ); + let minted_indices = zone_change_indices(&minted); + assert_eq!( + minted_indices.len(), + 2, + "the Emit-path batch emits one ZoneChanged per token" + ); + assert!( + minted_indices.iter().all(|index| *index != copy_index), + "the realized copy entry ({copy_index}) must not share an index with the same-turn \ + token batch ({minted_indices:?})" + ); +} + +/// CR 400.7 + CR 603.6a — convergence point (a). On the UNPAUSED copy route the entry is realized +/// inside `finish_copy_target_choice_entry`, i.e. during the action that answers the copy-target +/// prompt. The settled-`Priority` backstop cannot substitute for it: this action does not settle +/// (a stale second `CopyTargetChoice` is a known pre-existing defect on this route), so the +/// backstop would slip the row and the emit into a LATER action — one client round trip late, with +/// an empty CR 400.7 look-back in between. +/// +/// REVERT-PROBE (discriminating, RUN): delete the flush call in +/// `engine_replacement::finish_copy_target_choice_entry` ⇒ the FIRST copy-target answer emits +/// nothing and both ledgers are still empty after it, failing here, while +/// `..._through_a_mandatory_as_enters_choice`, `..._with_a_second_pause` and +/// `..._an_etb_counter_ordering_pause` stay green (they realize at (c) / (b)). +#[test] +fn unpaused_copy_token_entry_is_realized_by_the_copy_target_action_itself() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let vizier = stage_embalm_vizier(&mut scenario); + scenario.add_creature(P0, "Grizzly Bears", 2, 2); + let mut runner = scenario.build(); + + let drive = drive_embalm_copy(&mut runner, vizier, Some("Grizzly Bears")); + // POSITIVE reach-guard: the copy-target prompt is the only production entrance to the + // postponed (`Suppress`) route, and this route raises no as-enters pause after it. + assert_eq!( + drive.prompts[..2], + [ + "ReplacementChoice(2)".to_string(), + "CopyTargetChoice".to_string() + ], + "the unpaused route reaches the copy-target prompt with no intervening pause" + ); + let token = drive.token(); + + let copy_step = &drive.steps[1]; + assert_eq!( + (copy_step.zone_rows, copy_step.entry_rows), + (1, 1), + "the FIRST copy-target answer realizes the entry on both ledgers, in its own action" + ); + assert_eq!( + copy_step.tokens_created, 1, + "the entry pair rides that same action's ActionResult, not a later one" + ); + assert_eq!( + copy_step.zone_changed_indices, + vec![ledger_index(&runner, token)], + "the emitted ZoneChanged carries the index the recorder assigned" + ); + assert!( + !copy_step.parked, + "nothing is left parked once the copy completes with no as-enters pause" + ); + // Post-copy identity, exactly once — the same pins the other three routes carry. + let (zone_name, zone_power, battlefield_name) = entry_rows(&runner, token, &drive); + assert_eq!(zone_name, "Grizzly Bears"); + assert_eq!(zone_power, Some(2)); + assert_eq!(battlefield_name, zone_name); +} From 365d13d2f080e44cfbf8ed2b13eae80471272aab Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Sun, 2 Aug 2026 01:39:05 -0500 Subject: [PATCH 2/6] docs(engine): cite CR 514.2 for the token-entry turn-boundary reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit on #6851. The per-turn clear of `pending_token_battlefield_entry` cited only CR 400.7, which names the ledgers it defends but is not the authority for the reset itself. CR 514.2 is: the cleanup step is where all "until end of turn" and "this turn" effects end. Cite both. Also tightens the one place that read as though CR 603.2c names `turn_zone_change_index`. The index is the engine's own key; the batched zone-change replay guard dedups on it to hold CR 603.2c's once-per-occurrence bound. The citation stays — it is the rule the guard implements, and it matches this file's existing module header — but the sentence now separates rule from mechanism. Comment-only; no behaviour change. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/turns.rs | 12 +++++++----- .../tests/integration/token_zone_change_index.rs | 4 +++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/game/turns.rs b/crates/engine/src/game/turns.rs index d266944f75..8956d835cd 100644 --- a/crates/engine/src/game/turns.rs +++ b/crates/engine/src/game/turns.rs @@ -1158,11 +1158,13 @@ pub fn start_next_turn(state: &mut GameState, events: &mut Vec) { state.zone_changes_this_turn.clear(); state.batched_zone_change_trigger_fired.clear(); state.battlefield_entries_this_turn.clear(); - // CR 400.7: defence in depth for the two ledgers above. A parked token battlefield entry is - // realized within the action that settles, and every prompt-abandonment path clears it, so none - // should reach a turn boundary. One that did would write its row onto the NEXT turn's freshly - // cleared ledger — an "entered this turn" answer for an entry that happened last turn. Mirrors - // the `deferred_entry_events` clears in `elimination.rs` / `scenario_db.rs`. + // CR 514.2 + CR 400.7: the cleanup step is where "this turn" state ends, which is the authority + // for this reset; CR 400.7 names the two ledgers above that it defends. Defence in depth only — + // a parked token battlefield entry is realized within the action that settles, and every + // prompt-abandonment path clears it, so none should reach a turn boundary. One that did would + // write its row onto the NEXT turn's freshly cleared ledger — an "entered this turn" answer for + // an entry that happened last turn. Mirrors the `deferred_entry_events` clears in + // `elimination.rs` / `scenario_db.rs`. state.pending_token_battlefield_entry = None; // CR 701.26 + CR 603.4: reset per-object tap counts so "first time it became // tapped this turn" intervening-ifs start fresh each turn. diff --git a/crates/engine/tests/integration/token_zone_change_index.rs b/crates/engine/tests/integration/token_zone_change_index.rs index 6035ce123c..08b6f51134 100644 --- a/crates/engine/tests/integration/token_zone_change_index.rs +++ b/crates/engine/tests/integration/token_zone_change_index.rs @@ -1092,7 +1092,9 @@ fn suppressed_liminal_copy_token_entry_realizes_through_a_mandatory_as_enters_ch ); // (3) The emit rides the SAME action that realized the entry, exactly once, carrying the - // recorder-assigned CR 603.2c dedup key. + // recorder-assigned `turn_zone_change_index`. That index is the engine's own key — the CR + // does not name it — and the batched zone-change replay guard dedups on it to hold the + // CR 603.2c once-per-occurrence bound (same framing as this file's module header). assert_eq!( settled.tokens_created, 1, "the entry pair is emitted exactly once, on the realizing action" From 4869aaca34259122b417e5b670f509986fff2c07 Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Sun, 2 Aug 2026 04:58:14 -0500 Subject: [PATCH 3/6] fix(engine): converge direct-return actions through the trigger pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reducer's direct-return arms build their `ActionResult` inside the match and never reach `apply_action`'s tail pipeline, so a CR 403.3 token battlefield entry realized on one of those routes entered with no ETB observer ever seeing it — CR 603.6a requires the check to run whenever an event puts a permanent onto the battlefield. `realize_settled_token_battlefield_entry` now reports whether it realized, and `apply_action_boundary_core` runs the same `run_post_action_pipeline_from` the tail path delegates to, over the slice the realization appended. The convergence is arm-agnostic: the match has 16 non-error direct returns and this is inert on the 15 that never park. Measured on the reviewer's named path (Embalm Vizier of Many Faces -> copy Painter's Servant -> mandatory colour NamedChoice -> Tribute, Soul Warden on the battlefield): Soul Warden's life delta goes 0 -> 1. Reverting the block reproduces the 0, failing at the reach-guard first; with the guard relaxed the life assertion alone flips. The regression pins "OrderTriggers(2)" — Tribute is declined, so Fanatic's CR 702.104b intervening-if is true and its ETB fires alongside Soul Warden's, both P0's, so CR 603.3b requires an ordering prompt. Both `waiting_for` writes are required: `finish_action_boundary` -> `sync_waiting_for` copies the result into the state, so a state-only write is undone, and the life-safety preview never calls `finish_action_boundary`, so a result-only write is insufficient. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/effects/counters.rs | 15 +- crates/engine/src/game/effects/token.rs | 45 ++++-- crates/engine/src/game/elimination.rs | 7 +- crates/engine/src/game/engine.rs | 60 ++++++-- crates/engine/src/game/engine_replacement.rs | 6 +- .../integration/token_zone_change_index.rs | 132 ++++++++++-------- 6 files changed, 171 insertions(+), 94 deletions(-) diff --git a/crates/engine/src/game/effects/counters.rs b/crates/engine/src/game/effects/counters.rs index 2aaf1c44c8..070262ea33 100644 --- a/crates/engine/src/game/effects/counters.rs +++ b/crates/engine/src/game/effects/counters.rs @@ -722,12 +722,15 @@ fn apply_pending_counter_post_action( // MEASURED redundancy, stated rather than implied: when the drain's action DOES settle // to `Priority` (the Faithful Watchdog fixture in // `tests/integration/token_zone_change_index.rs`, and every route the current card pool - // reaches), `token::realize_settled_token_battlefield_entry` inside `apply_action` - // would realize it anyway, still ahead of the CR 603.2 trigger scan — deleting this - // call alone flips no test. It is kept for a drain that does NOT settle in its own - // action, where this is the only in-action realization point. `false` means an earlier - // convergence point already realized it (structurally idempotent, `Option::take_if`), - // which is not an error. + // reaches), `token::realize_settled_token_battlefield_entry` realizes it anyway — from + // inside `apply_action` ahead of that action's CR 603.2 scan, and, for handlers that + // never reach that pipeline, from `apply_action_boundary_core`, which now runs + // `run_post_action_pipeline_from` over the slice it appended. Deleting this call AND the + // in-`apply_action` one flips no test. It is kept for a drain that does NOT settle in + // its own action, where this is the only in-action realization point, and because the + // in-`apply_action` call orders the CR 400.7 row ahead of that action's CR 704.3 SBA + // pass (CR 704.5f). `false` means an earlier convergence point already realized it + // (structurally idempotent, `Option::take_if`), which is not an error. let _ = super::token::flush_pending_token_battlefield_entry(state, object_id, events); if !state.last_created_token_ids.contains(&object_id) { state.last_created_token_ids.push(object_id); diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index 96cc4d1441..c9b1fd45a1 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -1901,11 +1901,14 @@ pub(crate) fn flush_pending_token_battlefield_entry( /// what makes the copy token's ETB observers ("whenever another creature enters") fire, and it /// also puts the CR 400.7 row on the ledger before that pipeline's SBA pass (CR 704.3) can bury a /// 0-toughness copy under CR 704.5f. -/// * in `apply_action_boundary_core`, after `apply_action` returned — the backstop for handlers -/// that build an `ActionResult` straight out of the reducer match and never reach that pipeline -/// (`handle_tribute_choice` is the reachable one). The entry is still realized on both ledgers -/// and still emitted, but AFTER the trigger scan, so that class's ETB observers do not fire. That -/// partial is tracked separately; it is strictly better than dropping the entry entirely. +/// * in `apply_action_boundary_core`, after `apply_action` returned — for the handlers that build +/// an `ActionResult` straight out of the reducer match and never reach that pipeline +/// (`handle_tribute_choice` is the reachable one). That call site converges them onto +/// `engine_priority::run_post_action_pipeline_from` over exactly the slice this realization +/// appended, so the CR 603.6a check runs for them too and their ETB observers fire. For the +/// REALIZED ENTRY the only remaining difference from the in-`apply_action` call is ordering +/// against that action's CR 704.3 SBA pass, which is why both call sites are kept; the handler's +/// OWN earlier events stay outside that scan window by design (`scan_from`). /// /// Order between the two is irrelevant: the flush's `Option::take_if` makes the second call — and /// any call after the two in-resolution convergence points in `engine_replacement.rs` / @@ -1917,24 +1920,30 @@ pub(crate) fn flush_pending_token_battlefield_entry( /// left. The cost is a lost CR 400.7 row for an entry that did happen. After the in-`apply_action` /// call above, the only way to reach this branch is a settling action that never runs the pipeline /// AND removes the token within itself; no production route is known to do both. +/// +/// Returns whether an entry pair was actually appended to `events` — `false` for an unsettled +/// action, for nothing parked, for an entry an earlier convergence point already consumed, and +/// for the CR 704.5f drop branch (which does consume the park but emits nothing). The boundary +/// call site gates its CR 603.6a trigger pass on exactly that. pub(crate) fn realize_settled_token_battlefield_entry( state: &mut GameState, events: &mut Vec, -) { +) -> bool { if !matches!(state.waiting_for, WaitingFor::Priority { .. }) { - return; + return false; } let Some(pending_id) = state .pending_token_battlefield_entry .as_ref() .map(|pending| pending.object_id) else { - return; + return false; }; if state.battlefield.contains(&pending_id) { - flush_pending_token_battlefield_entry(state, pending_id, events); + flush_pending_token_battlefield_entry(state, pending_id, events) } else { state.pending_token_battlefield_entry = None; + false } } @@ -4118,7 +4127,11 @@ mod tests { choices: Vec::new(), }; let mut events = Vec::new(); - realize_settled_token_battlefield_entry(&mut state, &mut events); + assert!( + !realize_settled_token_battlefield_entry(&mut state, &mut events), + "an unsettled action realizes nothing, so the boundary convergence must not run a \ + trigger pass" + ); assert_eq!(ledger_rows(&state, object_id), (0, 0)); assert!(events.is_empty()); assert!( @@ -4130,7 +4143,11 @@ mod tests { state.waiting_for = WaitingFor::Priority { player: PlayerId(0), }; - realize_settled_token_battlefield_entry(&mut state, &mut events); + assert!( + realize_settled_token_battlefield_entry(&mut state, &mut events), + "a settled action with the token still on the battlefield realizes the pair, which is \ + what gates the CR 603.6a pass at the action boundary" + ); assert_eq!(ledger_rows(&state, object_id), (1, 1)); assert!(state.pending_token_battlefield_entry.is_none()); assert_eq!( @@ -4152,7 +4169,11 @@ mod tests { player: PlayerId(0), }; let mut departed_events = Vec::new(); - realize_settled_token_battlefield_entry(&mut departed, &mut departed_events); + assert!( + !realize_settled_token_battlefield_entry(&mut departed, &mut departed_events), + "the CR 704.5f drop branch consumes the park but emits nothing, so there is no slice \ + for the boundary convergence to scan" + ); assert_eq!(ledger_rows(&departed, departed_id), (0, 0)); assert!(departed_events.is_empty()); assert!(departed.pending_token_battlefield_entry.is_none()); diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index 3ebe80b672..be919ceeb1 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -1073,9 +1073,10 @@ fn abandon_source_bound_resolution_prompt(state: &mut GameState, player: PlayerI // The prompt and its ability continuation are abandoned, so no realization point will ever be // reached for a token battlefield entry parked by this resolution. Leaving the `Option` live // would let a later token's park trip the fail-loud overwrite assert, and would let the - // action-boundary backstop write a CR 400.7 row for a resolution that no longer exists. If the - // token itself survives the abandonment its entry row is lost — the same loss the - // `deferred_entry_events.clear()` above already accepts for that entry's trigger replay. + // action-boundary convergence write a CR 400.7 row and run a CR 603.6a trigger pass for a + // resolution that no longer exists. If the token itself survives the abandonment its entry row + // is lost — the same loss the `deferred_entry_events.clear()` above already accepts for that + // entry's trigger replay. state.pending_token_battlefield_entry = None; state.waiting_for = WaitingFor::Priority { player: players::next_player(state, player), diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index a149219500..a753858b5a 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -376,13 +376,45 @@ fn apply_action_boundary_core( // CR 400.7 + CR 403.3 + CR 614.12a: an as-enters choice (and any continuation it raises) can // span an arbitrary number of client round-trips of ANY `WaitingFor` shape, so realization of a // parked token battlefield entry is keyed on the action having SETTLED, not on prompt shape. - // `apply_action` realizes it itself on every route that reaches `run_post_action_pipeline` (see - // `realize_settled_token_battlefield_entry`, which is where the ETB-observer-correct placement - // lives). This call is the BACKSTOP for the handlers that return an `ActionResult` straight out - // of the reducer match and never reach that pipeline: they still realize the entry here, one - // trigger scan too late, rather than stranding it. `Option::take_if` makes it a no-op after any - // earlier convergence point. - effects::token::realize_settled_token_battlefield_entry(state, &mut result.events); + // `apply_action` realizes it itself on every route that reaches `run_post_action_pipeline`. + // + // CR 603.6a: the entry pair this realization emits IS the event that puts a permanent onto the + // battlefield, so every permanent must be checked for matching enters-the-battlefield triggers + // (CR 603.2 + CR 603.3b place them on the stack before the next player receives priority). + // Reaching here with something to realize means the action settled WITHOUT running + // `run_post_action_pipeline` — one of the reducer arms that builds an `ActionResult` straight + // out of the match (`handle_tribute_choice` is the reachable one). Converge those onto the same + // pipeline the rest of the reducer uses, scanning ONLY the slice this realization appended, so + // a handler that already settled its own events (`handle_opponent_may_choice`, which collects + // into `deferred_triggers` without recording them in `consumed_before_priority_trigger_events`) + // cannot have them collected a second time. Inert on every other route: the flush returns + // `false` when nothing was parked or an earlier convergence point already consumed it + // (`Option::take_if`). + let scan_from = result.events.len(); + if effects::token::realize_settled_token_battlefield_entry(state, &mut result.events) { + let wf = match engine_priority::run_post_action_pipeline_from( + state, + &mut result.events, + scan_from, + &result.waiting_for, + false, + false, + ) { + Ok(wf) => wf, + Err(err) => { + *state = boundary_snapshot; + return Err(err); + } + }; + // The pipeline's terminal return hands back `flush_pending_priority_intercepts(..)` WITHOUT + // writing `state.waiting_for`, and the drain can raise `OrderTriggers` (CR 603.3b; measured + // on the Fanatic route). BOTH writes are load-bearing: `finish_action_boundary` copies + // `result.waiting_for` INTO the state at `sync_waiting_for`, and + // `apply_interaction_pre_reconciliation_for_life_safety` returns `raw.result` without ever + // calling `finish_action_boundary`. + state.waiting_for = wf.clone(); + result.waiting_for = wf; + } Ok(RawActionApplication { result, journal_start, @@ -8948,12 +8980,14 @@ fn apply_action( // the action's result, not the pre-action state (fixes stale TargetSelection // after CancelCast). state.waiting_for = waiting_for.clone(); - // CR 603.2 + CR 603.6a: a token battlefield entry postponed by an as-enters choice is - // realized HERE, before the trigger scan, so this action's `events` carry the entry pair - // the scan reads — otherwise the copy token enters with no observer ("whenever another - // creature enters") ever seeing it. Also ahead of the pipeline's CR 704.3 SBA pass, so the - // CR 400.7 row survives a copy that enters with 0 toughness. Same gate as the - // action-boundary backstop, one authority. + // CR 704.3 + CR 704.5f: a token battlefield entry postponed by an as-enters choice is + // realized HERE, before the pipeline below, so the CR 400.7 row is written ahead of that + // pipeline's SBA pass and survives a copy that enters with 0 toughness. It also puts the + // entry pair into this action's `events` ahead of the CR 603.2 / CR 603.6a scan — no longer + // the ONLY way that check runs (the action-boundary convergence in + // `apply_action_boundary_core` runs the same pipeline for direct-return handlers), but + // still the only placement that beats the SBA pass. Same gate as that boundary call, one + // authority; keeping it here also avoids two full pipeline passes per settling action. effects::token::realize_settled_token_battlefield_entry(state, &mut events); let wf = engine_priority::run_post_action_pipeline( state, diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index f5aa986170..916653cb9d 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -1783,8 +1783,10 @@ pub(super) fn handle_copy_target_choice( // record and CR 603.6a emit the unpaused one performs below. // // Realizing it INSIDE the counter drain (rather than leaving it to the action-boundary - // backstop) is what keeps this route's ETB observers firing: the post-action runs before - // that action's `run_post_action_pipeline` trigger scan, so the emitted pair is scanned. + // convergence) keeps the emitted pair ahead of this action's `run_post_action_pipeline` + // trigger scan AND ahead of its CR 704.3 SBA pass. The boundary now converges the trigger + // half for handlers that never reach that pipeline, so this hand-down is retained for the + // SBA ordering and for a drain that does not settle in its own action. // // On a pause this function returns at the `commit_liminal_token_entry_*` call below, so // the unpaused tail's CR 614.12a `BecomeCopy` chain, `finish_copy_target_choice_entry`, diff --git a/crates/engine/tests/integration/token_zone_change_index.rs b/crates/engine/tests/integration/token_zone_change_index.rs index 08b6f51134..b5da8b4f74 100644 --- a/crates/engine/tests/integration/token_zone_change_index.rs +++ b/crates/engine/tests/integration/token_zone_change_index.rs @@ -713,11 +713,15 @@ fn suppressed_liminal_copy_token_entry_is_recorded_once() { // `GameState::pending_token_battlefield_entry` and realized as one indivisible operation by // `token::flush_pending_token_battlefield_entry` at the first instant the object IS that thing. // -// Three convergence points call that one flush. (a) and (c) are pinned INDEPENDENTLY — deleting -// either one alone flips its test. (b) is only EXERCISED, not isolated: on every route the card -// pool reaches it settles in its own action, so (c) duplicates its work and deleting (b) alone -// flips nothing (measured). Its test still discriminates the flush as a whole (deleting (b) AND -// the in-`apply_action` (c) call takes it 1 → 0). +// Three convergence points call that one flush, and a fourth defensive call in the `Suppress` arm +// itself (`token.rs`) exists only so parking over a live entry cannot lose it. (a) is pinned +// INDEPENDENTLY — deleting it alone flips its test. (b) and the in-`apply_action` half of (c) are +// EXERCISED, not isolated: on every route the card pool reaches, the action-boundary half of (c) +// converges the same work, so deleting either one alone flips nothing (measured). They are kept for +// CR 704.3 ordering — the CR 400.7 row must be written before the settling action's SBA pass so +// CR 704.5f cannot bury a 0-toughness copy first — and, for (b), for a drain that does not settle +// in its own action. Their tests still discriminate the flush lifecycle as a whole (delete the park +// and every route test fails). // (a) `engine_replacement::finish_copy_target_choice_entry` — the unpaused route // (`suppressed_liminal_copy_token_entry_is_recorded_once`, above). // (b) `PendingCounterPostAction::EmitCommittedCopyTokenEntry` — the CR 616.1 ETB-counter @@ -726,10 +730,11 @@ fn suppressed_liminal_copy_token_entry_is_recorded_once() { // round trips it takes (`..._through_a_mandatory_as_enters_choice`, // `..._that_raises_a_second_pause`). One gate (settled `Priority` + token still on the // battlefield) called from two places in `engine.rs`: inside `apply_action` before -// `run_post_action_pipeline`, which is what keeps the realized token's ETB observers firing, -// and at the action boundary as the backstop for handlers that return an `ActionResult` -// straight out of the reducer match (`handle_tribute_choice`) and so never reach that -// pipeline. The two tests measure that difference directly: Painter +1 life, Fanatic 0. +// `run_post_action_pipeline`, and at the action boundary, where a realization now also runs +// `run_post_action_pipeline_from` over the slice it appended so the handlers that return an +// `ActionResult` straight out of the reducer match (`handle_tribute_choice`) get the same +// CR 603.6a check. Both tests measure +1 life; what distinguishes them is WHERE the pipeline +// runs, pinned by the presence of `OrderTriggers(2)` on the Fanatic route. /// Verbatim Oracle text from `data/card-data.json` (paraphrases can take a different parser /// branch, so the fixtures below must use the real strings). @@ -940,6 +945,14 @@ fn drive_embalm_copy( "TributeChoice".to_string(), GameAction::DecideOptionalEffect { accept: false }, ), + // CR 603.3b: a realized entry can trigger two same-controller abilities at once (the + // copy's own ETB plus a battlefield observer), which surfaces an ordering prompt. + WaitingFor::OrderTriggers { triggers, .. } => ( + format!("OrderTriggers({})", triggers.len()), + GameAction::OrderTriggers { + order: (0..triggers.len()).collect(), + }, + ), other => { drive.prompts.push(format!("{other:?}")); break; @@ -1014,11 +1027,12 @@ fn ledger_index(runner: &GameRunner, token: ObjectId) -> usize { /// `suppressed_liminal_copy_token_entry_is_recorded_once` (convergence point (a)) and /// `..._realizes_through_an_etb_counter_ordering_pause` (convergence point (b)) stay green. /// -/// SECOND REVERT-PROBE, isolating WHERE the settled action realizes it (discriminating, RUN): -/// delete only the `apply_action` call, keeping the boundary backstop ⇒ every ledger and emit -/// assertion below stays green and ONLY the Soul Warden assertion flips 1 → 0, because the backstop -/// appends the entry pair after `run_post_action_pipeline` has already scanned this action's events -/// for triggers. +/// SECOND REVERT-PROBE, isolating the CONVERGENCE as a whole (discriminating, RUN): deleting only +/// the `apply_action` call now flips NOTHING — the action-boundary call realizes the entry and runs +/// `run_post_action_pipeline_from` over the slice it appended, so the observer still fires. What +/// still flips this test's Soul Warden assertion 1 → 0 is deleting the boundary block's pipeline +/// call as well; the two placements now differ only in ordering against this action's CR 704.3 SBA +/// pass, which no fixture on this route discriminates. #[test] fn suppressed_liminal_copy_token_entry_realizes_through_a_mandatory_as_enters_choice() { let mut scenario = GameScenario::new(); @@ -1105,11 +1119,12 @@ fn suppressed_liminal_copy_token_entry_realizes_through_a_mandatory_as_enters_ch "the emitted ZoneChanged carries the index the recorder assigned" ); - // (4) DISCRIMINATOR for WHERE the settled action realizes it (CR 603.2 + CR 603.6a): the pair - // is emitted from inside `apply_action`, ahead of `run_post_action_pipeline`, so this - // action's trigger scan sees the token enter and the board's ETB observers fire. Realizing - // at the action BOUNDARY instead (after the reducer returned) leaves this at 0 — that is - // the maintainer's own named path, and it is the assertion that pins it. + // (4) CR 603.2 + CR 603.6a: the pair is emitted from inside `apply_action`, ahead of + // `run_post_action_pipeline`, so this action's trigger scan sees the token enter and the + // board's ETB observers fire. The action-boundary convergence would also produce +1 here + // (it runs the same pipeline over the slice it appends), so this assertion pins THAT the + // observer fires, not WHERE the realization happened; the Fanatic test's `OrderTriggers(2)` + // is what pins the boundary route specifically. assert_eq!( life_of_p0(runner.state()) - life_start, 1, @@ -1123,18 +1138,17 @@ fn suppressed_liminal_copy_token_entry_realizes_through_a_mandatory_as_enters_ch /// trips of two different prompt shapes. This is the shape a fix hung off any single prompt /// variant's resume arm cannot see. /// -/// REVERT-PROBE (discriminating, RUN): same as the Painter test — delete the -/// `token::realize_settled_token_battlefield_entry` calls in `engine.rs` ⇒ 0 rows on both ledgers -/// and no emit. +/// REVERT-PROBE (discriminating, RUN): delete the `run_post_action_pipeline_from` block in +/// `engine::apply_action_boundary_core` (leaving the bare realize call) ⇒ the reach-guard below +/// loses its `"OrderTriggers(2)"` element and fails first, and the Soul Warden assertion goes +/// 1 → 0. No other test in this file moves. /// -/// KNOWN PARTIAL, pinned below rather than left unasserted: this class settles through -/// `handle_tribute_choice`, which builds its `ActionResult` directly in the reducer match and never -/// reaches `run_post_action_pipeline`, so the entry is realized by the action-BOUNDARY backstop — -/// after that action's trigger scan. Its ETB observers therefore do not fire (assertion (4) below -/// measures 0, where the Painter class measures 1). Not a regression: before this lifecycle the -/// class emitted nothing at all and recorded a pre-copy row. The fix is to give the direct-return -/// handlers the same pipeline the rest of the reducer uses; when that lands, assertion (4) flips to -/// 1 and this test must be updated — a FAILURE here is a fix, not a regression. +/// CR 603.6a (`docs/MagicCompRules.txt:2599`): this class settles through `handle_tribute_choice`, +/// which builds its `ActionResult` directly in the reducer match and never reaches +/// `run_post_action_pipeline`, so the action-boundary convergence is what runs the ETB check for +/// it. TWO abilities trigger — Soul Warden's observer and the copy's own CR 603.4 "if tribute +/// wasn't paid" ETB — same controller, so CR 603.3b makes their order the controller's choice and +/// the ordering prompt is REQUIRED here, not an artifact of the harness. #[test] fn suppressed_liminal_copy_token_entry_realizes_through_an_as_enters_choice_with_a_second_pause() { let mut scenario = GameScenario::new(); @@ -1157,8 +1171,10 @@ fn suppressed_liminal_copy_token_entry_realizes_through_an_as_enters_choice_with "CopyTargetChoice".to_string(), "NamedChoice(1)".to_string(), "TributeChoice".to_string(), + "OrderTriggers(2)".to_string(), ], - "the tribute continuation must raise a SECOND pause after the as-enters choice" + "the tribute continuation raises a SECOND pause, and the realized entry then raises the \ + CR 603.3b ordering prompt for its two ETB triggers" ); let token = drive.token(); @@ -1210,18 +1226,18 @@ fn suppressed_liminal_copy_token_entry_realizes_through_an_as_enters_choice_with "the emitted ZoneChanged carries the index the recorder assigned" ); - // (4) The KNOWN PARTIAL, measured instead of left silent (see the doc comment). The tribute - // answer settles through `handle_tribute_choice`'s direct `ActionResult` return, so the - // entry is realized by the action-boundary backstop, AFTER this action's trigger scan — - // the same fixture on the Painter route (which does reach `run_post_action_pipeline`) - // measures 1, and `declined_copy_replacement_records_the_token_entry_without_parking_it` - // measures 1 on the `Emit` route, so a Soul Warden that simply never fires in this harness - // is ruled out and this 0 is the real gap, not a blind instrument. + // (4) CR 603.6a (`MagicCompRules.txt:2599`): the realized entry is the event that put a + // permanent onto the battlefield, so every permanent is checked for matching ETB triggers. + // `handle_tribute_choice` builds its `ActionResult` straight out of the reducer match, so + // the action-boundary convergence in `apply_action_boundary_core` is what runs that check + // for this class. TWO triggers fire (Soul Warden's observer and Fanatic's own CR 603.4 + // "if tribute wasn't paid" ETB) — the `OrderTriggers(2)` element of the reach-guard above + // pins that, and this assertion pins that the observer actually resolved. assert_eq!( life_of_p0(runner.state()) - life_start, - 0, - "documented gap: this class realizes at the action boundary, after the trigger scan, so \ - the token's ETB observers do not fire; prompts = {:?}", + 1, + "Soul Warden observes the realized copy token entering through the direct-return handler; \ + prompts = {:?}", drive.prompts ); } @@ -1229,21 +1245,21 @@ fn suppressed_liminal_copy_token_entry_realizes_through_an_as_enters_choice_with /// CR 400.7 + CR 616.1 — convergence point (b). Copying Faithful Watchdog ("enters with three /// +1/+1 counters") while Hardened Scales and Branching Evolution both want to modify that counter /// event forces the CR 616.1 ordering choice, which pauses the entry INSIDE the counter pipeline. -/// Realizing there (rather than at the action boundary) is what puts the entry pair into `events` -/// before this action's trigger scan, so the token's ETB observers still fire. +/// Realizing there puts the entry pair into `events` before this action's trigger scan AND before +/// its CR 704.3 SBA pass. The action-boundary convergence would also make the observers fire on +/// this fixture (it runs the same pipeline over the slice it appends); what (b) and the +/// in-`apply_action` call own, and the boundary does not, is that SBA ordering — (b) additionally +/// owns a drain that does NOT settle in its own action. /// -/// REVERT-PROBE (discriminating, RUN): delete BOTH in-action realization points — the flush call in +/// REVERT-PROBE (discriminating, RUN): delete the park itself (`token.rs`'s `Suppress` arm stores +/// nothing) ⇒ every ledger, emit and observer assertion in this test fails. Deleting the two +/// IN-ACTION realization points — the flush call in /// `counters::apply_pending_counter_post_action`'s `EmitCommittedCopyTokenEntry` arm AND -/// `token::realize_settled_token_battlefield_entry` inside `engine::apply_action` ⇒ the Soul Warden -/// assertion flips 1 → 0 while every ledger and emit assertion above stays green (the action -/// -boundary backstop still writes the rows and emits in the same action, just after the trigger -/// scan) — which is exactly why the observer assertion is this test's discriminator. -/// -/// MEASURED, and NOT what the earlier revision of this comment claimed: deleting the `counters.rs` -/// call ALONE now flips nothing, because this fixture's counter-order answer settles to `Priority` -/// and `apply_action` realizes the entry before the trigger scan regardless. The two points are -/// redundant on this route; `counters.rs` still owns a drain that does not settle in its own -/// action. +/// `token::realize_settled_token_battlefield_entry` inside `engine::apply_action` — no longer flips +/// anything here: this fixture's counter-order answer settles to `Priority`, so the action-boundary +/// convergence realizes the entry and runs `run_post_action_pipeline_from` over it in the same +/// action. What those two still own is CR 704.3 ordering (row before the SBA pass), which this +/// fixture does not discriminate. #[test] fn suppressed_liminal_copy_token_entry_realizes_through_an_etb_counter_ordering_pause() { let mut scenario = GameScenario::new(); @@ -1300,13 +1316,13 @@ fn suppressed_liminal_copy_token_entry_realizes_through_an_etb_counter_ordering_ assert_eq!(zone_name, "Faithful Watchdog"); assert_eq!(battlefield_name, zone_name); - // (3) DISCRIMINATOR for convergence point (b): the pair is emitted BEFORE this action's - // trigger scan, so a board ETB observer sees the token enter (CR 603.2). + // (3) The pair is emitted BEFORE this action's trigger scan, so a board ETB observer sees the + // token enter (CR 603.2). assert_eq!( life_of_p0(runner.state()) - life_start, 1, - "Soul Warden observes the copy token entering (flushing at the action boundary instead \ - appends the pair after the trigger scan ⇒ 0)" + "Soul Warden observes the copy token entering (CR 603.6a); deleting the park entirely is \ + what takes this to 0" ); } From 35f71f0d828e732e0b6446a0e80ea79a1f2d9c09 Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Sun, 2 Aug 2026 04:58:15 -0500 Subject: [PATCH 4/6] fix(tools): bind the parse-diff sticky to the head it was generated from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `render_markdown` returned early for the no-change case with the marker and "No card-parse changes detected" and nothing else — no base, no head — while the changed-cards branch printed only the baseline. No branch had ever emitted a head SHA, so the sticky comment could not be tied to the commit it described and regenerating it could not help. Both branches now carry the head, sourced from the `HEAD_SHA` the CI step already exports, with a `--head-sha` flag mirroring the existing `--base-sha`. Deriving it from git would be wrong rather than merely inelegant: that job checks out the synthetic PR merge commit, so git reports the merge SHA, never the PR head. Two consumer constraints shape the wording, each pinned by an assertion with a revert-probe: `scripts/pr_review.py` requires the marker to remain the first line, and it classifies sticky state by the substrings "Baseline pending" and "signature(s)", so the head line must contain neither or a no-change sticky misclassifies as real parse changes. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/bin/coverage_parse_diff.rs | 82 +++++++++++++++++++- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/bin/coverage_parse_diff.rs b/crates/engine/src/bin/coverage_parse_diff.rs index b7ffb32a00..e40c9176a6 100644 --- a/crates/engine/src/bin/coverage_parse_diff.rs +++ b/crates/engine/src/bin/coverage_parse_diff.rs @@ -335,12 +335,16 @@ fn main() { let mut markdown_out: Option = None; let mut json_out: Option = None; let mut base_sha = String::from("unknown"); + // CI exports this on the `parsediff` step (`ci.yml`) as `pull_request.head.sha`. NOT derived + // from git: that job checks out the synthetic PR merge commit, so `HEAD` is not the PR head. + let mut head_sha = std::env::var("HEAD_SHA").unwrap_or_else(|_| String::from("unknown")); let mut max_clusters = 25usize; while let Some(a) = args.next() { match a.as_str() { "--markdown" => markdown_out = args.next(), "--json" => json_out = args.next(), "--base-sha" => base_sha = args.next().unwrap_or(base_sha), + "--head-sha" => head_sha = args.next().unwrap_or(head_sha), "--max-clusters" => { max_clusters = args .next() @@ -351,7 +355,7 @@ fn main() { } } if positional.len() != 2 { - eprintln!("usage: coverage-parse-diff [--base-sha SHA] [--markdown OUT] [--json OUT] [--max-clusters N]"); + eprintln!("usage: coverage-parse-diff [--base-sha SHA] [--head-sha SHA] [--markdown OUT] [--json OUT] [--max-clusters N]"); process::exit(2); } let base = load(&positional[0]); @@ -438,6 +442,7 @@ fn main() { let md = render_markdown( &base_sha, + &head_sha, &clusters, max_clusters, changed_card_set.len(), @@ -577,6 +582,7 @@ fn render_cluster_sections(s: &mut String, clusters: &[Cluster], show_cards: boo #[allow(clippy::too_many_arguments)] fn render_markdown( base_sha: &str, + head_sha: &str, clusters: &[Cluster], max_clusters: usize, changed_cards: usize, @@ -586,6 +592,12 @@ fn render_markdown( ) -> String { let mut s = String::new(); s.push_str("\n"); + // Provenance: bind this comment to the head it was generated from. The sticky is EDITED in + // place on every re-push (coverage-parse-diff-comment.yml), so without the head SHA a reader + // cannot tell a fresh "no changes" from a stale one. Emitted before the branch so the + // no-changes early return below carries it too, and above the fold so the 60k-char truncation + // in the comment workflow cannot drop it. + let _ = writeln!(s, "_Generated for head `{head_sha}`._\n"); if clusters.is_empty() && added.is_empty() && removed.is_empty() { s.push_str("### Parse changes introduced by this PR\n\n"); s.push_str("✓ No card-parse changes detected.\n"); @@ -686,6 +698,10 @@ fn render_json( mod tests { use super::*; + /// Stand-in for CI's `HEAD_SHA`; full 40 chars so the identity check the sticky supports is + /// exercised at its real width. + const HEAD_SHA_FIXTURE: &str = "bee984f809e084d2bd0c71c4bbbb3d67ac8d13b4"; + /// Build a childless ability item with the given label/details/support. fn item(label: &str, details: &[(&str, &str)], supported: bool) -> ParsedItem { ParsedItem { @@ -811,7 +827,16 @@ mod tests { ), ]; - let markdown = render_markdown("e085a8d5fa08", &clusters, 4, 5, 0, &[], &[]); + let markdown = render_markdown( + "e085a8d5fa08", + HEAD_SHA_FIXTURE, + &clusters, + 4, + 5, + 0, + &[], + &[], + ); for section in [ "#### 🟢 Added (1 signature)", @@ -877,7 +902,16 @@ mod tests { ), ]; - let markdown = render_markdown("e085a8d5fa08", &clusters, 1, 4, 0, &[], &[]); + let markdown = render_markdown( + "e085a8d5fa08", + HEAD_SHA_FIXTURE, + &clusters, + 1, + 4, + 0, + &[], + &[], + ); assert!(markdown.contains( "
    … 3 more signature(s) (3 card-changes) — showing first 3;" @@ -888,6 +922,48 @@ mod tests { assert!(!markdown.contains("Affected (first 3): Added Card")); } + /// The sticky is edited in place on every re-push, so a body with no head SHA cannot be told + /// apart from a stale one. Both render branches must carry it — the no-changes early return is + /// the one the maintainer hit. + #[test] + fn markdown_identifies_the_head_sha_in_both_branches() { + const HEAD: &str = HEAD_SHA_FIXTURE; + + let empty = render_markdown("e085a8d5fa08", HEAD, &[], 4, 0, 0, &[], &[]); + assert!( + empty.contains(HEAD), + "the no-changes body must identify the head it was generated from: {empty}" + ); + assert!( + empty.starts_with(""), + "scripts/pr_review.py matches the sticky with startswith(MARKER); the marker must stay \ + the first line: {empty}" + ); + assert!( + !empty.contains("signature(s)"), + "scripts/pr_review.py classifies a body containing 'signature(s)' as real_changes; the \ + no-changes body must not: {empty}" + ); + + let clusters = vec![cluster( + ChangeKind::SupportFlip, + "Mill", + "", + "false", + "true", + &["Support Card"], + )]; + let changed = render_markdown("e085a8d5fa08", HEAD, &clusters, 4, 1, 0, &[], &[]); + assert!( + changed.contains(HEAD), + "the with-changes body must identify the head too: {changed}" + ); + assert!( + changed.contains("e085a8d5fa08"), + "the baseline SHA is still reported alongside the head" + ); + } + /// Regression guard for the sibling-collision case: two items share /// (category, label, source_text); the identical one must cancel as a /// multiset and the residual pair must reconcile to ONE field-change — From bc9f9445524f90c16cbbe27ae720d644fe4da723 Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Sun, 2 Aug 2026 05:44:09 -0500 Subject: [PATCH 5/6] fix(tools): make parse-diff provenance strict and self-identifying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings from CodeRabbit on the head-SHA change. A present-but-valueless `--base-sha` or `--head-sha` silently fell back to the previous value, so a report could name a commit it does not describe. Both now return a usage error. The pre-existing `--base-sha` line is fixed alongside the one this branch added: leaving it lenient would make two flags in the same provenance category behave differently. `--markdown`, `--json` and `--max-clusters` stay lenient by design and a test pins that asymmetry as deliberate — a missing value there omits output the caller can see, so there is nothing to misattribute. `parse-diff.json` carried no provenance at all, while both the Markdown report and the sticky comment direct a reader with truncated output to open it. `DiffReport` now carries `head_sha` and `base_sha`, ordered as the Markdown presents them. Nothing in the repo deserializes that artifact — the four references to it are two workflow write/upload paths and two prose mentions — so the added keys cannot break a consumer. Neither defect is reachable through CI, which never passes `--head-sha` and always pairs its flags with values; this hardens the local and manual surface. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/bin/coverage_parse_diff.rs | 187 +++++++++++++++++-- 1 file changed, 168 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/bin/coverage_parse_diff.rs b/crates/engine/src/bin/coverage_parse_diff.rs index e40c9176a6..a8be456714 100644 --- a/crates/engine/src/bin/coverage_parse_diff.rs +++ b/crates/engine/src/bin/coverage_parse_diff.rs @@ -329,22 +329,41 @@ fn load(path: &str) -> CoverageFile { } } -fn main() { - let mut args = std::env::args().skip(1); +/// Parsed CLI arguments. +#[derive(Debug)] +struct Args { + base_path: String, + head_path: String, + base_sha: String, + head_sha: String, + markdown_out: Option, + json_out: Option, + max_clusters: usize, +} + +/// Parse the CLI. `head_sha_default` is CI's `HEAD_SHA` env value, read by the caller so this stays +/// a pure function of its inputs. +/// +/// The two provenance flags REJECT a present-but-valueless form: falling back would silently +/// misattribute the whole report to another commit, and a confidently wrong SHA is worse than a +/// missing one. `--markdown` / `--json` / `--max-clusters` stay deliberately lenient — a missing +/// value there omits or degrades output the caller can see, so there is nothing to misattribute. +fn parse_args( + mut args: impl Iterator, + head_sha_default: String, +) -> Result { let mut positional: Vec = Vec::new(); let mut markdown_out: Option = None; let mut json_out: Option = None; let mut base_sha = String::from("unknown"); - // CI exports this on the `parsediff` step (`ci.yml`) as `pull_request.head.sha`. NOT derived - // from git: that job checks out the synthetic PR merge commit, so `HEAD` is not the PR head. - let mut head_sha = std::env::var("HEAD_SHA").unwrap_or_else(|_| String::from("unknown")); + let mut head_sha = head_sha_default; let mut max_clusters = 25usize; while let Some(a) = args.next() { match a.as_str() { "--markdown" => markdown_out = args.next(), "--json" => json_out = args.next(), - "--base-sha" => base_sha = args.next().unwrap_or(base_sha), - "--head-sha" => head_sha = args.next().unwrap_or(head_sha), + "--base-sha" => base_sha = args.next().ok_or("--base-sha requires a value")?, + "--head-sha" => head_sha = args.next().ok_or("--head-sha requires a value")?, "--max-clusters" => { max_clusters = args .next() @@ -354,12 +373,33 @@ fn main() { other => positional.push(other.to_string()), } } - if positional.len() != 2 { - eprintln!("usage: coverage-parse-diff [--base-sha SHA] [--head-sha SHA] [--markdown OUT] [--json OUT] [--max-clusters N]"); - process::exit(2); - } - let base = load(&positional[0]); - let head = load(&positional[1]); + let [base_path, head_path] = <[String; 2]>::try_from(positional) + .map_err(|_| "expected exactly two positional arguments")?; + Ok(Args { + base_path, + head_path, + base_sha, + head_sha, + markdown_out, + json_out, + max_clusters, + }) +} + +fn main() { + // CI exports HEAD_SHA on the `parsediff` step (`ci.yml`) as `pull_request.head.sha`. NOT derived + // from git: that job checks out the synthetic PR merge commit, so `HEAD` is not the PR head. + let head_sha_default = std::env::var("HEAD_SHA").unwrap_or_else(|_| String::from("unknown")); + let args = match parse_args(std::env::args().skip(1), head_sha_default) { + Ok(a) => a, + Err(msg) => { + eprintln!("coverage-parse-diff: {msg}"); + eprintln!("usage: coverage-parse-diff [--base-sha SHA] [--head-sha SHA] [--markdown OUT] [--json OUT] [--max-clusters N]"); + process::exit(2); + } + }; + let base = load(&args.base_path); + let head = load(&args.head_path); let bmap: BTreeMap = base .cards @@ -441,16 +481,16 @@ fn main() { }); let md = render_markdown( - &base_sha, - &head_sha, + &args.base_sha, + &args.head_sha, &clusters, - max_clusters, + args.max_clusters, changed_card_set.len(), oracle_changed, &added_cards, &removed_cards, ); - match &markdown_out { + match &args.markdown_out { Some(p) => { if let Err(e) = fs::write(p, &md) { eprintln!("coverage-parse-diff: cannot write {p}: {e}"); @@ -460,8 +500,15 @@ fn main() { None => println!("{md}"), } - if let Some(p) = &json_out { - let json = render_json(&clusters, &added_cards, &removed_cards, oracle_changed); + if let Some(p) = &args.json_out { + let json = render_json( + &args.head_sha, + &args.base_sha, + &clusters, + &added_cards, + &removed_cards, + oracle_changed, + ); if let Err(e) = fs::write(p, json) { eprintln!("coverage-parse-diff: cannot write {p}: {e}"); process::exit(2); @@ -649,6 +696,11 @@ fn render_markdown( /// hand-rolled escaping/joining. #[derive(Serialize)] struct DiffReport<'a> { + /// Same provenance pair the Markdown carries, in the order it presents them (head, then + /// baseline). The sticky comment sends a reader here when it truncates, so the artifact has to + /// identify its own commits rather than borrow the comment's. + head_sha: &'a str, + base_sha: &'a str, oracle_changed: usize, added_cards: &'a [String], removed_cards: &'a [String], @@ -668,12 +720,16 @@ struct ClusterJson<'a> { } fn render_json( + head_sha: &str, + base_sha: &str, clusters: &[Cluster], added: &[String], removed: &[String], oracle_changed: usize, ) -> String { let report = DiffReport { + head_sha, + base_sha, oracle_changed, added_cards: added, removed_cards: removed, @@ -1003,4 +1059,97 @@ mod tests { assert_eq!(changes[0].kind, ChangeKind::SupportFlip); assert_eq!(changes[0].label, "Mill"); } + + /// The two required positionals plus whatever flags the case is exercising. + fn argv(flags: &[&str]) -> std::vec::IntoIter { + let mut v = vec!["base.json".to_string(), "head.json".to_string()]; + v.extend(flags.iter().map(|s| (*s).to_string())); + v.into_iter() + } + + /// A trailing `--base-sha`/`--head-sha` is a usage error, not a silent fallback: the report + /// would otherwise be stamped with a commit the caller never named. Each arm asserts on its own + /// flag name, so fixing only one of the provenance pair fails the other. + #[test] + fn provenance_flags_reject_a_missing_value() { + let base_err = parse_args(argv(&["--base-sha"]), "env-head".into()) + .expect_err("a valueless --base-sha must not fall back to `unknown`"); + assert!( + base_err.contains("--base-sha"), + "the error must name the offending flag: {base_err}" + ); + + let head_err = parse_args(argv(&["--head-sha"]), "env-head".into()) + .expect_err("a valueless --head-sha must not fall back to the env default"); + assert!( + head_err.contains("--head-sha"), + "the error must name the offending flag: {head_err}" + ); + + // Positive control: the same flags WITH values parse, and an explicit --head-sha overrides + // the env default rather than being ignored. + let ok = parse_args( + argv(&["--base-sha", "e085a8d5fa08", "--head-sha", HEAD_SHA_FIXTURE]), + "env-head".into(), + ) + .expect("both provenance flags with values must parse"); + assert_eq!(ok.base_sha, "e085a8d5fa08"); + assert_eq!(ok.head_sha, HEAD_SHA_FIXTURE); + + // Omitting them entirely is still legal — that is CI's shape for the head (env-supplied). + let defaulted = parse_args(argv(&[]), "env-head".into()).expect("positionals alone parse"); + assert_eq!(defaulted.head_sha, "env-head"); + assert_eq!(defaulted.base_sha, "unknown"); + + // The positional arity check survives the Vec → [String; 2] rewrite. + assert!(parse_args(["only-one.json".to_string()].into_iter(), "env-head".into()).is_err()); + } + + /// The asymmetry with the provenance flags is deliberate. A missing `--markdown`/`--json`/ + /// `--max-clusters` value omits or degrades output the caller can see for themselves; there is + /// no commit to misattribute. Pinned so a later "make every flag strict" sweep is a decision. + #[test] + fn output_flags_stay_lenient_on_a_missing_value() { + let md = parse_args(argv(&["--markdown"]), "env-head".into()) + .expect("a valueless --markdown must not be a usage error"); + assert!(md.markdown_out.is_none(), "output falls back to stdout"); + + let js = parse_args(argv(&["--json"]), "env-head".into()) + .expect("a valueless --json must not be a usage error"); + assert!( + js.json_out.is_none(), + "the drill-down artifact is simply skipped" + ); + + let mc = parse_args(argv(&["--max-clusters"]), "env-head".into()) + .expect("a valueless --max-clusters must not be a usage error"); + assert_eq!(mc.max_clusters, 25, "the default cluster cap stands"); + } + + /// The sticky comment sends a reader to `parse-diff.json` when its body is truncated, so the + /// artifact must identify its own commits instead of borrowing the comment's. + #[test] + fn json_report_carries_both_shas() { + const BASE: &str = "e085a8d5fa0817e3a1f6e7c9d40b2a5c3e8f1d62"; + + let clusters = vec![cluster( + ChangeKind::SupportFlip, + "Mill", + "", + "false", + "true", + &["Support Card"], + )]; + let json = render_json(HEAD_SHA_FIXTURE, BASE, &clusters, &[], &[], 0); + let v: serde_json::Value = + serde_json::from_str(&json).expect("render_json must emit valid JSON"); + + // Distinct fixture values, so a head/base swap fails rather than passing symmetrically. + assert_eq!(v["head_sha"], HEAD_SHA_FIXTURE); + assert_eq!(v["base_sha"], BASE); + assert_eq!( + v["clusters"][0]["label"], "Mill", + "the drill-down is unchanged" + ); + } } From 7396e76b699018134a98167711e76a0ad0e35fc9 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 2 Aug 2026 04:07:34 -0700 Subject: [PATCH 6/6] fix(PR-6851): validate parse-diff SHA arguments Co-authored-by: Lindsey Gray --- crates/engine/src/bin/coverage_parse_diff.rs | 37 ++++++++++++++++---- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/bin/coverage_parse_diff.rs b/crates/engine/src/bin/coverage_parse_diff.rs index a8be456714..63418e7751 100644 --- a/crates/engine/src/bin/coverage_parse_diff.rs +++ b/crates/engine/src/bin/coverage_parse_diff.rs @@ -362,8 +362,18 @@ fn parse_args( match a.as_str() { "--markdown" => markdown_out = args.next(), "--json" => json_out = args.next(), - "--base-sha" => base_sha = args.next().ok_or("--base-sha requires a value")?, - "--head-sha" => head_sha = args.next().ok_or("--head-sha requires a value")?, + "--base-sha" => { + base_sha = args + .next() + .filter(|value| !value.is_empty() && !value.starts_with("--")) + .ok_or("--base-sha requires a value")? + } + "--head-sha" => { + head_sha = args + .next() + .filter(|value| !value.is_empty() && !value.starts_with("--")) + .ok_or("--head-sha requires a value")? + } "--max-clusters" => { max_clusters = args .next() @@ -1067,11 +1077,12 @@ mod tests { v.into_iter() } - /// A trailing `--base-sha`/`--head-sha` is a usage error, not a silent fallback: the report - /// would otherwise be stamped with a commit the caller never named. Each arm asserts on its own - /// flag name, so fixing only one of the provenance pair fails the other. + /// A missing, empty, or option-token value after a provenance flag is a usage error, not a + /// silent fallback: the report would otherwise be stamped with a commit the caller never named. + /// Each arm asserts on its own flag name, so fixing only one of the provenance pair fails the + /// other. #[test] - fn provenance_flags_reject_a_missing_value() { + fn provenance_flags_reject_missing_empty_and_option_values() { let base_err = parse_args(argv(&["--base-sha"]), "env-head".into()) .expect_err("a valueless --base-sha must not fall back to `unknown`"); assert!( @@ -1086,6 +1097,20 @@ mod tests { "the error must name the offending flag: {head_err}" ); + for (flag, invalid_value) in [ + ("--base-sha", ""), + ("--base-sha", "--markdown"), + ("--head-sha", ""), + ("--head-sha", "--markdown"), + ] { + let err = parse_args(argv(&[flag, invalid_value]), "env-head".into()) + .expect_err("empty and option-token provenance values must be rejected"); + assert!( + err.contains(flag), + "the error must name {flag} for {invalid_value:?}: {err}" + ); + } + // Positive control: the same flags WITH values parse, and an explicit --head-sha overrides // the env default rather than being ignored. let ok = parse_args(