diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 18c9de6065..9cc2673a7b 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -8396,14 +8396,125 @@ pub(crate) fn filter_consumed_trigger_events( /// byte-identical `ZoneChanged` that no owner collected alongside one that two /// observers saw, both are dropped. At the priority scan that residual is no /// larger than the filter this replaces; at the search-delivery park there is no -/// prior `ZoneChanged` filter at all, so the residual is new there and is bounded -/// by byte-identical `ZoneChanged` duplicates being unreachable inside one -/// collector slice. An occurrence-exact witness is NOT available here: the only -/// exact record is `LogicalZoneChangeGroup::all_origin_occurrences`, and a +/// prior `ZoneChanged` filter at all, so the residual is new there — and for +/// DISTINCT occurrences within one turn it is EMPTY, which is proved under +/// "OCCURRENCE EXACTNESS" below rather than assumed. An occurrence-exact witness +/// is NOT available here: the only exact record is +/// `LogicalZoneChangeGroup::all_origin_occurrences`, and a /// completed owner's group is a caller-owned local that is gone before this runs /// (`GameState` holds a group only inside the two *paused* frames, /// `PendingChangeZoneIteration` and `PendingBatchDeliveries`). /// +/// OCCURRENCE EXACTNESS — PROVED WITHIN ONE TURN, WITH ITS BASIS AND ITS BOUNDS. +/// CR 400.7 + CR 603.2c. Two byte-identical `ZoneChanged` in one slice denote +/// ONE occurrence emitted twice — precisely what this filter should drop — and +/// two DISTINCT occurrences are never byte-identical, because +/// `ZoneChangeRecord::turn_zone_change_index` participates in `GameEvent` +/// equality and is unique per occurrence within a turn. All FOUR production +/// sites that construct a `ZoneChanged` into an event buffer ship the index +/// `restrictions::record_zone_change` assigned: `move_to_zone`'s tail, +/// `record_and_emit_entry_from_no_zone`, the library-insert path +/// `move_to_library_at_index`, and `merge.rs`'s CR 730.3c component split. That +/// recorder is the SOLE grower of `state.zone_changes_this_turn` (it reads +/// `len()`, stamps, then pushes) and the ledger never shrinks mid-turn, so two +/// distinct occurrences in one turn carry distinct indices and the `position()` +/// match below cannot cross-consume them. +/// +/// THE BASIS IS THE INDEX. The other three separators are FAMILY-DEPENDENT and +/// must not be read as an unconditional conjunction: +/// * `turn_zone_change_index` — the basis; live for EVERY family. +/// * `GameEvent::ZoneChanged::object_id` — a TOP-LEVEL field, sibling to +/// `record`, so it separates distinct objects independently of record +/// equality. Dead when ONE object repeats a transition. +/// * `ZoneChangeRecord::entered_incarnation` — separates battlefield +/// re-entries of one object. `None`, and therefore inert, for every +/// non-battlefield destination. +/// * `trigger_source_context.identity.reference.incarnation` — separates two +/// snapshots of one object taken at different incarnations. Inert on any +/// path that does not bump the incarnation. +/// +/// EXCEPTION — THE WITHIN-ZONE REPOSITION FAMILY. For `move_to_library_at_index` +/// with `from == Zone::Library` (the CR 400.7 zero-bump branch at +/// `zones.rs:1809-1812`, emit at `zones.rs:1829`; CR 701.20b — revealing a card +/// does not move it) all THREE additional separators are dead at once and +/// `turn_zone_change_index` is the SOLE discriminator. Those events are +/// reachable as witnesses here — library-destination `ChangesZoneAll` observers +/// exist in the card pool and `zone_change_clause_matches` +/// (`trigger_matchers.rs:1091-1130`) has no same-zone guard — AND inside a +/// `park_search_observer_triggers` slice, via the tutor-to-top class +/// (`SearchLibrary` -> `Shuffle` -> `PutAtLibraryPosition{Top}`). +/// +/// SCOPE: LIVE PLAY. Four residuals remain open, are filed as issues, and are +/// NOT closed by this filter: +/// * R1 — the index is PER-TURN. `zone_changes_this_turn` is cleared at +/// `turns.rs:1253`, while `start_next_turn` does NOT clear +/// `state.deferred_triggers`, so a witness that survives a turn boundary +/// could in principle alias a reset index. The queue is bounded only by +/// drain-at-priority (`drain_deferred_trigger_queue_unchecked`), by the +/// CR 724 end-the-turn / end-the-combat-phase EFFECTS, and by elimination +/// — NOT by the turn boundary. +/// For the within-zone reposition family the two incarnation +/// fields add NO cross-turn protection, because they were never advanced. +/// * R2 — deserialized states bypass the recorder. +/// `PersistedGameState::into_game_state` (`types/game_state.rs:9024`) +/// reconstructs `ZoneChanged` straight into live buffers with +/// `#[serde(default)]` indices, so a restored state can carry index `0` on +/// distinct occurrences. Pre-existing and out of scope here. +/// * R3 — `zone_change_clause_matches` (`trigger_matchers.rs:1091-1130`) has +/// no same-zone guard, so a library-destination `ChangesZoneAll` observer +/// fires on a within-library reposition, which CR 400.7 / CR 701.20b say is +/// not a zone change. The record is ALSO written to the ledger +/// unconditionally (`zones.rs:1818-1819`), so +/// `QuantityRef::ZoneChangeCountThisTurn` / `ZoneChangeAggregateThisTurn` +/// (`game/quantity.rs:4125-4165`) over-count and the ledger-subscript +/// consumers (`types/ability.rs:23726`, `:23841`) see phantom rows. Gating +/// the matcher alone would NOT fix those. +/// * R4 — the `EffectZoneChoice` `SelectCards` arm +/// (`engine_resolution_choices.rs:4493-4540`) validates length, membership +/// and current zone but NOT uniqueness, unlike its FIVE sibling arms: +/// `engine_resolution_choices.rs:833` (keep-on-top), `:864` (dig), `:2891` +/// (pile A), `:6409` (`EachPlayerCopyChosen`), and `mulligan.rs:561` +/// (bottom selection), the last of which answers the SAME +/// `GameAction::SelectCards` variant. A duplicate id therefore reaches +/// `move_library_origin_cards_in_selection_order` +/// (`engine_resolution_choices.rs:6941`) and repositions one object twice +/// inside one loop. That is a boundary-validation gap, not a filter defect. +/// +/// CARVE-OUT: `game/stack.rs:3608` builds a production `ZoneChanged` carrying +/// index `0`, but it is a local probe passed only to +/// `trigger_index::candidates_for_event` — it never enters a `Vec`, +/// never reaches `state.deferred_triggers`, and is therefore outside the scope +/// of the invariant above by construction. +/// +/// PINNED BY THREE TESTS, none of which covers the whole: +/// * `occurrence_exact_witness_consumes_the_occurrence_its_witness_names` +/// (`triggers_dedup_regression_tests.rs`) pins the EQUALITY link at this +/// authority, using `ZoneChangeRecord::test_minimal` records in which the +/// other three separators are `None`. A PRODUCTION fixture for this link is +/// CONSTRUCTIBLE but deliberately NOT built: the only known route depends on +/// the R4 validation gap, and when R4 is fixed the action would be REJECTED, +/// so such a row would fail to construct and go RED — coupling it to a +/// validation gap rather than to the invariant. +/// * `within_library_repositions_are_separated_only_by_the_occurrence_index` +/// (`game/zones.rs`) pins the exception family at the library-insert emit +/// site with the real mover: zero bump, `entered_incarnation` `None` on +/// both, identical `trigger_source_context`, consecutive indices. +/// * `parked_delivery_records_carry_distinct_occurrence_indices` +/// (`tests/integration/search_delivery_observer_dedup.rs`) pins the +/// allocator->event fidelity link in production on ONE emit path only +/// (`zone_pipeline` -> `move_to_zone` ordinary arm -> `zones.rs:1362`). It +/// is a sentinel, not a census over the four emit sites. +/// +/// KNOWN UNCLOSED GUARD GAP, deliberately out of scope here: +/// `apply_resolved_zone_change` compares the recorder's return value against +/// `command.turn_zone_change_index` — a SIBLING FIELD of the command — at +/// `zones.rs:946-953`, and never against +/// `command.zone_change_record.turn_zone_change_index`, which is the copy that +/// actually becomes the event (`zones.rs:1199` -> `:1362`). So the emitted +/// event's index has NO production guard on any path. Extending that comparison +/// is the class-wide fix and would cover every path through +/// `resolve_and_apply_zone_change` at once. +/// /// A collector whose slice is provably exactly one owner's completion slice does /// NOT need this — a blanket `ZoneChanged` drop is equivalent there, and that is /// what `engine_resolution_choices::batch_or_drain_observer_triggers` diff --git a/crates/engine/src/game/triggers_dedup_regression_tests.rs b/crates/engine/src/game/triggers_dedup_regression_tests.rs index f0e112d672..388eb87fff 100644 --- a/crates/engine/src/game/triggers_dedup_regression_tests.rs +++ b/crates/engine/src/game/triggers_dedup_regression_tests.rs @@ -3808,6 +3808,27 @@ fn owner_collected_filter_never_drops_non_zone_change_events() { /// `filter_already_collected_trigger_events_from`'s contract, made executable so /// no future author can re-assert occurrence-exactness without deliberately /// updating this row. +/// +/// THIS ROW DESCRIBES A `#[cfg(test)]`-ONLY INPUT, NOT A PRODUCTION LOSS. Both +/// events come from `zone_change_event`, which builds its record with +/// `ZoneChangeRecord::test_minimal` — a `#[cfg(test)]` constructor whose own doc +/// says *"Production code must use `GameObject::snapshot_for_zone_change`"*. It +/// pins `turn_zone_change_index` at `0` for BOTH events and leaves +/// `trigger_source_context` and `entered_incarnation` as `None`. In production, +/// two byte-identical `ZoneChanged` denote ONE occurrence emitted twice, so +/// dropping both is the CORRECT answer for this input. +/// +/// Distinct production occurrences are separated by `turn_zone_change_index` in +/// EVERY family, and additionally by `object_id` (a top-level field of the +/// event, sibling to `record`), by `entered_incarnation` (battlefield +/// destinations only), and by +/// `trigger_source_context.identity.reference.incarnation` (only where the path +/// bumps the incarnation) — with the within-library reposition family the +/// exception in which only the index survives. Those links are pinned by +/// `occurrence_exact_witness_consumes_the_occurrence_its_witness_names` (U5, +/// below), by `within_library_repositions_are_separated_only_by_the_occurrence_index` +/// (in `game/zones.rs`), and by `parked_delivery_records_carry_distinct_occurrence_indices` +/// (in `tests/integration/search_delivery_observer_dedup.rs`). #[test] fn owner_collected_filter_counts_contexts_not_occurrences() { let mut state = setup(); @@ -3837,3 +3858,107 @@ fn owner_collected_filter_counts_contexts_not_occurrences() { NOT occurrence-exact" ); } + +/// U5 — the witness consumes the occurrence IT NAMES, not merely one of that shape. +/// +/// CR 603.2c sentence 2. Two DISTINCT occurrences of one object are separated in +/// production by `turn_zone_change_index` always, and by up to three further +/// fields depending on the family (`object_id`, `entered_incarnation`, +/// `trigger_source_context.identity.reference.incarnation`). This row isolates the +/// one that is live for EVERY family and lives inside `ZoneChangeRecord`'s +/// equality — `turn_zone_change_index`, assigned per-occurrence by +/// `restrictions::record_zone_change`. `ZoneChangeRecord::test_minimal` leaves +/// `trigger_source_context` and `entered_incarnation` as `None`, and both events +/// here share one `ObjectId`, so the index is the SOLE difference. +/// +/// A production analogue of this shape DOES exist — a within-library reposition +/// (CR 400.7 zero-bump branch, `zones.rs:1809-1812`) neutralizes the same three +/// fields — see `within_library_repositions_are_separated_only_by_the_occurrence_index` +/// in `zones.rs`. That row IS red under a `turn_zone_change_index`-excluding +/// `PartialEq`, but it pins the zones EMIT link; a production fixture for the +/// FILTER-AUTHORITY link pinned here is CONSTRUCTIBLE but is deliberately NOT +/// built: the only known route depends on a duplicate-id +/// `SelectCards` payload that the `EffectZoneChoice` arm fails to reject, and +/// every sibling validator rejects such a payload with an error. So once that +/// gap is closed the action is REJECTED, the row fails to construct, and it goes +/// RED — the row would be testing the validation gap, not the invariant. The +/// behavioural pin therefore lives here. +/// +/// The witness names the SECOND occurrence, so the survivor must be the FIRST. +/// The survivor is identified by a RAW FIELD READ, not by `GameEvent` equality: +/// an equality-based assertion would itself be evaluated under the very +/// `PartialEq` a regression would break, and would pass either way. +/// +/// Goes red if a future change drops `turn_zone_change_index` out of +/// `ZoneChangeRecord` equality (e.g. a manual `impl PartialEq` that skips it), or +/// if the filter regresses to set membership. +#[test] +fn occurrence_exact_witness_consumes_the_occurrence_its_witness_names() { + let mut state = setup(); + + let first_event = GameEvent::ZoneChanged { + object_id: ObjectId(7), + from: Some(Zone::Library), + to: Zone::Battlefield, + record: Box::new(ZoneChangeRecord { + turn_zone_change_index: 0, + ..ZoneChangeRecord::test_minimal(ObjectId(7), Some(Zone::Library), Zone::Battlefield) + }), + }; + let second_event = GameEvent::ZoneChanged { + object_id: ObjectId(7), + from: Some(Zone::Library), + to: Zone::Battlefield, + record: Box::new(ZoneChangeRecord { + turn_zone_change_index: 1, + ..ZoneChangeRecord::test_minimal(ObjectId(7), Some(Zone::Library), Zone::Battlefield) + }), + }; + + assert_ne!( + first_event, second_event, + "CR 400.7: turn_zone_change_index is the ONLY field separating these two \ + occurrences of one object, and it MUST participate in GameEvent equality" + ); + + let events = vec![first_event.clone(), second_event.clone()]; + + // Reach-guard: with an empty queue the filter body still runs and keeps both + // events. Without this the main assertion below would also be satisfied by a + // function that returned everything. NOT revert-failing under F-EQ. + assert!( + state.deferred_triggers.is_empty(), + "the reach-guard needs an empty queue" + ); + assert_eq!( + zone_change_count(&filter_already_collected_trigger_events_from( + &state, + &events, + 0, + &[] + )), + 2, + "reach-guard: with no queued witness the filter keeps both occurrences" + ); + + // The witness names the SECOND occurrence. + state + .deferred_triggers + .push(queued_context_for(second_event.clone())); + + let survivors = filter_already_collected_trigger_events_from(&state, &events, 0, &[]); + assert_eq!( + survivors.len(), + 1, + "one witness consumes exactly one occurrence" + ); + let GameEvent::ZoneChanged { record, .. } = &survivors[0] else { + panic!("the survivor must be the ZoneChanged that no witness named"); + }; + assert_eq!( + record.turn_zone_change_index, 0, + "CR 603.2c: the witness named occurrence 1, so occurrence 0 must survive; \ + reading the index RAW (not via GameEvent equality) is what makes this \ + assertion survive a broken ZoneChangeRecord PartialEq" + ); +} diff --git a/crates/engine/src/game/zones.rs b/crates/engine/src/game/zones.rs index 25de9f2a0d..78d2ca270d 100644 --- a/crates/engine/src/game/zones.rs +++ b/crates/engine/src/game/zones.rs @@ -3232,6 +3232,134 @@ mod tests { assert_eq!(state.players[0].library[1], id2); // goes to bottom } + /// U6 — CR 400.7 + CR 701.20b: a within-Library reposition is ZERO MOVES, so + /// two repositions of ONE card in one turn are separated by + /// `turn_zone_change_index` ALONE. + /// + /// This is the ONE production family in which the occurrence index is the SOLE + /// discriminator. The zero-bump branch below (`if from != Zone::Library`) is + /// rules-correct — CR 701.20b: "Revealing a card doesn't cause it to leave the + /// zone it's in", and CR 400.7 only makes a new object on a move BETWEEN zones — + /// and its correctness is exactly what kills the other three separators: + /// * `object_id` — same object, twice; + /// * `entered_incarnation` — `None` on both (not a + /// battlefield destination; `snapshot_for_zone_change` seeds `None`); + /// * `trigger_source_context…reference.incarnation` — identical on both (the + /// incarnation is deliberately not bumped). + /// + /// Every other family is over-determined and would survive losing the index. + /// This one would not. + /// + /// The sibling rows `move_to_library_top` and `move_to_library_bottom` above + /// cover the `from != Library` (bumping) arm of the same branch; this row + /// covers the `from == Library` arm. + /// + /// Reached in production from clash placement (CR 701.30a, + /// `engine_resolution_choices.rs`), the `EffectZoneChoice` library-origin + /// repositioners, and both `zone_pipeline` library-placement arms; and reached + /// inside a `park_search_observer_triggers` slice by the tutor-to-top class + /// (`SearchLibrary -> Shuffle -> PutAtLibraryPosition{Top}`). + /// + /// Goes red under EITHER of: + /// * a `ZoneChangeRecord` `PartialEq` that stops comparing + /// `turn_zone_change_index` (the two events become fully equal); or + /// * an emit-site-3 regression that ships the `0` placeholder instead of the + /// index `restrictions::record_zone_change` returned. + #[test] + fn within_library_repositions_are_separated_only_by_the_occurrence_index() { + let mut state = setup(); + let filler = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Filler".to_string(), + Zone::Library, + ); + let card = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Card".to_string(), + Zone::Library, + ); + + let incarnation_before = state.objects[&card].incarnation; + let mut events = Vec::new(); + move_to_library_at_index(&mut state, card, Some(0), &mut events); // to top + move_to_library_at_index(&mut state, card, None, &mut events); // to bottom + + let zone_changes: Vec<&GameEvent> = events + .iter() + .filter(|event| matches!(event, GameEvent::ZoneChanged { .. })) + .collect(); + assert_eq!( + zone_changes.len(), + 2, + "each reposition emits exactly one ZoneChanged" + ); + + let GameEvent::ZoneChanged { + object_id: first_object, + from: first_from, + to: first_to, + record: first, + } = zone_changes[0] + else { + panic!("the first reposition must emit a ZoneChanged"); + }; + let GameEvent::ZoneChanged { + object_id: second_object, + from: second_from, + to: second_to, + record: second, + } = zone_changes[1] + else { + panic!("the second reposition must emit a ZoneChanged"); + }; + + assert_eq!(*first_object, card); + assert_eq!(*second_object, card); + assert_eq!(*first_from, Some(Zone::Library)); + assert_eq!(*second_from, Some(Zone::Library)); + assert_eq!(*first_to, Zone::Library); + assert_eq!(*second_to, Zone::Library); + + assert_eq!( + state.objects[&card].incarnation, incarnation_before, + "CR 400.7 + CR 701.20b: a within-Library reposition is zero moves, so \ + the zero-bump branch must have been taken" + ); + assert!( + state.players[0].library.contains(&filler) && state.players[0].library.contains(&card), + "both cards stay in the library across the two repositions" + ); + + assert!( + first.entered_incarnation.is_none() && second.entered_incarnation.is_none(), + "CR 400.7: a within-Library reposition has no battlefield entry, so \ + `entered_incarnation` separates nothing here" + ); + assert_eq!( + first.trigger_source_context, second.trigger_source_context, + "CR 701.20b: the zero-bump branch leaves the source context identical, so it \ + separates nothing here" + ); + + assert_eq!( + second.turn_zone_change_index, + first.turn_zone_change_index + 1, + "restrictions::record_zone_change is the sole allocator and never shrinks the \ + ledger, so two repositions in one turn get consecutive indices; emit site 3 \ + (zones.rs:1829) must ship the allocator's value, not the `0` placeholder" + ); + assert_ne!( + zone_changes[0], zone_changes[1], + "turn_zone_change_index is the ONLY field separating these two events — dropping \ + it from ZoneChangeRecord's equality collapses two distinct CR 603.2c occurrences \ + into one" + ); + } + #[test] fn player_zones_are_per_player() { let mut state = setup(); diff --git a/crates/engine/tests/integration/search_delivery_observer_dedup.rs b/crates/engine/tests/integration/search_delivery_observer_dedup.rs index 5fdf6ac37c..afa3917fb2 100644 --- a/crates/engine/tests/integration/search_delivery_observer_dedup.rs +++ b/crates/engine/tests/integration/search_delivery_observer_dedup.rs @@ -23,7 +23,16 @@ //! occurrences." Row `two_land_search_delivery_fires_landfall_twice` pins the //! second sentence so the fix is not over-applied into a blanket suppression. //! -//! HARNESS NOTE — every park-path row here passes priority before asserting. +//! HARNESS NOTE — every park-path row that asserts what the parked observers +//! DID passes priority first (H1, H2, H5, H6, N1, N2). Two park-path rows +//! deliberately assert PRE-drain and must NOT pass, because a pass runs the +//! drain, empties the queue and makes the assertion vacuous: N5 +//! (`parked_delivery_records_carry_distinct_occurrence_indices`) asserts on the +//! parked queue's CONTENTS, behind the `assert_observers_were_parked` +//! reach-guard; and N4 (`fetch_with_no_legal_target_parks_nothing`) asserts the +//! queue is EMPTY on a fail-to-find, which is also what licenses its no-firing +//! assertion without a pass. H3, H4 and N3 are NOT park paths; they reach the +//! stack in-action or through `advance_until_stack_empty`. //! `park_search_observer_triggers` deliberately defers its observers to the NEXT //! priority checkpoint (issue #5336): the parked action returns //! `ResolutionChoiceOutcome::WaitingForWithParkedObservers`, which sets @@ -635,3 +644,152 @@ fn fetch_with_no_legal_target_parks_nothing() { "no land entered, so landfall must not fire" ); } + +// --------------------------------------------------------------------------- +// N5 — CR 400.7 + CR 603.2c: the two occurrences parked by ONE search delivery +// carry DISTINCT `turn_zone_change_index` values, each agreeing with the +// ledger row `restrictions::record_zone_change` wrote for it. +// +// SCOPE — this row is a SENTINEL ON ONE EMIT PATH, NOT A CENSUS. +// Its Harrow fixture traverses: +// change_zone::resolve -> execute_zone_move_with_applied_terminal +// -> deliver_replaced_zone_change -> zones::move_to_zone (ordinary arm) +// -> resolve_and_apply_zone_change (zones.rs:826) +// -> zones.rs:1199 -> emit at zones.rs:1362. +// A regression at the other three production emit sites — zones.rs:1430 +// (`from: None` entries), zones.rs:1829 (library insert; covered instead by +// `within_library_repositions_are_separated_only_by_the_occurrence_index` +// in game/zones.rs), or merge.rs:689 (CR 730.3c split) — is NEVER EXECUTED +// by this fixture and will NOT turn it red. +// +// WHY IT IS NONETHELESS LOAD-BEARING: the `TurnRecordIndexMismatch` guard +// at zones.rs:946-953 validates the COMMAND's `turn_zone_change_index` +// field against the allocator, never the RECORD's copy — and the record's +// copy is what becomes the event (zones.rs:1199 -> :1362). So the emitted +// index has no production guard on any path. +// `GameObject::snapshot_for_zone_change` seeds `turn_zone_change_index: 0` +// (game_object.rs:1983), so "an emit site forgot to stamp it" is the +// natural failure mode this row detects, not a contrived one. +// +// It deliberately does NOT pin the equality link: raw field reads bypass +// `PartialEq`. That link is pinned at the authority layer by +// `occurrence_exact_witness_consumes_the_occurrence_its_witness_names`. +// +// NO PRIORITY PASS — DELIBERATE, AND THE EXCEPTION THE HARNESS NOTE ABOVE +// NAMES. This row asserts on the PRE-DRAIN parked queue, not on a +// post-drain effect, so `assert_observers_were_parked` (which requires a +// NON-EMPTY `deferred_triggers`) is its positive reach-guard. Passing +// priority first would run the drain, empty the queue, and make every +// assertion below vacuous — the exact opposite of what the harness note +// protects against for post-drain rows. `fetch_with_no_legal_target_parks_nothing` +// already asserts on `deferred_triggers` the same way. +// --------------------------------------------------------------------------- + +#[test] +fn parked_delivery_records_carry_distinct_occurrence_indices() { + let db = shared_card_db().expect("integration card fixture must load"); + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.add_real_card(P0, "Kazandu Mammoth", Zone::Battlefield, db); + // Harrow is an Instant with "sacrifice a land" as an additional cost. + let harrow = scenario.add_real_card(P0, "Harrow", Zone::Hand, db); + let spare = scenario.add_real_card(P0, "Mountain", Zone::Battlefield, db); + let forest = scenario.add_real_card(P0, "Forest", Zone::Library, db); + let island = scenario.add_real_card(P0, "Island", Zone::Library, db); + + let mut runner = scenario.build(); + engine::game::rehydrate_game_from_card_db(runner.state_mut(), db); + add_mana(&mut runner, 1, 0, 2); + + runner + .cast(harrow) + .sacrifice_with(&[spare]) + .search_first_legal() + .resolve(); + + // Reach-guards: the delivery really happened, and the observers really are + // sitting in the PRE-drain parked queue. + assert_eq!( + runner.state().objects[&forest].zone, + Zone::Battlefield, + "both basics must have been delivered" + ); + assert_eq!( + runner.state().objects[&island].zone, + Zone::Battlefield, + "both basics must have been delivered" + ); + assert_observers_were_parked(&runner); + + // Every `ZoneChanged` the parked contexts carry, paired with the occurrence + // index its record was stamped with. `GameEvent` is spelled out rather than + // imported so this row adds no import to the module. + let parked: Vec<(ObjectId, usize)> = runner + .state() + .deferred_triggers + .iter() + .flat_map(|context| context.trigger_events.iter()) + .filter_map(|event| match event { + engine::types::events::GameEvent::ZoneChanged { + object_id, record, .. + } => Some((*object_id, record.turn_zone_change_index)), + _ => None, + }) + .collect(); + + let indices_for = |land: ObjectId| -> Vec { + parked + .iter() + .filter(|(id, _)| *id == land) + .map(|(_, index)| *index) + .collect() + }; + let forest_indices = indices_for(forest); + let island_indices = indices_for(island); + + // Third reach-guard: a record was located for EACH land, so the assertions + // below cannot pass vacuously on an empty iterator. + assert!( + !forest_indices.is_empty(), + "the parked queue must carry a ZoneChanged for the delivered Forest" + ); + assert!( + !island_indices.is_empty(), + "the parked queue must carry a ZoneChanged for the delivered Island" + ); + assert!( + forest_indices + .iter() + .all(|index| *index == forest_indices[0]) + && island_indices + .iter() + .all(|index| *index == island_indices[0]), + "N observers of ONE occurrence contribute N copies of the SAME value, so \ + every copy for one object must carry the same occurrence index" + ); + + // CR 603.2c sentence 2: two lands entering is TWO occurrences, and + // `restrictions::record_zone_change` allocates a distinct index for each. + assert_ne!( + forest_indices[0], island_indices[0], + "CR 400.7 + CR 603.2c: two distinct occurrences in ONE park slice must \ + carry DISTINCT turn_zone_change_index values, or the queued-context \ + witness could cross-consume them" + ); + + // Each emitted event's index must agree with the ledger row the allocator + // wrote for it — the two-storage contract. An emit site that ships the `0` + // placeholder instead of the allocator's value breaks this. + for (land, index) in [(forest, forest_indices[0]), (island, island_indices[0])] { + let record = runner + .state() + .zone_changes_this_turn + .get(index) + .unwrap_or_else(|| panic!("the emitted index {index} must address a real ledger row")); + assert_eq!( + record.object_id, land, + "the index carried by the emitted event must address THAT object's \ + own ledger row (zone_changes_this_turn[{index}])" + ); + } +}