diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index 08bcde4e37..3b7281ae98 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -3027,16 +3027,31 @@ pub fn candidate_actions_broad_with_probe( ) }) .collect(), - // CR 732.2a: an accepted object-growth loop collapses into a finite count - // the controller names. `max` is the engine's 1000-wide loop bound; the AI - // never wants a huge pile, so offer only the default N=1 — this bounds - // search regardless of the display cap. Must precede the general arm below. + // CR 732.2a: an accepted object-growth loop collapses into a finite count the + // controller names. Offer only N=1 rather than the whole `min..=max` range: the AI + // never wants a huge pile, and one candidate bounds search regardless of how wide + // the prompt is. Must precede the general arm below. + // + // CR 732.2c: `max` is NOT a fixed 1000 — it is the count the table accepted + // (`pending_materialization_count`), so it can legitimately be 0 (a shortcut + // accepted at `Fixed(0)`). Clamp, or the generator's sole candidate is rejected by + // the reducer's `amount > max` guard and the AI has no legal action at this prompt. + // + // Unreachable for the AI *today* and deliberately kept correct anyway: the AI's own + // `WaitingFor::LoopShortcut` arm below only ever proposes `IterationCount:: + // UntilLethal`, which routes to `apply_until_lethal_shortcut` and never reaches + // `materialize_fixed_shortcut` — the only path that registers a stash. So no + // AI-declared shortcut currently produces this prompt; a human-declared one in a + // mixed game, or a future bounded AI offer, does. WaitingFor::PayAmountChoice { player, resource: PayableResource::LoopCollapse { .. }, + max, .. } => vec![candidate( - GameAction::SubmitPayAmount { amount: 1 }, + GameAction::SubmitPayAmount { + amount: (*max).min(1), + }, TacticalClass::Selection, Some(*player), )], diff --git a/crates/engine/src/analysis/resource.rs b/crates/engine/src/analysis/resource.rs index dfb2a79036..0618f6a823 100644 --- a/crates/engine/src/analysis/resource.rs +++ b/crates/engine/src/analysis/resource.rs @@ -3311,6 +3311,65 @@ fn fire_time_conditions_read_projected_resource_scoped( false } +/// CR 113.6 (CR 113.6k): every trigger definition that FUNCTIONS in its source's current zone. +/// The shared board walk for the axis firewalls — `board_has_event_observer` and +/// [`board_has_functioning_etb_trigger`] both ask "which event does it react to?" of the same +/// set, so the zone gate has one authority. +fn functioning_board_trigger_defs( + state: &GameState, +) -> impl Iterator { + state.objects.values().flat_map(move |obj| { + crate::game::functioning_abilities::active_trigger_definitions(state, obj) + .map(|active| active.definition) + .filter(move |def| { + crate::game::triggers::trigger_definition_functions_in_zone(def, obj.zone) + }) + }) +} + +/// CR 603.6a: does ANY functioning board trigger fire on a battlefield entry? +/// +/// The route firewall for a batched collapse that MINTS TOKENS: each minted token is a real +/// CR 603.6a entry, so every board ETB trigger fires for real on top of whatever the batched +/// arithmetic already applied. Measured on the Sprout Swarm 4p dump: the batched +/// `[Tokens, Life { per_cycle_delta: 1 }]` pair took P0 from 546 to 596 at the collapse, and +/// draining the 50 real token-ETB triggers paid the SAME life again, ending at 646. Routing to +/// the concrete replay makes the real ETB triggers the ONLY source, which is what the board does. +/// +/// SHAPE-AGNOSTIC by construction. An earlier form asked whether the trigger's effect chain was +/// an `Effect::GainLife`, which is under-approximate: life reaches `apply_life_gain` from four +/// resolvers (`effects/life.rs`, `effects/double.rs`, `effects/exchange_life.rs`, and +/// `effects/deal_damage.rs`'s CR 702.15b lifelink leg), so a Terror-of-the-Peaks-shaped board — +/// an ETB damage trigger on a permanent with lifelink — grows a genuinely ETB-sourced life axis +/// that no `Effect`-shape test can see. Asking only "is there a functioning ETB trigger" cannot +/// miss a life source. +/// +/// This predicate and the effect-shape test it replaced are INCOMPARABLE, not nested — dropping the +/// `Effect::GainLife` conjunct is strictly LOOSER on effect shape (any ETB trigger counts, not just +/// a life-gaining one). What narrows the CALLER is a different axis: it pairs this with +/// `token_profile.is_some()`, so only a collapse that MINTS the entries can route here and a +/// token-less loop never does. Looser on shape, narrower on axis; neither side contains the other. +/// +/// Distinct from [`life_growth_is_observed`], which asks whether a LUMP gain would miscount an +/// observer. Here the batched arithmetic is right and the double-apply comes from the collapse +/// itself. Deliberately NOT folded into `life_growth_is_observed`: that predicate also gates the +/// offer firewall, where this shape is not an observation. Deliberately NOT a +/// registration-cancelling suppressor either — the axis can be MIXED-cause (an ETB rider plus a +/// drain), and the batched `Life` registration is per-player, so dropping it would under-apply +/// the non-ETB half and silence the wrong beneficiary. +/// +/// A sound OVER-approximation in the same idiom as its siblings: a true result routes to the +/// discrete N-cycle driver, which is always correct (only slower). +pub(crate) fn board_has_functioning_etb_trigger(state: &GameState) -> bool { + use crate::types::triggers::TriggerEventKey; + functioning_board_trigger_defs(state).any(|def| { + crate::game::trigger_index::keys_from_trigger_def(def) + .0 + .iter() + .any(|key| matches!(key, TriggerEventKey::EnterBattlefield(_))) + }) +} + /// CR 732.2a / CR 603.4 / CR 614.1: does any battlefield/command-FUNCTIONING trigger fire on /// `trig_key`, or any active battlefield/command replacement replace `repl_event`? The shared /// per-event observer scan for the axis-specific firewalls, classifying triggers via the same @@ -3320,20 +3379,12 @@ fn board_has_event_observer( trig_key: crate::types::triggers::TriggerEventKey, repl_event: ReplacementEvent, ) -> bool { - for obj in state.objects.values() { - for active in crate::game::functioning_abilities::active_trigger_definitions(state, obj) { - let def = active.definition; - // CR 603.4 / CR 113.6: only a trigger that FUNCTIONS in its source's current zone. - if !crate::game::triggers::trigger_definition_functions_in_zone(def, obj.zone) { - continue; - } - if crate::game::trigger_index::keys_from_trigger_def(def) - .0 - .contains(&trig_key) - { - return true; - } - } + if functioning_board_trigger_defs(state).any(|def| { + crate::game::trigger_index::keys_from_trigger_def(def) + .0 + .contains(&trig_key) + }) { + return true; } for (_, obj, def) in crate::game::functioning_abilities::active_replacements(state) { // CR 614.1 / CR 113.6: `active_replacements` is all-zones; a life/counter-event diff --git a/crates/engine/src/game/derived_views.rs b/crates/engine/src/game/derived_views.rs index 36f680e7d9..4a1ff748e6 100644 --- a/crates/engine/src/game/derived_views.rs +++ b/crates/engine/src/game/derived_views.rs @@ -793,6 +793,54 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews views.turn_order = turn_order; views.viewer_turn_number = viewer_turn_number; + // CR 732.2c: once every player accepted the shortcut it IS taken, at the finite N the + // proposal named — so an axis with a scheduled collapse is already BOUNDED and must not + // render `∞` anywhere beside the finite totals it is growing. ONE authority + // (`GameState::scheduled_collapse_axes`), THREE consumers below: the per-axis resource + // badge rows, the ∞ object pile, and the ∞ counter pills. The gate is computed here, + // once per controller, precisely so no surface can re-derive it and drift — a HUD that + // hides the resource badge while a card group still shows ∞ is internally inconsistent. + // + // Filter the PROJECTION, never the store: `unbounded_resources` + + // `unbounded_loop_enablers` stay in CR 104.4b / CR 110.1 lockstep until the CR 500.5 + // boundary applies the growth, which is what keeps `zones::apply_zone_exit_cleanup`'s + // defuse armed in the meantime. + // + // FAIL-CLOSED: only axes a registered materialization really collapses are hidden, so an + // unregistered ∞ axis (a mana engine registers none) still renders. + // + // CLASS RULE for the hide-set: hide only axes whose growth is still DEFERRED; never hide an + // axis that is ALREADY MATERIALIZED and spendable right now. `Tokens` / `Counters` / `Life` + // are deferred by construction — the growth is not on the board until the boundary applies + // it — so an `∞` for them is exactly the lie this gate kills. A `DriveSequence` is the one + // item that does NOT name a deferral: its `collapsed_axes` is `proposal.unbounded`, i.e. + // EVERY axis of the whole loop, and a `Mana(_)` among them is live *now* — + // `mana_payment::refill_infinite_mana` tops that controller's pool back to + // `INFINITE_MANA_PER_TYPE` off the STORE (which this projection deliberately never touches) + // after every action. Hiding it would show no `∞` beside a pool that keeps refilling: the + // same internally-inconsistent HUD as an `∞ Life` badge on a finite life total, inverted. + // CR 500.5: `turns::drain_pending_phase_transition_progress` clears the mana axis when the + // step/phase ends, and THAT is what legitimately ends the badge — not this projection. + // + // `Mana(_)` is today's only already-materialized axis: census of production readers of + // `GameState::unbounded_resources` (`refill_infinite_mana`, the CR 500.5 clear in `turns`, + // and this projection) shows it is the only axis any reader turns back into a spendable + // resource. Widen this `retain` only for an axis that gains the same property. + let scheduled_collapse: BTreeMap> = state + .pending_unbounded_materialization + .iter() + .map(|(&controller, items)| { + let mut axes = state.scheduled_collapse_axes(items); + axes.retain(|a| !matches!(a, ResourceAxis::Mana(_))); + (controller, axes) + }) + .collect(); + let collapse_scheduled = |controller: PlayerId, axis: &ResourceAxis| -> bool { + scheduled_collapse + .get(&controller) + .is_some_and(|axes| axes.contains(axis)) + }; + // CR 732.2a: project every unbounded-resource loop into per-(player, axis) // `∞` HUD rows. Runs in every format (placed BEFORE the Commander // short-circuit below) and stays empty (field omitted) when no loop is @@ -800,6 +848,9 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews // (`attribution_player`); the frontend only formats each axis to a family. for (&controller, axes) in &state.unbounded_resources { for &axis in axes { + if collapse_scheduled(controller, &axis) { + continue; + } views.unbounded_resources.push(UnboundedResourceView { player: attribution_player(axis, controller), axis, @@ -811,7 +862,15 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews // winning controller's tapped fodder-class members — dropping any that have since // left the battlefield (stale member). Public board state (no viewer filtering); // the frontend renders `∞` on any group whose members are all pile members. - for ids in state.unbounded_loop_pile.values() { + // + // CR 732.2c: same gate, same authority as the badge rows above. The pile IS the + // `TokensCreated` axis — `clear_collapsed_materializations` drops it on exactly that + // axis collapsing — so once that axis has a scheduled finite mint the group must stop + // rendering ∞ in lockstep with its resource badge. + for (&controller, ids) in &state.unbounded_loop_pile { + if collapse_scheduled(controller, &ResourceAxis::TokensCreated) { + continue; + } for id in ids { if state.battlefield.contains(id) { views.unbounded_pile.push(*id); @@ -825,15 +884,28 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews // the battlefield (stale member). Display-only per-object channel mirroring // `unbounded_pile`; the frontend renders `∞` (not `×N`) on any counter pill whose // type is in this set. Runs in every format (BEFORE the Commander short-circuit). - for targets in state.unbounded_counter_targets.values() { + // + // CR 732.2c: same gate, same authority. A pill's axis is derived by the SHARED + // `(object, counter) -> ResourceAxis` mapping the collapse itself uses + // (`collapsed_counter_axis`), so a pill can never disagree with the badge it mirrors. + // The class lookup is LIVE by design here: this loop only emits pills for bearers still + // on the battlefield, so the object is present and its class is current. + for (&controller, targets) in &state.unbounded_counter_targets { for (id, ct) in targets { - if state.battlefield.contains(id) { - views - .unbounded_counters - .entry(*id) - .or_default() - .push(ct.clone()); + if !state.battlefield.contains(id) { + continue; } + if collapse_scheduled( + controller, + &crate::types::game_state::collapsed_counter_axis(state, *id, ct), + ) { + continue; + } + views + .unbounded_counters + .entry(*id) + .or_default() + .push(ct.clone()); } } diff --git a/crates/engine/src/game/effects/incubate.rs b/crates/engine/src/game/effects/incubate.rs index ac56d8d391..78c6e7049c 100644 --- a/crates/engine/src/game/effects/incubate.rs +++ b/crates/engine/src/game/effects/incubate.rs @@ -96,7 +96,8 @@ pub fn resolve( // token (escalates to a full pass if it sources effects, carries // counters, etc.). crate::game::layers::mark_layers_entered(state, obj_id); - crate::game::restrictions::record_battlefield_entry(state, obj_id); + // CR 403.3 battlefield-entry bookkeeping is done by `record_zone_change` below — + // recording it here too would double-count `battlefield_entries_this_turn`. crate::game::restrictions::record_token_created(state, obj_id); // CR 603.6a: The Incubator token enters the battlefield as a zone change @@ -109,14 +110,21 @@ pub fn resolve( // triggers (issue #4238). Mirrors // `token.rs::apply_create_token_after_replacement_with_created_ids` and // `conjure.rs`'s identical fix for the same bug class. - let zone_change_record = state + // + // CR 400.7 + CR 603.2c: route the record through `restrictions::record_zone_change` — the + // single authority that assigns this turn's zone-change index — and write the assigned index + // back onto the emitted record. `snapshot_for_zone_change` leaves it at its `0` placeholder, + // and the batched zone-change replay guard (`triggers.rs`) dedups on + // `(definition_ref, turn_zone_change_index)` read off the EVENT, so an unrouted record aliases + // this Incubator onto occurrence `0` and a `batched: true` ETB trigger that already fired for + // another entry this turn is swallowed. Same shape as `merge.rs` and `token.rs`. + let mut zone_change_record = state .objects .get(&obj_id) .expect("incubator token was just created") .snapshot_for_zone_change(obj_id, None, Zone::Battlefield); - state - .zone_changes_this_turn - .push_back(zone_change_record.clone()); + zone_change_record.turn_zone_change_index = + crate::game::restrictions::record_zone_change(state, zone_change_record.clone()); events.push(GameEvent::ZoneChanged { object_id: obj_id, from: None, diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index 77f296bd5b..c23de1d81e 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -950,7 +950,8 @@ pub(crate) fn apply_create_token_after_replacement_with_created_ids( // continuous effect / carries counters / etc., or if any active effect // reads board population. crate::game::layers::mark_layers_entered(state, obj_id); - crate::game::restrictions::record_battlefield_entry(state, obj_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. crate::game::restrictions::record_token_created(state, obj_id); // CR 303.4 + CR 303.7: A Role/Aura token created "attached to" a host @@ -980,25 +981,16 @@ pub(crate) fn apply_create_token_after_replacement_with_created_ids( // Battlefield }` so every ETB trigger matcher (Elvish Vanguard, Soul // Warden, Panharmonicon) fires for tokens through the same code path // used for normal battlefield entry. The accompanying `TokenCreated` - // event is preserved below for token-specific consumers (animation, - // logging, `LastCreated` target filters). - let zone_change_record = state - .objects - .get(&obj_id) - .expect("token just created") - .snapshot_for_zone_change(obj_id, None, Zone::Battlefield); - events.push(GameEvent::ZoneChanged { - object_id: obj_id, - from: None, - to: Zone::Battlefield, - record: Box::new(zone_change_record), - }); - - events.push(GameEvent::TokenCreated { - object_id: obj_id, - name: spec.characteristics.display_name.clone(), - source_id: spec.source_id, - }); + // event is emitted for token-specific consumers (animation, logging, + // `LastCreated` target filters). Single authority for both, and for the + // CR 400.7 zone-change index the batched replay guard keys on. + push_committed_token_entry_events( + state, + obj_id, + spec.characteristics.display_name.clone(), + spec.source_id, + events, + ); // CR 603.7: Tokens with a limited duration get a delayed sacrifice trigger. // Used by Mobilize and similar keywords that create temporary attacking tokens. @@ -1122,6 +1114,60 @@ pub fn apply_resolved_token_creation( state.objects.insert(object_id, object); // allow-raw-zone: replay materializes a token birth, which has no from-zone move (CR 111.1 + CR 614.12). zones::add_to_zone(state, object_id, Zone::Battlefield, command.owner); + // CR 111.3 + CR 111.10: a token's abilities come from the creating effect + // and the predefined/catalog tables, NOT from the body the command carries, + // so the body alone materializes a Treasure with no "{T}, Sacrifice this + // token: Add one mana of any color." Both live paths inject after + // materializing and before their entry snapshot (Spec: this file, above the + // `push_committed_token_entry_events` call; Copy: `token_copy.rs`'s + // `finalize_copied_token` + `inject_predefined_token_abilities`), so replay + // does the same here, per body variant. The dispatch mirrors + // `finalize_committed_liminal_token_entry_from_action`'s + // `LiminalTokenAbilityInjection` match arm-for-arm — a blanket + // `inject_resolved_token_abilities` would be wrong for the Copy body, whose + // live authority uses the predefined-only injector after + // `finalize_copied_token`'s CR 707.2 cast-only strip. + match &command.body { + ResolvedTokenBody::Copy { copy, .. } => { + super::token_copy::finalize_copied_token(state, copy.source_id, object_id); + inject_predefined_token_abilities(state, object_id); + } + ResolvedTokenBody::Spec { .. } => inject_resolved_token_abilities(state, object_id), + } + // CR 400.7 + CR 403.3: the resolve path records the birth through + // `restrictions::record_zone_change` (`push_committed_token_entry_events`), + // which appends to this turn's zone-change ledger and assigns the entry's + // index. Replay must record the same entry: the ledger length IS the index + // allocator, so a birth that records nothing leaves every later replayed + // zone change one short of its recorded `turn_zone_change_index` and + // `apply_resolved_zone_change` fails closed on `TurnRecordIndexMismatch`. + // The record is reconstructed from the materialized object rather than + // carried on the command: it is a projection of state this applier has + // already installed. + // + // KNOWN CEILING — two record-visible classes the reconstruction cannot + // reproduce, both because the LIVE journal point (`record_token_creation`, + // in the resolve path above) runs BEFORE the live mutations and before the + // live snapshot, so no call site inside THIS applier can close them; they + // would need the live journal-record point moved: + // (i) `spec.enter_with_counters` — the live snapshot's + // `trigger_source_context.lki.counters` (and P/T, if the counter's + // layer bump landed first) carry the entry counters. Counters replay + // through their own `ObjectCounter` command, journaled AFTER this + // birth, so the reconstructed record here has none. + // (ii) `spec.attach_to` (Role/Aura tokens) — `record.attached_to`. Same + // reason: attachment replays through the Attachment family. + // A third class, predefined/catalog ability injection contributing + // `record.trigger_definitions`, IS closed — by the injection dispatch + // directly above, which runs before this snapshot exactly as the live paths + // do. Storing the live record on the command would not close (i) or (ii) + // either, for the same ordering reason, so it was not done. + let entry_record = state + .objects + .get(&object_id) + .expect("the token was materialized above") + .snapshot_for_zone_change(object_id, None, Zone::Battlefield); + crate::game::restrictions::record_zone_change(state, entry_record); // CR 111.1: replay must not hand the same id out again to a later allocation. state.next_object_id = state.next_object_id.max(command.resulting_next_object_id); // CR 613.7d: the birth drew an entry timestamp alongside the object id, and @@ -1475,15 +1521,6 @@ pub(crate) fn continue_liminal_copy_token_batch_after_counter_pause( ) } -pub(crate) fn commit_liminal_token_entry_with_event_emission( - state: &mut GameState, - event: ProposedEvent, - events: &mut Vec, - entry_events: TokenEntryEventEmission, -) -> bool { - commit_liminal_token_entry_with_post_actions(state, event, events, entry_events, Vec::new()) -} - pub(crate) fn commit_liminal_token_entry_with_post_actions( state: &mut GameState, event: ProposedEvent, @@ -1662,7 +1699,8 @@ pub(crate) fn finalize_committed_liminal_token_entry_from_action( } } crate::game::layers::mark_layers_entered(state, object_id); - crate::game::restrictions::record_battlefield_entry(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. crate::game::restrictions::record_token_created(state, object_id); if enters_attacking { @@ -1710,15 +1748,33 @@ pub(crate) fn finalize_committed_liminal_token_entry_from_action( true } +/// CR 603.6a + CR 400.7: emit a token's battlefield-entry events, recording the 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. +/// +/// The index matters: `GameObject::snapshot_for_zone_change` leaves +/// `turn_zone_change_index` at its `0` placeholder for the recorder to overwrite, and the +/// CR 603.2c batched zone-change replay guard (`triggers.rs`) dedups on +/// `(definition_ref, turn_zone_change_index)`. A token entry that never reached the recorder +/// therefore shipped index `0` on the wire, so a SECOND same-turn token batch collided with the +/// first and its batched trigger fire was swallowed. +/// +/// Callers must NOT also call `record_battlefield_entry` — `record_zone_change` does it, and a +/// second call double-counts `battlefield_entries_this_turn`. pub(crate) fn push_committed_token_entry_events( - state: &GameState, + state: &mut GameState, object_id: ObjectId, name: String, source_id: ObjectId, events: &mut Vec, ) { - if let Some(token) = state.objects.get(&object_id) { - let zone_change_record = token.snapshot_for_zone_change(object_id, None, Zone::Battlefield); + let entry = 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()); events.push(GameEvent::ZoneChanged { object_id, from: None, diff --git a/crates/engine/src/game/effects/token_copy.rs b/crates/engine/src/game/effects/token_copy.rs index 16f69dc916..512caa7632 100644 --- a/crates/engine/src/game/effects/token_copy.rs +++ b/crates/engine/src/game/effects/token_copy.rs @@ -835,6 +835,14 @@ pub(crate) fn apply_copy_token_after_replacement_with_created_ids( } // CR 111.10: Predefined token abilities for known subtypes (Treasure, Food, etc.). + // + // PAIRED WITH THE REPLAY ARM at `token::apply_resolved_token_creation`'s + // `ResolvedTokenBody::Copy` match arm, which must call the same + // predefined-only injector. Unlike the liminal path — where one + // `copy_resume.is_some()` predicate drives both the live and journaled + // matches, so a divergence fails to compile — this branch is coupled to + // replay by convention only. Switching it to the catalog-wide + // `inject_resolved_token_abilities` would silently desync replay from live. super::token::inject_predefined_token_abilities(state, token_id); // Battlefield entry of a copy token: request an incremental re-derive // for just this token. `flush_layers` escalates to a full pass when diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index ca01b30572..0ae92d0999 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -1598,8 +1598,61 @@ fn materialize_fixed_shortcut( // activation period) is the routing signal; the `seq` rides `state.last_loop_action_sequence` // (carried on the clone since the offer). The drain path below is byte-identical for every // other loop. + // + // CR 732.2c: record the count the shortcut was ACCEPTED at. "Once the last player has + // either accepted or shortened the shortcut proposal, the shortcut is taken" — its ending + // point is fixed at N, so the CR 500.5 boundary collapse prompt may only offer `0..=N`. + // Re-asking with a wider range would let the controller take a longer sequence than the + // one the table agreed to. + // + // STASH-GATED, and it must stay that way. A bound with no deferred materialization to + // bound is unclearable — all three clears (`take_pending_materialization`, + // `clear_collapsed_materializations`, `clear_unbounded_loop`) are keyed on the stash, and + // the field is `#[serde(default)]`-persistent — so it would outlive its accept and + // silently cap the NEXT accept's agreed count forever (a mana accept at `Fixed(1)` + // capping a later, unanimously agreed `Fixed(500)` object-growth collapse at 1). Only the + // object-growth route below registers anything, and even it registers CONDITIONALLY: a + // mana engine grows no token/counter/life axis and registers nothing at all. So the gate + // is a measured STASH-GREW check taken ACROSS the call — testing before it would be + // unconditionally false, since that call is what registers. Length-delta rather than + // `contains_key`, so a non-registering accept cannot `min`-shrink a bound that an + // earlier, larger, genuinely-registering accept owns. + // + // MINIMUM, not overwrite: `register_pending_materialization` APPENDS, so a controller who + // accepts twice before the CR 500.5 boundary owns ONE stash holding both accepts' items, + // and the boundary applies ONE submitted amount to every item in it. Overwriting the bound + // would let a later `Fixed(1000)` accept re-scale an earlier `Fixed(1)` accept's items + // 1000×, materializing growth the table never agreed to. The minimum is the only bound + // that no accept in the stash can exceed. Conservative on purpose: the later accept is + // UNDER-delivered (its agreed 1000 caps at the earlier 1) rather than the earlier one + // being over-delivered — divergence from the table's agreement in the safe direction. + // + // The exact fix is a per-accept bound, deferred for its WIRE-COMPATIBILITY COST — not + // because it is unrepresentable. A bound carried ON each item, or the accept-grouped + // `Vec` this is tracked as, survives the boundary's + // pause-safety `sort_by_key` fine: the sort moves each payload along with its key. What + // it costs is a shape change to `pending_unbounded_materialization`, a SAVED-GAME field, + // plus the `cr733/authority_matrix` census fixture that pins its composition. (Only a + // PARALLEL per-item bound VECTOR would be positionally unsyncable across that sort; that + // is the shape being rejected here, not per-accept binding as such.) if !state.last_loop_action_sequence.is_empty() { + let stashed_before = state + .pending_unbounded_materialization + .get(&proposal.proposer) + .map_or(0, Vec::len); materialize_object_growth_shortcut(state, result, proposal); + if state + .pending_unbounded_materialization + .get(&proposal.proposer) + .map_or(0, Vec::len) + > stashed_before + { + state + .pending_materialization_count + .entry(proposal.proposer) + .and_modify(|bound| *bound = (*bound).min(n)) + .or_insert(n); + } return; } @@ -2724,20 +2777,38 @@ fn materialize_object_growth_shortcut( !growths.is_empty() && crate::analysis::resource::counter_growth_is_observed(state); let life_observed = !life.is_empty() && crate::analysis::resource::life_growth_is_observed(state); - if counter_observed || life_observed { + // CR 732.2a + CR 603.6a: a life axis the board RE-EARNS on a battlefield entry also belongs on + // the concrete replay. Not an observedness question (the batched arithmetic is right) but a + // ROUTE one: the batched `Tokens` collapse mints N real tokens whose real CR 603.6a entries + // re-earn the same life the batched `Life` already applied, so the accept pays twice. + // + // The conjuncts are AXIS-shaped, never effect-shaped: a life axis grew (`!life.is_empty()`), + // the collapse will mint the tokens that re-earn it (`token_profile.is_some()` — a mana-only + // collapse mints nothing, so nothing re-fires), and the board has an entry trigger at all. + // Testing the trigger's EFFECT for `GainLife` would be under-approximate: life reaches + // `apply_life_gain` from four resolvers, including CR 702.15b lifelink on an ETB damage + // trigger (the Terror of the Peaks shape), which no effect-shape test can see. + let life_etb_sourced = !life.is_empty() + && token_profile.is_some() + && crate::analysis::resource::board_has_functioning_etb_trigger(state); + // M5: hoisted out of the branch so an empty period can never register NOTHING — a route + // flipped to the replay falls back to the batched arm instead of silently dropping the whole + // materialization. (Unreachable today: `growths`/`life` are derived from the same + // `drive_one_period_frames`, which returns `None` on an empty sequence, so every route + // predicate is already false there. Kept explicit so a future route conjunct cannot + // reintroduce the hole.) + let sequence = state.last_loop_action_sequence.clone(); + if (counter_observed || life_observed || life_etb_sourced) && !sequence.is_empty() { // CR 732.2a: OBSERVED batchable growth — one DriveSequence collapses the WHOLE loop (all // axes); replaying the captured sequence recreates every per-cycle effect honoring // observers. Do NOT also register batched items (the routes are exclusive per accept). - let sequence = state.last_loop_action_sequence.clone(); - if !sequence.is_empty() { - state.register_pending_materialization( - proposal.proposer, - crate::types::game_state::PersistentAxisMaterialization::DriveSequence { - sequence, - collapsed_axes: proposal.unbounded.clone(), - }, - ); - } + state.register_pending_materialization( + proposal.proposer, + crate::types::game_state::PersistentAxisMaterialization::DriveSequence { + sequence, + collapsed_axes: proposal.unbounded.clone(), + }, + ); } else { // UNOBSERVED fast path — register each grown persistent axis for the batched N×δ collapse. if let Some(profile) = token_profile { diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index 2d529a37e4..0d1166724a 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -1774,11 +1774,35 @@ pub(super) fn handle_copy_target_choice( })); } } - if !super::effects::token::commit_liminal_token_entry_with_event_emission( + // 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, + }, + ) + .into_iter() + .collect(); + if !super::effects::token::commit_liminal_token_entry_with_post_actions( state, resume_event, events, TokenEntryEventEmission::Suppress, + paused_entry_emit, ) { return Ok(state.waiting_for.clone()); } diff --git a/crates/engine/src/game/restrictions.rs b/crates/engine/src/game/restrictions.rs index bc74be690f..eb39cd7cc2 100644 --- a/crates/engine/src/game/restrictions.rs +++ b/crates/engine/src/game/restrictions.rs @@ -379,9 +379,9 @@ pub(crate) fn battlefield_entry_record_for( subtypes: obj.card_types.subtypes.clone(), supertypes: obj.card_types.supertypes.clone(), colors: obj.color.clone(), - // CR 403.3: snapshot the object's keywords at entry time. This is the - // printed/base + counter-granted keyword set (pre-layer; see the field doc - // on BattlefieldEntryRecord.keywords for the documented Layer-6 limitation). + // CR 403.3: snapshot the object's keywords at entry time — whatever the layer + // state is at the caller's record point (pre-flush for most entries, post-flush + // for an attached token). See the field doc on `BattlefieldEntryRecord.keywords`. keywords: obj.keywords.clone(), controller: obj.controller, } diff --git a/crates/engine/src/game/turns.rs b/crates/engine/src/game/turns.rs index 4d14670051..3bfa98b926 100644 --- a/crates/engine/src/game/turns.rs +++ b/crates/engine/src/game/turns.rs @@ -453,12 +453,33 @@ pub(super) fn drain_pending_phase_transition_progress( state.waiting_for = WaitingFor::PayAmountChoice { player: controller, resource: PayableResource::LoopCollapse { axis }, - // CR 732.2a: any finite count (incl. 0 — a legal collapse-to- - // nothing; the ∞ still ends). `max` reuses the engine's loop - // safety bound; the AI branch offers only N=1 so the wide range - // never enters search. Tapped tokens carry no lethal driver. + // ENGINE TOLERANCE, NOT A RULES ENTITLEMENT — no CR licenses this, and + // none is cited: CR 732.2c says the shortcut "is taken" at the accepted + // count and the game simply advances to that ending point, so there is no + // re-choice and strictly `min` and `max` would both be the accepted N. + // `min: 0` is unchanged from BASE and kept as a deliberate NEVER-OVER- + // DELIVER fail-safe, not as wedge-avoidance — `min == max == N` is already + // a single legal answer, so a narrow range could not wedge the boundary. + // What 0 buys is a floor the engine can always honor: collapsing to + // nothing is strictly less than what the table agreed to, so no batching + // or replay imprecision below it can ever materialize growth nobody + // accepted. Tapped tokens carry no lethal driver, so 0 is also never a + // hidden win-denial. min: 0, - max: crate::game::engine::MAX_SHORTCUT_CYCLES, + // CR 732.2c: the shortcut was TAKEN at the count every player + // accepted, so the collapse may not exceed it — re-asking with the + // engine-wide safety bound would let the controller run a longer + // sequence than the one the table agreed to. `MAX_SHORTCUT_CYCLES` + // remains the defensive fallback for a stash with no recorded bound. + // `materialize_fixed_shortcut` writes the bound in lockstep with the + // registration that creates the stash, so on current code the only + // bound-less stash is one deserialized from a save written before the + // bound was tracked. + max: state + .pending_materialization_count + .get(&controller) + .copied() + .unwrap_or(crate::game::engine::MAX_SHORTCUT_CYCLES), accumulated: 0, source_id: ObjectId(0), pending_mana_ability: None, diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 61acbdc840..918100419e 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -1719,10 +1719,15 @@ pub struct BattlefieldEntryRecord { /// with flying entered this turn") evaluate via the CR 603.10 last-known-state /// against entry-time characteristics (like the existing core_types/colors /// snapshots). KNOWN LIMITATION: this captures the object's keywords at record - /// time, which is BEFORE the layer system re-evaluates (layers are only marked - /// dirty, not recomputed, at zone-change). Printed flyers and keyword-counter / - /// intrinsic flyers are counted; a creature granted flying ONLY by a Layer-6 - /// continuous effect (e.g. an anthem) at the moment it enters is NOT counted. + /// time, which for most entries is BEFORE the layer system re-evaluates (layers + /// are only marked dirty, not recomputed, at zone-change). Printed flyers and + /// keyword-counter / intrinsic flyers are counted; a creature granted flying ONLY + /// by a Layer-6 continuous effect (e.g. an anthem) at the moment it enters is NOT + /// counted. EXCEPTION — a token created attached to a host (Role/Aura tokens): + /// `effects::attach::attach_to` runs `mark_layers_full` + `flush_layers`, and the + /// token path records its entry AFTER the attach, so that sub-path's snapshot IS + /// post-flush and does see Layer-6 grants. Not a defect: the paired + /// `ZoneChangeRecord` has always been taken post-attach, so the two ledgers agree. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub keywords: Vec, pub controller: PlayerId, @@ -3514,6 +3519,64 @@ pub enum PersistentAxisMaterialization { }, } +/// CR 732.2a: the `unbounded_resources` axis a counter of `ct` on `obj_id` backs — mirrors +/// `ResourceVector::snapshot`'s `(CounterClass, ObjectClass)` keying. SINGLE mapping from a +/// counter target to its axis, shared by `scheduled_collapse_axes`, +/// `clear_collapsed_materializations`' surviving-target guard, and the ∞ counter-pill +/// projection in `game::derived_views`. +/// +/// LIVE RE-DERIVATION — DELIBERATE DISPLAY-ONLY TOLERANCE. The class is read from the +/// object as it stands NOW, not snapshotted at accept. `state.objects` retains an object +/// across an ordinary zone change, so the `Other` fallback is reached only when the bearer +/// truly stopped existing, or when its printed types changed under CR 400.7. In that window +/// the derived axis becomes `Counter(_, Other)`, which is not the axis `unbounded_resources` +/// holds ⇒ the pill/badge is NOT hidden and the `∞` renders for a collapse that is still +/// scheduled. +/// +/// CENSUS (`grep -rn 'objects.remove' crates/`, `#[cfg(test)]` bodies excluded) — FOUR +/// production removes, not one. Cease-to-exist (`zones::…replay_resolved_object_cease`, +/// e.g. a token dying to CR 704.5d) is the only one that can erase a counter BEARER; the +/// other three cannot plausibly hold an `unbounded_counter_targets` entry, so the conclusion +/// above survives unchanged: +/// • `game::engine_debug` `DebugAction::Remove` — a debug-only forced deletion, not a +/// CR 400.1 zone event, and not reachable in a real game; +/// • `game::effects::prepare::cleanup_failed_prepared_copy_cast` — an ephemeral synthetic +/// STACK copy discarded after a failed cast, never a battlefield permanent; +/// • `game::casting::handle_cancel_cast` — the same synthetic prepare-copy, rolled back on +/// cancel. +/// Two further removes operate on DISCARDED COMPARISON CLONES, never live state +/// (`game::engine::normalize_recast_frame`, `analysis::resource`'s frame projection). +/// +/// REACHABILITY BY CONSUMER (the fallback is not uniformly live): +/// • the ∞ counter-PILL projection in `game::derived_views` — UNREACHABLE. That loop +/// `continue`s on `!state.battlefield.contains(id)` before calling this, and every +/// production remove above deletes from the zone set before `objects` (or never touches +/// a battlefield permanent at all), so a battlefield id is present in `state.objects`. +/// • `scheduled_collapse_axes` (both its `derived_views` hide-set caller and its +/// `clear_collapsed_materializations` caller) and that function's surviving-target guard +/// — LIVE, and byte-identical to the pre-extraction nested `counter_axis` helper they +/// already used. The hide-set case is exactly the fail-open described above. +/// +/// That fail-open polarity is the SAME one this phase mandates everywhere else (an axis +/// with no registration renders ∞): it can only ever show an extra ∞ for at most the +/// accept→CR-500.5-boundary window, never hide a real one. A snapshot would have to add a +/// serde field to `CounterGrowth` — a saved-game surface change across every construction +/// site — to buy a strictly-display improvement, so it is not taken. Removing a non-present +/// axis stays a harmless no-op, and `clear_collapsed_materializations`' surviving-target +/// guard re-preserves any axis still backed. +pub(crate) fn collapsed_counter_axis( + state: &GameState, + obj_id: ObjectId, + ct: &CounterType, +) -> ResourceAxis { + let oc = state + .objects + .get(&obj_id) + .map(|o| object_class(o.card_types.core_types.as_slice())) + .unwrap_or(ObjectClass::Other); + ResourceAxis::Counter(CounterClass::from_counter_type(ct), oc) +} + /// CR 122.1: one object's per-cycle beneficial counter growth captured at accept, for /// the unobserved batched path. `per_cycle_delta` is multiplied by the controller-named /// N at the boundary and applied via the single counter authority. @@ -13193,6 +13256,25 @@ pub struct GameState { #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub pending_unbounded_materialization: BTreeMap>, + /// CR 732.2c: the finite iteration count each controller's accepted `Fixed(N)` loop + /// shortcut named. Once the last player has accepted, the shortcut IS taken at that + /// N — the boundary collapse prompt (`PayableResource::LoopCollapse`) may therefore + /// only let the controller collapse to at most N, because re-asking for a larger + /// count would be a new game choice the accepted proposal never contained. Written + /// by `materialize_fixed_shortcut` (the ONLY path that can register a deferred + /// materialization — `UntilLethal` never reaches it); read by + /// `turns::drain_pending_phase_transition_progress` as the prompt's `max`; cleared in + /// lockstep with `pending_unbounded_materialization` by `take_pending_materialization`, + /// `clear_collapsed_materializations` and `clear_unbounded_loop`. + /// + /// INTENTIONALLY EXCLUDED from `PartialEq`, `normalize_for_loop`, and + /// `loop_fingerprint` (same unbounded family as `pending_unbounded_materialization`): + /// shortcut annotation, not rules state for equality — a populated live state must + /// still compare equal to the empty ring snapshots or CR 104.4b loop detection yields + /// false negatives. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub pending_materialization_count: BTreeMap, + /// Oracle ids (fallback: object names) of cards whose abilities hit /// `Effect::Unimplemented` at resolution this game. Diagnostics only — /// records *runtime resolution hits*, is game-scoped, and survives zone @@ -17961,6 +18043,7 @@ impl GameState { unbounded_loop_pile: BTreeMap::new(), unbounded_counter_targets: BTreeMap::new(), pending_unbounded_materialization: BTreeMap::new(), + pending_materialization_count: BTreeMap::new(), unimplemented_oracle_ids: BTreeSet::new(), pending_trigger_abandons: Vec::new(), loop_detection: LoopDetectionMode::Off, @@ -18941,9 +19024,60 @@ impl GameState { &mut self, controller: PlayerId, ) -> Option> { + // CR 732.2c: the accepted count is spent with the stash it bounded. + self.pending_materialization_count.remove(&controller); self.pending_unbounded_materialization.remove(&controller) } + /// CR 732.2a: the exact `ResourceAxis` set a deferred materialization stash will + /// collapse at the next CR 500.5 boundary. SINGLE AUTHORITY, with exactly two callers: + /// - `clear_collapsed_materializations` REMOVES these axes once the growth was applied; + /// - `game::derived_views::derive_views` HIDES their `∞` HUD rows while the collapse is + /// merely SCHEDULED. CR 732.2c fixes the finite N at accept, so the axis is already + /// bounded — rendering `∞ Life` beside a finite, growing life total is a lie. + /// + /// Returns the axes UNFILTERED, including any `Mana(_)` a `DriveSequence` names, because the + /// `clear_collapsed_materializations` caller MUST remove that axis at the boundary. The + /// `derive_views` caller drops `Mana(_)` from its hide-set on the way out: mana is already + /// materialized in the pool (`mana_payment::refill_infinite_mana` re-tops it off this very + /// store until CR 500.5 empties it), so it is the one axis that must keep rendering `∞` + /// while a collapse is merely scheduled. See the class rule at that call site. + /// + /// Hiding in the PROJECTION rather than removing from the store is load-bearing: + /// the store keeps `unbounded_resources` and `unbounded_loop_enablers` in CR 104.4b / + /// CR 110.1 lockstep, which is what `zones::apply_zone_exit_cleanup` reads to defuse a + /// capability whose enabler leaves between accept and boundary. + /// + /// FAIL-CLOSED: only an axis some REGISTERED item actually collapses is returned, so an + /// ∞ axis with no registration (a mana engine registers nothing) keeps its badge. + /// EXHAUSTIVE over `PersistentAxisMaterialization` (no wildcard) — a future variant + /// build-breaks here instead of silently leaking a stale `∞`. + pub fn scheduled_collapse_axes( + &self, + items: &[PersistentAxisMaterialization], + ) -> BTreeSet { + let mut axes: BTreeSet = BTreeSet::new(); + for item in items { + match item { + PersistentAxisMaterialization::Tokens(_) => { + axes.insert(ResourceAxis::TokensCreated); + } + PersistentAxisMaterialization::Counters(growths) => { + for g in growths { + axes.insert(collapsed_counter_axis(self, g.object, &g.counter)); + } + } + PersistentAxisMaterialization::Life { player, .. } => { + axes.insert(ResourceAxis::Life(*player)); + } + PersistentAxisMaterialization::DriveSequence { collapsed_axes, .. } => { + axes.extend(collapsed_axes.iter().copied()); + } + } + } + axes + } + /// CR 732.2a: clear every unbounded-resource axis recorded for `controller`. /// Whole-player clear: with the infinite-mana toggle as the only PR-6 producer /// this matches today's all-or-nothing disable; an axis-scoped clear can be @@ -18954,6 +19088,7 @@ impl GameState { self.unbounded_loop_pile.remove(&controller); self.unbounded_counter_targets.remove(&controller); // display-only counter analog of the pile self.pending_unbounded_materialization.remove(&controller); + self.pending_materialization_count.remove(&controller); // CR 732.2c bound, in lockstep } /// CR 732.2a: end the ACTUALLY-collapsed persistent axes for `controller` after @@ -18970,8 +19105,11 @@ impl GameState { /// display targets those axes back (the driven loop collapses whole). /// /// PRESERVES any coexisting NON-collapsed axis (a debug `SetInfiniteMana` `Mana(_)` - /// axis, or a second uncollapsed loop): the collapsed set never contains a `Mana(_)` - /// axis, so mana is preserved by construction. Drops `unbounded_resources[controller]` + /// axis, or a second uncollapsed loop). The batched `Tokens` / `Counters` / `Life` items + /// never name a `Mana(_)` axis, so a batched collapse preserves mana by construction; a + /// `DriveSequence` CAN name one (its `collapsed_axes` is the loop's whole `proposal.unbounded` + /// set) and then removing it here is correct — that loop's mana really did end with it. + /// Drops `unbounded_resources[controller]` /// (and its `unbounded_loop_enablers` entry in CR 104.4b/CR 110.1 lockstep, mirroring /// `clear_unbounded_mana_loop`) only when its axis set becomes empty. Always removes /// the whole `pending_unbounded_materialization` list (owned by `take_` at the submit @@ -18981,49 +19119,30 @@ impl GameState { controller: PlayerId, collapsed: &[PersistentAxisMaterialization], ) { - // The `unbounded_resources` axis a counter of `ct` on `obj_id` backs — mirrors - // `ResourceVector::snapshot`'s `(CounterClass, ObjectClass)` keying. A vanished - // token bearer (CR 400.7) has unknowable class ⇒ `Other` (removing a non-present - // axis is a harmless no-op; the surviving-target guard below re-preserves any that - // is still backed). - fn counter_axis(state: &GameState, obj_id: ObjectId, ct: &CounterType) -> ResourceAxis { - let oc = state - .objects - .get(&obj_id) - .map(|o| object_class(o.card_types.core_types.as_slice())) - .unwrap_or(ObjectClass::Other); - ResourceAxis::Counter(CounterClass::from_counter_type(ct), oc) - } - // --- Phase 1 (reads): what to remove --- - let mut axes_to_remove: BTreeSet = BTreeSet::new(); + // The axis set comes from the SHARED authority the ∞-row projection also reads, so + // "hidden while scheduled" and "removed once applied" can never disagree. + let mut axes_to_remove = self.scheduled_collapse_axes(collapsed); + // The token pile drops exactly when the token axis collapses — true for a batched + // `Tokens` item and for a `DriveSequence` that names `TokensCreated`. + let drop_token_pile = axes_to_remove.contains(&ResourceAxis::TokensCreated); + // Counter DISPLAY-TARGET bookkeeping — a different question from "which axes + // collapse", so it stays local rather than widening the shared authority. + // Exhaustive (no wildcard) for the same build-break guarantee. let mut collapsed_pairs: BTreeSet<(ObjectId, CounterType)> = BTreeSet::new(); let mut driven_axes: BTreeSet = BTreeSet::new(); - let mut drop_token_pile = false; for item in collapsed { match item { - PersistentAxisMaterialization::Tokens(_) => { - axes_to_remove.insert(ResourceAxis::TokensCreated); - drop_token_pile = true; - } PersistentAxisMaterialization::Counters(growths) => { for g in growths { - axes_to_remove.insert(counter_axis(self, g.object, &g.counter)); collapsed_pairs.insert((g.object, g.counter.clone())); } } - PersistentAxisMaterialization::Life { player, .. } => { - axes_to_remove.insert(ResourceAxis::Life(*player)); - } PersistentAxisMaterialization::DriveSequence { collapsed_axes, .. } => { - for ax in collapsed_axes { - axes_to_remove.insert(*ax); - driven_axes.insert(*ax); - if matches!(ax, ResourceAxis::TokensCreated) { - drop_token_pile = true; - } - } + driven_axes.extend(collapsed_axes.iter().copied()); } + PersistentAxisMaterialization::Tokens(_) + | PersistentAxisMaterialization::Life { .. } => {} } } @@ -19037,7 +19156,7 @@ impl GameState { ts.iter() .filter(|(obj, ct)| { !collapsed_pairs.contains(&(*obj, ct.clone())) - && !driven_axes.contains(&counter_axis(self, *obj, ct)) + && !driven_axes.contains(&collapsed_counter_axis(self, *obj, ct)) }) .cloned() .collect() @@ -19045,7 +19164,7 @@ impl GameState { .unwrap_or_default(); let backed: BTreeSet = surviving_targets .iter() - .map(|(obj, ct)| counter_axis(self, *obj, ct)) + .map(|(obj, ct)| collapsed_counter_axis(self, *obj, ct)) .collect(); axes_to_remove.retain(|ax| !backed.contains(ax)); @@ -19067,6 +19186,7 @@ impl GameState { } } self.pending_unbounded_materialization.remove(&controller); + self.pending_materialization_count.remove(&controller); // CR 732.2c bound, in lockstep } /// CR 500.5 + CR 106.4: end a loop-backed ∞-mana capability at a step/phase boundary — an @@ -19310,6 +19430,7 @@ fn _gamestate_partition_is_total(s: &GameState) { unbounded_loop_pile: _, unbounded_counter_targets: _, pending_unbounded_materialization: _, + pending_materialization_count: _, unimplemented_oracle_ids: _, pending_trigger_abandons: _, loop_detection: _, @@ -22225,6 +22346,73 @@ mod tests { ); } + /// R6a (CR 732.2c collapse bound): sibling of + /// `pending_unbounded_materialization_excluded_from_loop_equality` — the accepted-count + /// bound follows the identical exclusion-by-omission discipline, and for the same + /// reason: it is written at accept, so including it would make a live post-accept state + /// unequal to the pre-accept ring snapshots and yield CR 104.4b false negatives. + /// + /// It also pins the CR 732.2c LOCKSTEP: every seam that drops the stash drops the bound + /// with it, so a stale bound can never outlive the collapse it bounded. + /// + /// REVERT-PROBE: add `&& self.pending_materialization_count == + /// other.pending_materialization_count` to the manual `impl PartialEq for GameState` → + /// the three equality assertions fail. Drop any one `remove` → the matching lockstep + /// assertion fails. + #[test] + fn pending_materialization_count_excluded_from_loop_equality_and_cleared_in_lockstep() { + use crate::analysis::resource::loop_states_equal_modulo_resources; + + let a = GameState::new_two_player(7); + let mut b = a.clone(); + b.pending_materialization_count.insert(PlayerId(0), 7); + assert_ne!( + a.pending_materialization_count, b.pending_materialization_count, + "fixture must actually differ in pending_materialization_count" + ); + assert!( + a == b, + "manual PartialEq must exclude pending_materialization_count (annotation, not rules state)" + ); + assert!( + loop_states_equal(&a, &b), + "loop_states_equal (CR 104.4b/732.2a) must exclude pending_materialization_count" + ); + assert!( + loop_states_equal_modulo_resources(&a, &b), + "the PR-0/PR-2 modulo path must exclude pending_materialization_count" + ); + + // CR 732.2c lockstep — each of the three stash-dropping seams drops the bound too. + let mut s = a.clone(); + s.pending_materialization_count.insert(PlayerId(0), 7); + s.take_pending_materialization(PlayerId(0)); + assert!( + s.pending_materialization_count.is_empty(), + "take_pending_materialization must drop the CR 732.2c bound" + ); + let mut s = a.clone(); + s.pending_materialization_count.insert(PlayerId(0), 7); + s.clear_unbounded_loop(PlayerId(0)); + assert!( + s.pending_materialization_count.is_empty(), + "clear_unbounded_loop must drop the CR 732.2c bound" + ); + let mut s = a.clone(); + s.pending_materialization_count.insert(PlayerId(0), 7); + s.clear_collapsed_materializations( + PlayerId(0), + &[PersistentAxisMaterialization::Life { + player: PlayerId(0), + per_cycle_delta: 1, + }], + ); + assert!( + s.pending_materialization_count.is_empty(), + "clear_collapsed_materializations must drop the CR 732.2c bound" + ); + } + /// PR-7 Phase 4c (B5 defuse): `clear_unbounded_loop` must remove ALL THREE /// `unbounded_resources` / `unbounded_loop_enablers` / `unbounded_loop_pile` /// maps for the controller in lockstep — the `zones.rs` defuse hook relies on diff --git a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz index 73decf8ce7..1579d2a48b 100644 Binary files a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz and b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz differ diff --git a/crates/engine/tests/fixtures/witherbloom_sprout_lumaret_simple_4p.json.gz b/crates/engine/tests/fixtures/witherbloom_sprout_lumaret_simple_4p.json.gz new file mode 100644 index 0000000000..08d90cfbc6 Binary files /dev/null and b/crates/engine/tests/fixtures/witherbloom_sprout_lumaret_simple_4p.json.gz differ diff --git a/crates/engine/tests/integration/combo_infinite_pile.rs b/crates/engine/tests/integration/combo_infinite_pile.rs index 1ffad1cbbc..ee2cca96f8 100644 --- a/crates/engine/tests/integration/combo_infinite_pile.rs +++ b/crates/engine/tests/integration/combo_infinite_pile.rs @@ -37,6 +37,7 @@ use engine::game::engine::{apply, start_game}; use engine::game::layers::{flush_layers, mark_layers_full}; use engine::game::scenario::{GameRunner, GameScenario}; use engine::game::zones::{add_to_zone, create_object, remove_from_zone}; +use engine::types::ability::{Effect, TriggerDefinition}; use engine::types::actions::{GameAction, MulliganChoice}; use engine::types::card_type::CoreType; use engine::types::format::FormatConfig; @@ -114,14 +115,23 @@ fn p0_tapped_vanilla_saprolings(state: &GameState) -> BTreeSet { .collect() } -/// Drive the APNAP accept: P0 (the proposer) declares, then every prompted opponent accepts -/// in turn order until the protocol closes back to ordinary priority. +/// Drive the APNAP accept at the harness default of one cycle. CR 732.2c makes the +/// accepted count BINDING on the boundary collapse prompt, so any test that later submits +/// a larger N must declare that N here — use [`drive_all_accept_n`]. fn drive_all_accept(state: &mut GameState) { + drive_all_accept_n(state, 1); +} + +/// Drive the APNAP accept at `n`: P0 (the proposer) declares `Fixed(n)`, then every +/// prompted opponent accepts in turn order until the protocol closes back to ordinary +/// priority. CR 732.2c: `n` is the count the table agrees to, so it bounds the CR 500.5 +/// boundary collapse prompt — a test collapsing to N must accept at ≥ N. +fn drive_all_accept_n(state: &mut GameState, n: u32) { apply( state, P0, GameAction::DeclareShortcut { - count: IterationCount::Fixed(1), + count: IterationCount::Fixed(n), template: None, }, ) @@ -209,7 +219,33 @@ fn real_4p_object_growth_accept_writes_infinite_pile() { ); // (2) DERIVED — derive_views projects the pile (battlefield-filtered, public board state). - let derived = derive_views(&state, Some(P0)); + // + // R6a (CR 732.2c): this accept ALSO scheduled a finite `TokensCreated` collapse, and a + // scheduled axis is already bounded — so the ∞ group must be hidden on the WIRE in + // lockstep with its resource badge. Filter the PROJECTION, never the store: the store + // assertions above and the round-trip below are unchanged and still pass. + assert!( + derive_views(&state, Some(P0)).unbounded_pile.is_empty(), + "CR 732.2c: a scheduled finite collapse hides the ∞ group (the store keeps it)" + ); + // Non-vacuity + the ORIGINAL claim, retained: with nothing scheduled the projection is + // still exactly the battlefield-filtered pile set. + // + // NOT A SYNTHETIC STATE — "pile present, stash absent" is engine-reachable, and this is + // the production sequence that reaches it (cited so the next auditor need not re-derive + // it): accept an object-growth loop → the CR 500.5 boundary prompt → `SubmitPayAmount` → + // the handler's `take_pending_materialization` (`game::engine_resolution_choices`) empties + // the stash FIRST → the `Tokens` mint then PAUSES on an optional token-doubling + // replacement (CR 616.1) → the pause path calls `clear_collapsed_materializations(player, + // &collapsed)` with `collapsed` NOT containing the still-paused `Tokens` item ⇒ pile and + // ∞ axes preserved, stash already gone. NOT A CLAIM — ASSERTED, in + // `combo_infinite_pile::med_tokens_boundary_mint_pause_preserves_replacement_choice`, whose + // closing two assertions drive that exact sequence and check + // `pending_unbounded_materialization[P0]` ABSENT while `derive_views(..).unbounded_pile` is + // non-empty. That test is this arm's production-reachability evidence. + let mut unscheduled = state.clone(); + unscheduled.pending_unbounded_materialization.clear(); + let derived = derive_views(&unscheduled, Some(P0)); let derived_set: BTreeSet = derived.unbounded_pile.iter().copied().collect(); assert_eq!( derived_set, oracle, @@ -225,7 +261,20 @@ fn real_4p_object_growth_accept_writes_infinite_pile() { Some(&oracle), "the ∞ pile survives a serde round-trip (post-fix saves reload it)" ); - let reloaded_set: BTreeSet = derive_views(&reloaded, Some(P0)) + // R6a (CR 732.2c): the stash round-trips too, so the reloaded state's collapse is still + // SCHEDULED and its ∞ group stays hidden on the WIRE — same gate, same authority. + assert!( + derive_views(&reloaded, Some(P0)).unbounded_pile.is_empty(), + "CR 732.2c: the reloaded scheduled collapse still hides the ∞ group" + ); + // Same engine-reachable "pile present, stash absent" shape as above — see the + // `SubmitPayAmount` → `take_pending_materialization` → CR 616.1 mint-pause → + // `clear_collapsed_materializations` sequence cited at (2). + let mut reloaded_unscheduled = reloaded.clone(); + reloaded_unscheduled + .pending_unbounded_materialization + .clear(); + let reloaded_set: BTreeSet = derive_views(&reloaded_unscheduled, Some(P0)) .unbounded_pile .iter() .copied() @@ -575,7 +624,23 @@ fn build_fresh_4p_cast_offer_accept_writes_infinite_pile() { ); // (2) DERIVED — derive_views projects the pile. - let derived_set: BTreeSet = derive_views(runner.state(), Some(P0)) + // + // R6a (CR 732.2c): the accept also scheduled a finite `TokensCreated` collapse ⇒ the ∞ + // group is hidden on the WIRE while it is scheduled. Store unchanged (see the round-trip). + assert!( + derive_views(runner.state(), Some(P0)) + .unbounded_pile + .is_empty(), + "CR 732.2c: a scheduled finite collapse hides the ∞ group (the store keeps it)" + ); + // Non-vacuity + the ORIGINAL claim, retained. "Pile present, stash absent" is + // engine-reachable, not synthetic — the `SubmitPayAmount` → `take_pending_materialization` + // → CR 616.1 mint-pause → `clear_collapsed_materializations` sequence cited in + // `real_4p_object_growth_accept_writes_infinite_pile`, ASSERTED by + // `med_tokens_boundary_mint_pause_preserves_replacement_choice`'s closing two assertions. + let mut unscheduled = runner.state().clone(); + unscheduled.pending_unbounded_materialization.clear(); + let derived_set: BTreeSet = derive_views(&unscheduled, Some(P0)) .unbounded_pile .iter() .copied() @@ -713,7 +778,7 @@ fn real_4p_observed_drive_sequence_replays_captured_period_n_times() { !seq.is_empty(), "the offer carries the real recast period the DriveSequence replays" ); - drive_all_accept(&mut state); + drive_all_accept_n(&mut state, 3); // An OBSERVED loop's accept registers ONE DriveSequence over the whole loop (all axes) instead // of the batched Tokens/Counters/Life. Emulate that route: drop the batched token stash the @@ -830,7 +895,7 @@ fn real_4p_object_growth_boundary_collapse_mints_finite_tokens() { state.waiting_for ); - drive_all_accept(&mut state); + drive_all_accept_n(&mut state, 5); // Reach-guard (accept-capture, §1): accepting the object-growth loop stashed the // fodder's copiable profile for P0. Non-vacuity anchor for the negatives below. @@ -944,7 +1009,7 @@ fn real_4p_object_growth_boundary_collapse_mints_finite_tokens() { fn loop_collapse_large_mint_does_not_overflow_small_stack() { let mut state: GameState = serde_json::from_str(&OFFER_STATE).expect("the real 4p offer dump must deserialize"); - drive_all_accept(&mut state); + drive_all_accept_n(&mut state, 1000); let before = p0_saproling_ids(&state).len(); drive_priority_to_next_boundary(&mut state); assert!( @@ -1339,7 +1404,7 @@ fn real_4p_one_shot_bootstrap_seeds_tapped_infinite_pile_and_w_plus_1_untapped() ); // ── Step 2: APNAP accept → materialize. - drive_all_accept(runner.state_mut()); + drive_all_accept_n(runner.state_mut(), 5); assert!( matches!(runner.state().waiting_for, WaitingFor::Priority { .. }), "after all accept, materialize hands priority back, got {:?}", @@ -1382,7 +1447,23 @@ fn real_4p_one_shot_bootstrap_seeds_tapped_infinite_pile_and_w_plus_1_untapped() ); // derive_views projects the pile; it survives a serde round-trip. - let derived_set: BTreeSet = derive_views(runner.state(), Some(P0)) + // + // R6a (CR 732.2c): the accept also scheduled a finite `TokensCreated` collapse ⇒ the ∞ + // group is hidden on the WIRE while it is scheduled. Store unchanged (round-trip below). + assert!( + derive_views(runner.state(), Some(P0)) + .unbounded_pile + .is_empty(), + "CR 732.2c: a scheduled finite collapse hides the ∞ group (the store keeps it)" + ); + // Non-vacuity + the ORIGINAL claim, retained. "Pile present, stash absent" is + // engine-reachable, not synthetic — the `SubmitPayAmount` → `take_pending_materialization` + // → CR 616.1 mint-pause → `clear_collapsed_materializations` sequence cited in + // `real_4p_object_growth_accept_writes_infinite_pile`, ASSERTED by + // `med_tokens_boundary_mint_pause_preserves_replacement_choice`'s closing two assertions. + let mut unscheduled = runner.state().clone(); + unscheduled.pending_unbounded_materialization.clear(); + let derived_set: BTreeSet = derive_views(&unscheduled, Some(P0)) .unbounded_pile .iter() .copied() @@ -1606,7 +1687,7 @@ fn real_4p_boundary_collapse_batches_unobserved_counter_and_declines_observed_li let mut state: GameState = serde_json::from_str(&OFFER_STATE) .expect("the real 4p offer dump must deserialize into the current GameState"); - drive_all_accept(&mut state); + drive_all_accept_n(&mut state, 5); // Graft a beneficial +1/+1 counter axis (UNOBSERVED on this board) and a life axis (OBSERVED) // onto the accepted token loop — the SAME single-authority writers the accept path uses. @@ -1739,7 +1820,7 @@ fn real_4p_counter_observer_drift_in_window_declines_batched_counter_but_still_m let mut state: GameState = serde_json::from_str(&OFFER_STATE) .expect("the real 4p offer dump must deserialize into the current GameState"); - drive_all_accept(&mut state); + drive_all_accept_n(&mut state, 5); // Graft a +1/+1 counter axis (UNOBSERVED at accept — MEASURED counter_growth_is_observed=false). let creature = *p0_saproling_ids(&state) @@ -2097,7 +2178,7 @@ fn med_tokens_boundary_mint_pause_preserves_replacement_choice() { let mut state: GameState = serde_json::from_str(&OFFER_STATE) .expect("the real 4p offer dump must deserialize into the current GameState"); - drive_all_accept(&mut state); + drive_all_accept_n(&mut state, 3); // Install an OPTIONAL token-count-doubling replacement ("you may create twice that many tokens // instead", CR 616.1) on a fresh P0 battlefield permanent — AFTER accept, so it never perturbs @@ -2186,6 +2267,26 @@ fn med_tokens_boundary_mint_pause_preserves_replacement_choice() { .is_some_and(|p| !p.is_empty()), "REVERT-FLIP: the paused mint must preserve the ∞ token pile, not drop it" ); + + // PRODUCTION-REACHABILITY ANCHOR for the "pile present, stash absent" shape. Several R6a + // rows build that shape by cloning a post-accept state and clearing + // `pending_unbounded_materialization` by hand; these two lines assert the ENGINE produces it + // unaided, right here, so those clones are grounded in a measured production sequence rather + // than in a comment. The sequence is the CR 616.1 mint-pause above: + // `SubmitPayAmount` → `take_pending_materialization` (removes the whole stash) → + // `engine_resolution_choices`' pause guard → `clear_collapsed_materializations(player, + // &collapsed)` with the paused `Tokens` item ABSENT from `collapsed` ⇒ the pile survives + // while the stash is gone. + assert!( + !state.pending_unbounded_materialization.contains_key(&P0), + "the submit's take_pending_materialization removed P0's stash, got {:?}", + state.pending_unbounded_materialization.get(&P0) + ); + assert!( + !derive_views(&state, Some(P0)).unbounded_pile.is_empty(), + "…and with no stash left to schedule a collapse, the ∞ pile renders on the WIRE — the \ + engine-reachable 'pile present, stash absent' shape the clone-based R6a arms emulate" + ); } /// [BLOCKER] (#6259 review, CR 732.2a pause-safety): a MIXED stash pausing on the `Tokens` @@ -2226,7 +2327,7 @@ fn med_mixed_counter_tokens_pause_commits_finite_counter_and_keeps_only_tokens_u let mut state: GameState = serde_json::from_str(&OFFER_STATE) .expect("the real 4p offer dump must deserialize into the current GameState"); - drive_all_accept(&mut state); + drive_all_accept_n(&mut state, 4); // Graft an UNOBSERVED +1/+1 counter axis onto a P0 Saproling — the same single-authority // writers the accept path uses (mirrors @@ -2399,8 +2500,93 @@ fn low3_activate_and_settle(runner: &mut GameRunner, source: ObjectId, ability_i #[test] fn low3_unobserved_life_growth_accept_registers_batched_life() { use engine::analysis::resource::ResourceAxis; + + let runner = low3_life_engine_accepted(Low3BoardEtbTrigger::Absent); + + // Reach-guard: the accept produced a non-empty deferred stash (not a no-op). + let stash = runner + .state() + .pending_unbounded_materialization + .get(&P0) + .cloned() + .unwrap_or_default(); + assert!( + !stash.is_empty(), + "reach-guard: the accept must register a deferred materialization stash for P0" + ); + // DISCRIMINATOR: the UNOBSERVED life growth routes to a BATCHED `Life` item carrying the + // per-cycle δ — produced by the real accept-time routing, never grafted. + assert!( + stash.iter().any(|m| matches!( + m, + PersistentAxisMaterialization::Life { player, per_cycle_delta } + if *player == P0 && *per_cycle_delta >= 1 + )), + "the unobserved life loop must register a BATCHED Life stash (per_cycle_delta captured), \ + got {stash:?}" + ); + // DISCRIMINATOR: an UNOBSERVED loop BATCHES — it must NOT register a DriveSequence (that is the + // observed route; forcing the observed branch flips this). + assert!( + !stash + .iter() + .any(|m| matches!(m, PersistentAxisMaterialization::DriveSequence { .. })), + "an unobserved life loop batches; it must not register a DriveSequence, got {stash:?}" + ); + // The life axis is ∞-marked (the mana axis is too; both are real unbounded axes here). + assert!( + runner + .state() + .unbounded_resources + .get(&P0) + .is_some_and(|axes| axes.contains(&ResourceAxis::Life(P0))), + "the accepted loop marks the Life axis ∞ for P0" + ); +} + +/// Whether [`low3_life_engine_accepted`] installs a FUNCTIONING battlefield-entry trigger on the +/// board before the loop is driven. `Present` is the hostile arm for the `token_profile.is_some()` +/// conjunct of `life_etb_sourced` (engine.rs): it makes `board_has_functioning_etb_trigger` TRUE +/// while the loop stays mana-only, so the conjunct is the ONLY thing still holding the route on the +/// batched arm. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Low3BoardEtbTrigger { + Absent, + Present, +} + +/// A minimal functioning battlefield-entry life trigger (the Prosperous Innkeeper shape, built +/// directly because this fixture is synthetic and loads no card pool). Its EFFECT is deliberately +/// `GainLife`: that is the production shape that double-pays when a `Tokens` collapse mints real +/// CR 603.6a entries, and it is NOT a life OBSERVER — `life_growth_is_observed` keys on +/// `TriggerEventKey::LifeChanged` / `ReplacementEvent::GainLife`, never on `EnterBattlefield` — so +/// installing it cannot flip `life_observed` and mask what this arm is measuring. +fn low3_board_etb_life_trigger() -> TriggerDefinition { + use engine::types::ability::{AbilityDefinition, AbilityKind, QuantityExpr, TargetFilter}; + use engine::types::triggers::TriggerMode; + + let mut def = TriggerDefinition::new(TriggerMode::ChangesZone); + def.destination = Some(Zone::Battlefield); + def.trigger_zones = vec![Zone::Battlefield]; + def.execute = Some(Box::new(AbilityDefinition::new( + AbilityKind::Spell, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 1 }, + player: TargetFilter::Controller, + }, + ))); + def.description = + Some("Whenever another creature you control enters, you gain 1 life.".to_string()); + def +} + +/// Build the LOW-3 mana+life engine, optionally graft a functioning board ETB trigger, drive one +/// certified period, and take the offer through the real APNAP accept. Returns the post-accept +/// runner. Shared so both arms inherit the same non-vacuity reach-guards (3-step period, surfaced +/// offer) — the only difference between them is `etb`. +fn low3_life_engine_accepted(etb: Low3BoardEtbTrigger) -> GameRunner { use engine::game::mana_abilities::is_mana_ability; - use engine::types::ability::{Effect, TapStateChange}; + use engine::types::ability::TapStateChange; let mut scenario = GameScenario::new_n_player(2, 7); scenario.at_phase(Phase::PreCombatMain); @@ -2415,6 +2601,27 @@ fn low3_unobserved_life_growth_accept_registers_batched_life() { .id(); let mut runner = scenario.build(); runner.state_mut().loop_detection = LoopDetectionMode::Interactive; + if etb == Low3BoardEtbTrigger::Present { + // Grafted BEFORE the drive so the board is identical every cycle (a permanent that never + // changes cannot perturb the modulo-resources loop cover), and so the predicate the accept + // reads is the one this board really has. Nothing enters the battlefield during the + // mana+life period, so the trigger never actually fires — `board_has_functioning_etb_trigger` + // is a board-shape question, not a fired-this-cycle one. + let host = create_life_gainer(runner.state_mut(), P0, "Grafted Innkeeper"); + graft_trigger(runner.state_mut(), host, low3_board_etb_life_trigger()); + // Reach-guard: the graft must survive the layer rebuild and be ACTIVE (CR 113.6) on a + // battlefield permanent. Without this the arm would pass VACUOUSLY — a dropped graft leaves + // `board_has_functioning_etb_trigger` false, which is the `Absent` arm wearing a new name. + let state = runner.state(); + let host_obj = &state.objects[&host]; + assert!( + engine::game::functioning_abilities::active_trigger_definitions(state, host_obj).any( + |active| active.definition.destination == Some(Zone::Battlefield) + && active.definition.mode == engine::types::triggers::TriggerMode::ChangesZone + ), + "reach-guard: the grafted battlefield-entry trigger must be ACTIVE on the host" + ); + } // Derive the ability indices off the layer-built object (robust to parser ordering). let (mana_idx, life_idx, untap_idx) = { @@ -2475,44 +2682,527 @@ fn low3_unobserved_life_growth_accept_registers_batched_life() { response: ShortcutResponse::Accept, }) .expect("the single opponent accepts"); + runner +} + +/// PINS the `token_profile.is_some()` conjunct of `life_etb_sourced` (engine.rs) — the conjunct the +/// sibling test above cannot reach, because its board has no functioning ETB trigger at all, so +/// `board_has_functioning_etb_trigger` short-circuits the predicate false before `token_profile` is +/// ever consulted. +/// +/// Same mana-only Lifedynamo loop, but with a functioning battlefield-entry trigger grafted onto the +/// board. That makes TWO of the three conjuncts true (`!life.is_empty()` and +/// `board_has_functioning_etb_trigger`), so `token_profile.is_some()` — false here, because a +/// mana-only collapse mints no tokens and `current_period_fodder` returns `None` — is the ONLY thing +/// still holding this loop on the BATCHED route. +/// +/// Why that matters: the collapse re-earns an ETB-sourced life axis only if it MINTS the entries +/// that re-fire the trigger. A mana engine mints nothing, so there is no double-pay and the O(N) +/// replay is pure cost. Deleting the conjunct silently sends every life-growing loop with any board +/// ETB trigger — mana engines included — down the concrete replay. +/// +/// REVERT-PROBE (RUN): delete `&& token_profile.is_some()` from `life_etb_sourced` ⇒ this test's +/// batched-`Life` assertion FAILS (the stash becomes a lone `DriveSequence`). The sibling +/// `Absent`-arm test stays green under that same deletion, which is precisely why this arm exists. +#[test] +fn low3_mana_only_life_growth_stays_batched_despite_board_etb_trigger() { + let runner = low3_life_engine_accepted(Low3BoardEtbTrigger::Present); - // Reach-guard: the accept produced a non-empty deferred stash (not a no-op). let stash = runner .state() .pending_unbounded_materialization .get(&P0) .cloned() .unwrap_or_default(); + // Reach-guard: the accept produced a stash at all. assert!( !stash.is_empty(), "reach-guard: the accept must register a deferred materialization stash for P0" ); - // DISCRIMINATOR: the UNOBSERVED life growth routes to a BATCHED `Life` item carrying the - // per-cycle δ — produced by the real accept-time routing, never grafted. + // Reach-guard: this really is the mana-only shape — a `Tokens` item would mean the loop DID + // mint fodder, `token_profile` would be `Some`, and the conjunct would no longer be the + // load-bearing one. + assert!( + !stash + .iter() + .any(|m| matches!(m, PersistentAxisMaterialization::Tokens(_))), + "reach-guard: the mana-only loop must stash no Tokens axis, got {stash:?}" + ); + // DISCRIMINATOR: with the ETB trigger present and the loop token-less, the route stays BATCHED. + // Drop `token_profile.is_some()` from `life_etb_sourced` and this flips to a DriveSequence. assert!( stash.iter().any(|m| matches!( m, PersistentAxisMaterialization::Life { player, per_cycle_delta } if *player == P0 && *per_cycle_delta >= 1 )), - "the unobserved life loop must register a BATCHED Life stash (per_cycle_delta captured), \ + "a token-less life loop must stay on the BATCHED Life route even with a board ETB trigger, \ got {stash:?}" ); - // DISCRIMINATOR: an UNOBSERVED loop BATCHES — it must NOT register a DriveSequence (that is the - // observed route; forcing the observed branch flips this). assert!( !stash .iter() .any(|m| matches!(m, PersistentAxisMaterialization::DriveSequence { .. })), - "an unobserved life loop batches; it must not register a DriveSequence, got {stash:?}" + "a token-less life loop must not route to the concrete replay, got {stash:?}" ); - // The life axis is ∞-marked (the mana axis is too; both are real unbounded axes here). +} + +// ───────── CR 732.2a + CR 603.6a: an ETB-SOURCED life axis routes to the concrete replay ───────── +// +// MEASURED DEFECT (real 4p Sprout Swarm dump `.fb-dumps/witherbloom-sprout-lumaret-works-slow`, +// engine UNMODIFIED, `combofb_probe e no_spear_accept`): P0's board carried Bogwater Lumaret +// ("Whenever ~ or another creature you control enters, you gain 1 life"). The accept registered +// the BATCHED pair `[Tokens(Saproling), Life { player: P0, per_cycle_delta: 1 }]`. +// `SubmitPayAmount(50)` took P0 from 546 to 596 (the batched +50) and left 50 real token-ETB +// triggers on the stack; draining them paid the SAME life a SECOND time, ending at 646. The +// batched arithmetic is not wrong — the ROUTE is: the collapse's own `Tokens` minting re-earns an +// ETB-sourced life axis, so the accept pays twice. On the concrete replay the real ETB triggers +// are the ONLY life source, which is what the board actually does. +// +// `combo_infinite_pile_4p_offer.json.gz` is a capture of the same game with NO life gainer on the +// battlefield (MEASURED: accept ⇒ `unbounded == {TokensCreated}`, route `["Tokens"]`), so these +// tests host the engine's own parse of a REAL card from this dump's pool — Prosperous Innkeeper's +// "Whenever another creature you control enters, you gain 1 life" — on a live permanent. + +/// Prosperous Innkeeper's parsed "gain 1 life" battlefield-entry trigger, taken from the real +/// object in this dump. Selected by EFFECT, never by index: the Innkeeper also carries a +/// Treasure-token ETB trigger, and picking that one silently makes every life assertion vacuous. +fn innkeeper_etb_life_trigger(state: &GameState) -> TriggerDefinition { + state + .objects + .values() + .filter(|o| o.name == "Prosperous Innkeeper") + .flat_map(|o| o.trigger_definitions.iter_unchecked()) + .map(|entry| &entry.definition) + .find(|def| { + def.execute + .as_ref() + .is_some_and(|a| matches!(*a.effect, Effect::GainLife { .. })) + }) + .expect("Prosperous Innkeeper's ETB life trigger is in this dump's card pool") + .clone() +} + +/// MEASURED (revert-probe round 1): this dump carries Mortality Spear, whose life-conditional +/// cost static makes `fire_time_conditions_read_projected_resource` true — so +/// `life_growth_is_observed` already routes ANY life axis on this board to the replay, and a +/// route test built on it passes with the fix reverted. Strip those statics (the honest analogue +/// of a deck without that card, and exactly what the `combofb_probe e no_spear_accept` variant +/// does on the live dump) so the ONLY thing that can flip the route is the ETB source. +fn strip_life_conditional_cost_static(state: &mut GameState) { + let ids: Vec = state + .objects + .values() + .filter(|o| o.name == "Mortality Spear") + .map(|o| o.id) + .collect(); assert!( - runner - .state() - .unbounded_resources - .get(&P0) - .is_some_and(|axes| axes.contains(&ResourceAxis::Life(P0))), - "the accepted loop marks the Life axis ∞ for P0" + !ids.is_empty(), + "fixture precondition: this dump must contain the Mortality Spear whose static is stripped" + ); + for id in ids { + let o = state.objects.get_mut(&id).unwrap(); + o.static_definitions.clear(); + o.base_static_definitions = std::sync::Arc::new(Vec::new()); + } + mark_layers_full(state); + flush_layers(state); +} + +/// Install `def` on `host` and rebuild layers so it FUNCTIONS in its source's zone (CR 113.6). +fn graft_trigger(state: &mut GameState, host: ObjectId, def: TriggerDefinition) { + state + .objects + .get_mut(&host) + .expect("graft host") + .trigger_definitions + .push(def); + mark_layers_full(state); + flush_layers(state); +} + +/// A plain 1/1 creature permanent for `owner` to host a grafted trigger. Deliberately NOT named +/// "Saproling" so it cannot join the loop's content-equal fodder class. +fn create_life_gainer(state: &mut GameState, owner: PlayerId, name: &str) -> ObjectId { + let card_id = CardId(state.next_object_id); + let id = create_object(state, card_id, owner, name.to_string(), Zone::Battlefield); + let o = state.objects.get_mut(&id).unwrap(); + o.power = Some(1); + o.toughness = Some(1); + o.base_power = Some(1); + o.base_toughness = Some(1); + o.card_types.core_types = vec![CoreType::Creature]; + o.summoning_sick = false; + id +} + +/// A coarse label per registered materialization — the ROUTE oracle. `DriveSequence` is the +/// concrete replay; `Tokens`/`Counters`/`Life` are the batched N×δ collapse. +fn route_labels(state: &GameState, player: PlayerId) -> Vec { + state + .pending_unbounded_materialization + .get(&player) + .map(|v| { + v.iter() + .map(|m| match m { + PersistentAxisMaterialization::DriveSequence { .. } => { + "DriveSequence".to_string() + } + PersistentAxisMaterialization::Tokens(_) => "Tokens".to_string(), + PersistentAxisMaterialization::Counters(_) => "Counters".to_string(), + PersistentAxisMaterialization::Life { + player, + per_cycle_delta, + } => format!("Life({player:?},{per_cycle_delta})"), + }) + .collect() + }) + .unwrap_or_default() +} + +fn life_of(state: &GameState, player: PlayerId) -> i32 { + state + .players + .iter() + .find(|p| p.id == player) + .expect("seat is in the dump") + .life +} + +/// Resolve everything the collapse left on the stack — the step that EXPOSED the double-count +/// (the batched route's 50 leftover token-ETB triggers paid the life a second time on the drain). +fn drain_stack(state: &mut GameState) { + for _ in 0..8192 { + if state.stack.is_empty() { + return; + } + let WaitingFor::Priority { player } = state.waiting_for.clone() else { + return; + }; + apply(state, player, GameAction::PassPriority).expect("pass priority to drain the stack"); + } + panic!("drain_stack: the stack did not empty within 8192 passes"); +} + +/// Drive the accepted loop to its boundary, collapse at `n`, drain the stack, and return the +/// number of Saprolings minted — the whole production tail in one place. +fn collapse_at(state: &mut GameState, n: u32) -> usize { + let saps_before = p0_saproling_ids(state).len(); + drive_priority_to_next_boundary(state); + assert!( + matches!( + state.waiting_for, + WaitingFor::PayAmountChoice { player, resource: PayableResource::LoopCollapse { .. }, .. } + if player == P0 + ), + "the boundary must prompt P0 for the LoopCollapse count, got {:?}", + state.waiting_for + ); + apply(state, P0, GameAction::SubmitPayAmount { amount: n }) + .expect("P0 submits the finite loop-collapse count"); + drain_stack(state); + p0_saproling_ids(state).len() - saps_before +} + +/// R6b-converge (CR 732.2a + CR 603.6a): the two collapse ROUTES must land on the SAME life total. +/// +/// ARM 1 (ETB-observer board): a real parsed "whenever another creature you control enters, you +/// gain 1 life" trigger on a P0 permanent. The accept must register `DriveSequence` — NOT the +/// batched pair — and collapsing at N=5 must pay the life exactly ONCE, measured ACROSS the +/// collapse *and* the stack drain (the drain is where the double-count surfaced: 596 → 646). +/// +/// REVERT-PROBE (discriminating, RUN): drop the `life_etb_sourced` conjunct from the route +/// decision in `materialize_object_growth_shortcut` ⇒ the route flips to +/// `["Tokens", "Life(PlayerId(0),1)"]` and the life total over-counts by exactly N (the batched +/// +N at the collapse plus the N real token ETBs on the drain). +/// +/// MUST-NOT-FLIP, ARM 2: the same dump with NO life gainer grows no life axis (MEASURED: +/// `unbounded == {TokensCreated}`) and must still register the batched `Tokens`. +#[test] +fn batched_and_replay_routes_converge_on_the_same_life_total() { + const N: u32 = 5; + + // ── ARM 2 (must-NOT-flip): no life axis ⇒ the pure token loop still BATCHES. ── + let mut plain: GameState = serde_json::from_str(&OFFER_STATE) + .expect("the real 4p offer dump must deserialize into the current GameState"); + strip_life_conditional_cost_static(&mut plain); + drive_all_accept_n(&mut plain, N); + assert_eq!( + route_labels(&plain, P0), + vec!["Tokens".to_string()], + "a pure token loop with NO life axis keeps the batched Tokens route" + ); + + // ── ARM 1 (primary): an ETB-sourced life axis ⇒ the concrete replay, paid exactly once. ── + let mut state: GameState = serde_json::from_str(&OFFER_STATE).unwrap(); + strip_life_conditional_cost_static(&mut state); + let etb_life = innkeeper_etb_life_trigger(&state); + let host = create_life_gainer(&mut state, P0, "Grafted Innkeeper"); + graft_trigger(&mut state, host, etb_life); + + drive_all_accept_n(&mut state, N); + assert_eq!( + route_labels(&state, P0), + vec!["DriveSequence".to_string()], + "an ETB-sourced life axis routes to the concrete replay (revert 4a ⇒ [Tokens, Life(..,1)])" + ); + + let life_before = life_of(&state, P0); + let minted = collapse_at(&mut state, N); + + // (1) POSITIVE reach-guard: the collapse actually ran and minted N real tokens. + assert_eq!( + minted, N as usize, + "the collapse mints exactly N real Saprolings" + ); + // (2) DISCRIMINATOR: one Saproling ETB per driven cycle × 1 life each = exactly N. The + // batched route pays N at the collapse AND N again when the real ETBs drain ⇒ 2N. + let life_per_cycle = 1i32; // the single grafted ETB gainer on P0's battlefield + assert_eq!( + life_of(&state, P0) - life_before, + N as i32 * life_per_cycle, + "the ETB life is paid ONCE across collapse+drain (batched route ⇒ over-counts by N)" + ); +} + +/// MIXED-CAUSE (CR 732.2a): TWO ETB life gainers on P0's board ⇒ a batched route would carry +/// `per_cycle_delta == 2`. The route must flip to the concrete replay for the WHOLE axis, and a +/// materialization MUST still be registered (M5 reach-guard: an empty registration would make +/// every downstream assertion vacuous). +/// +/// This is why the fix is a ROUTE decision and not a registration-cancelling suppressor: a +/// suppressor keyed on "an ETB source exists" would drop the whole `Life` registration and +/// under-apply. Here it would pay N instead of 2N. +/// +/// REVERT-PROBE (discriminating, RUN): drop the `life_etb_sourced` conjunct ⇒ the route flips to +/// `["Tokens", "Life(PlayerId(0),2)"]` and the total becomes 4N (2N batched + 2N on the drain). +#[test] +fn mixed_cause_life_axis_routes_to_replay() { + const N: u32 = 5; + let mut state: GameState = serde_json::from_str(&OFFER_STATE) + .expect("the real 4p offer dump must deserialize into the current GameState"); + strip_life_conditional_cost_static(&mut state); + let etb_life = innkeeper_etb_life_trigger(&state); + let first = create_life_gainer(&mut state, P0, "Grafted Innkeeper A"); + graft_trigger(&mut state, first, etb_life.clone()); + let second = create_life_gainer(&mut state, P0, "Grafted Innkeeper B"); + graft_trigger(&mut state, second, etb_life); + + drive_all_accept_n(&mut state, N); + let labels = route_labels(&state, P0); + assert!( + !labels.is_empty(), + "M5 reach-guard: the route-flipped accept must register SOMETHING, got {labels:?}" + ); + assert_eq!( + labels, + vec!["DriveSequence".to_string()], + "a mixed-cause life axis routes wholesale to the concrete replay, got {labels:?}" + ); + + let life_before = life_of(&state, P0); + let minted = collapse_at(&mut state, N); + + assert_eq!( + minted, N as usize, + "reach-guard: the collapse minted N tokens" + ); + // DISCRIMINATOR: BOTH gainers pay, once each, per driven cycle ⇒ 2 per cycle. + assert_eq!( + life_of(&state, P0) - life_before, + N as i32 * 2, + "per_cycle_delta == 2 is paid in full ONCE (a suppressor ⇒ N, the batched route ⇒ 4N)" + ); +} + +/// CR 732.2a + CR 109.5: an OPPONENT's controller-agnostic ETB life-gainer sits on the board +/// alongside P0's own. P0's accept must still materialize P0's FULL delta — a board-level +/// boolean suppressor ("some ETB life-gainer exists ⇒ drop the life axis") would name the wrong +/// beneficiary and silently zero P0's gain. +/// +/// REVERT-PROBE (discriminating, RUN): replace the route flip with a board-level suppressor of +/// the `Life` registration ⇒ P0's Δlife collapses to 0 while P1's stays N. +#[test] +fn opponents_etb_life_gainer_does_not_suppress_your_axis() { + const N: u32 = 5; + let mut state: GameState = serde_json::from_str(&OFFER_STATE) + .expect("the real 4p offer dump must deserialize into the current GameState"); + strip_life_conditional_cost_static(&mut state); + let etb_life = innkeeper_etb_life_trigger(&state); + + // P0's own gainer ("another creature YOU control enters"). + let p0_host = create_life_gainer(&mut state, P0, "Grafted Innkeeper"); + graft_trigger(&mut state, p0_host, etb_life.clone()); + + // P1 gets a Soul-Warden-shaped copy: the same parsed GainLife trigger with the `You` + // controller narrowing dropped, so P0's tokens feed P1's life too. + let mut soul_warden = etb_life; + soul_warden.valid_card = None; + let p1_host = create_life_gainer(&mut state, P1, "Grafted Soul Warden"); + graft_trigger(&mut state, p1_host, soul_warden); + + drive_all_accept_n(&mut state, N); + let labels = route_labels(&state, P0); + assert!( + !labels.is_empty(), + "reach-guard: the accept must register a materialization, got {labels:?}" + ); + assert_eq!( + labels, + vec!["DriveSequence".to_string()], + "the ETB-sourced axis routes to the replay even with a foreign gainer present" + ); + + let p0_before = life_of(&state, P0); + let p1_before = life_of(&state, P1); + let minted = collapse_at(&mut state, N); + + assert_eq!( + minted, N as usize, + "reach-guard: the collapse minted N tokens" + ); + // DISCRIMINATOR: P0's OWN axis is materialized in full — a board-level suppressor zeroes it. + assert_eq!( + life_of(&state, P0) - p0_before, + N as i32, + "P0's own ETB life axis is paid in full despite the opponent's gainer" + ); + // POSITIVE control: the opponent's gainer really did fire, so the assertion above is not + // passing because nothing triggered at all. + assert_eq!( + life_of(&state, P1) - p1_before, + N as i32, + "the opponent's controller-agnostic gainer also pays once per driven cycle" + ); +} + +/// R4-C1 COMBINED GATE (4a + 4b together; per-commit green is explicitly insufficient). +/// +/// The same ETB life gainer as `batched_and_replay_routes_converge_on_the_same_life_total`, but +/// with `batched: true` — the "Whenever ONE OR MORE creatures you control enter, you gain 1 life" +/// shape (CR 603.2c). Collapsing at N now produces N SEPARATE same-turn token batches (one per +/// replayed cycle), so the total is right only if BOTH fixes hold: +/// +/// * 4a (route): the ETB-sourced axis must take the concrete replay. Reverting it re-introduces +/// the batched `Life` on top of the real entries ⇒ amplified over-count. +/// * 4b (index): each replayed cycle's entry must carry its OWN zone-change index. Reverting it +/// leaves every entry on the `0` placeholder, so `batched_zone_change_already_collected` keys +/// all N batches to `(def, 0)` and only the FIRST fires ⇒ the trigger-count assertion fails. +/// +/// Both revert-probes were RUN; observed values are in the assertion messages. +#[test] +fn combined_batched_etb_gainer_fires_once_per_replayed_cycle() { + const N: u32 = 5; + let mut state: GameState = serde_json::from_str(&OFFER_STATE) + .expect("the real 4p offer dump must deserialize into the current GameState"); + strip_life_conditional_cost_static(&mut state); + let mut batched_gainer = innkeeper_etb_life_trigger(&state); + batched_gainer.batched = true; + let host = create_life_gainer(&mut state, P0, "Grafted Batched Innkeeper"); + graft_trigger(&mut state, host, batched_gainer); + + drive_all_accept_n(&mut state, N); + assert_eq!( + route_labels(&state, P0), + vec!["DriveSequence".to_string()], + "4a: a batched ETB life gainer still routes the axis to the concrete replay" + ); + + let life_before = life_of(&state, P0); + let minted = collapse_at(&mut state, N); + + // POSITIVE reach-guard: N cycles really replayed. + assert_eq!(minted, N as usize, "the replay minted one token per cycle"); + // DISCRIMINATOR (needs BOTH fixes): N distinct same-turn batches ⇒ N fires ⇒ +N. + // revert 4b ⇒ all N batches collide on `(def, 0)` ⇒ +1. revert 4a ⇒ batched Life on top ⇒ >N. + assert_eq!( + life_of(&state, P0) - life_before, + N as i32, + "each replayed cycle is its OWN batch and fires once (revert 4b ⇒ 1, revert 4a ⇒ more)" + ); +} + +/// NON-`GainLife` LIFE SOURCE (CR 732.2a + CR 603.6a + CR 702.15b): the Terror-of-the-Peaks +/// shape — an ETB *damage* trigger on a permanent with LIFELINK. The life axis is just as +/// ETB-sourced as Soul Warden's, but it never passes through `Effect::GainLife`: it reaches +/// `apply_life_gain` from `effects/deal_damage.rs`'s CR 702.15b lifelink leg. +/// +/// This is the fixture that forced the route predicate to be AXIS-shaped rather than +/// EFFECT-shaped. An earlier form asked whether the trigger's effect chain contained an +/// `Effect::GainLife`; life reaches `apply_life_gain` from FOUR resolvers (`life.rs`, +/// `double.rs`, `exchange_life.rs`, `deal_damage.rs`), so that test answers NO here, the loop +/// keeps the batched `[Tokens, Life]` pair, and the 596-vs-646 double-apply reproduces. +/// +/// REVERT-PROBE (discriminating, RUN): re-add the `Effect::GainLife` chain test as a conjunct of +/// `board_has_functioning_etb_trigger` ⇒ this board's route flips back to +/// `["Tokens", "Life(PlayerId(0),3)"]` and the assertion below fails on the labels. +#[test] +fn lifelink_etb_damage_life_axis_routes_to_replay() { + const N: u32 = 5; + // 3 opponents in this 4p pod × 1 damage each, all dealt by one lifelink source per entry. + const LIFELINK_PER_CYCLE: i32 = 3; + + let mut state: GameState = serde_json::from_str(&OFFER_STATE) + .expect("the real 4p offer dump must deserialize into the current GameState"); + strip_life_conditional_cost_static(&mut state); + + // The real parsed battlefield-entry trigger CONDITION from this dump's pool, with only its + // EFFECT swapped for the Impact-Tremors damage body. What is under test is the life SOURCE, + // so the matcher half stays a real card's parse. + let mut ping = innkeeper_etb_life_trigger(&state); + ping.execute = Some(Box::new(engine::types::ability::AbilityDefinition::new( + engine::types::ability::AbilityKind::Spell, + Effect::DamageEachPlayer { + amount: engine::types::ability::QuantityExpr::Fixed { value: 1 }, + player_filter: engine::types::ability::PlayerFilter::Opponent, + }, + ))); + let host = create_life_gainer(&mut state, P0, "Grafted Terror of the Peaks"); + // CR 702.15b: damage dealt by a source with lifelink also causes its controller to gain that + // much life. `DamageContext::from_source` reads the EFFECTIVE keyword set, so the printed + + // base grant here is what makes the damage leg gain life. + { + let o = state.objects.get_mut(&host).expect("grafted host"); + o.keywords.push(engine::types::keywords::Keyword::Lifelink); + o.base_keywords + .push(engine::types::keywords::Keyword::Lifelink); + } + graft_trigger(&mut state, host, ping); + + drive_all_accept_n(&mut state, N); + let labels = route_labels(&state, P0); + assert!( + !labels.is_empty(), + "reach-guard: the accept must register a materialization, got {labels:?}" + ); + assert_eq!( + labels, + vec!["DriveSequence".to_string()], + "a LIFELINK-sourced ETB life axis routes to the concrete replay \ + (an Effect::GainLife shape test ⇒ [Tokens, Life(..,3)]), got {labels:?}" + ); + + let p0_before = life_of(&state, P0); + let p1_before = life_of(&state, P1); + let minted = collapse_at(&mut state, N); + + assert_eq!( + minted, N as usize, + "reach-guard: the collapse minted N tokens" + ); + // POSITIVE control: the damage really was dealt, so the lifelink assertion below cannot pass + // because nothing triggered at all. + assert_eq!( + life_of(&state, P1) - p1_before, + -(N as i32), + "each replayed cycle pings every opponent once" + ); + // DISCRIMINATOR: the lifelink life is paid ONCE across collapse+drain. The batched route pays + // the `Life { per_cycle_delta: 3 }` at the collapse AND the N real token ETBs pay it again on + // the drain ⇒ 2 × 3N. + assert_eq!( + life_of(&state, P0) - p0_before, + N as i32 * LIFELINK_PER_CYCLE, + "the CR 702.15b lifelink gain is paid ONCE (batched route ⇒ double)" ); } diff --git a/crates/engine/tests/integration/cr733_resolved_copy_token_creation.rs b/crates/engine/tests/integration/cr733_resolved_copy_token_creation.rs index 716a290660..c16b312e1b 100644 --- a/crates/engine/tests/integration/cr733_resolved_copy_token_creation.rs +++ b/crates/engine/tests/integration/cr733_resolved_copy_token_creation.rs @@ -24,6 +24,146 @@ use engine::types::zones::Zone; /// exceptions, so it takes the liminal seam and its body is complete at entry. const REPLICATE_ORACLE: &str = "Create a token that's a copy of target creature you control."; +/// Citanul Woodreaders — verbatim Scryfall Oracle text. Its Kicker keyword and +/// its kicked-gated entry trigger are the live-only mutation this fixture +/// measures. CR 707.2: a copy acquires cast-time choices ("whether it was +/// kicked") only "for an object on the stack" — a token copy of a permanent is +/// not one, so it never acquires the kicked-ness. CR 702.33a: the Kicker +/// keyword itself IS acquired with the rules text, but it "functions while the +/// spell with kicker is on the stack", so it is inert on a permanent — which is +/// why the seam strips it rather than leaving it. CR 603.4: the entry trigger's +/// intervening "if it was kicked" is therefore checked against a condition that +/// can never hold. +const CITANUL_WOODREADERS_ORACLE: &str = + "Kicker {2}{G} (You may pay an additional {2}{G} as you cast this spell.)\n\ + When this creature enters, if it was kicked, draw two cards."; + +/// CR 707.2 + CR 603.4: the copy seam's post-birth finalize +/// (`finalize_copied_token`) strips spell-casting-only keywords and +/// cast-payment-gated triggers off the token. That runs AFTER the birth is +/// journaled, and the journaled `CopyTokenSpec` still carries the unstripped +/// copiable values — so a replay that only materializes the body installs a +/// token holding a Kicker keyword and an "if it was kicked" trigger the live +/// token does not have. +/// +/// This is the Copy-body half of the injection dispatch in +/// `apply_resolved_token_creation`. It is a separate arm from the ordinary +/// spec-body one on purpose: the Copy arm must NOT call +/// `inject_resolved_token_abilities` (that would grant catalog text the copy is +/// not entitled to), so the two arms cannot be collapsed and each needs its own +/// production-path test. +/// +/// REVERT-PROBE (discriminating, RUN): delete the `ResolvedTokenBody::Copy` arm +/// of that dispatch ⇒ the replayed token keeps `Keyword::Kicker` and the +/// cast-gated trigger, and both assertions below fail. +#[test] +fn copy_token_birth_replays_the_cast_only_strip() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let original = scenario + .add_creature_from_oracle(P0, "Citanul Woodreaders", 1, 4, CITANUL_WOODREADERS_ORACLE) + .id(); + let spell_id = scenario + .add_spell_to_hand_from_oracle(P0, "Replicate", true, REPLICATE_ORACLE) + .with_mana_cost(ManaCost::zero()) + .id(); + + let mut runner = scenario.build(); + // Reach guard on the SOURCE: the fixture only measures a strip if there is + // something to strip. A parser change that stopped producing either of these + // would otherwise turn the parity assertions below into empty == empty. + let source = &runner.state().objects[&original]; + assert!( + source + .keywords + .iter() + .any(|keyword| matches!(keyword, engine::types::keywords::Keyword::Kicker(_))), + "reach guard: the copy source must carry a spell-casting-only keyword, got {:?}", + source.keywords + ); + assert!( + !source.trigger_definitions.is_empty(), + "reach guard: the copy source must carry the kicked-gated entry trigger" + ); + + let committed = runner.cast(spell_id).target_object(original).commit(); + let pre_state = committed.state().clone(); + let journal_start = pre_state.resolved_rules_journal.entries().len(); + + let outcome = committed.resolve(); + let state = outcome.state(); + + let token_id = *state + .last_created_token_ids + .first() + .expect("CR 707.2: the resolved effect must create a copy token"); + let live = &state.objects[&token_id]; + assert_eq!( + live.name, "Citanul Woodreaders", + "CR 707.2 reach guard: the copy acquired the source's copiable name" + ); + // CR 707.2 reach guard: the live strip actually happened, so "replayed == + // live" below is a real constraint rather than a tautology on two + // identically-unstripped objects. + assert!( + !live + .keywords + .iter() + .any(|keyword| matches!(keyword, engine::types::keywords::Keyword::Kicker(_))), + "CR 707.2 + CR 702.33a: the live copy token must not keep Kicker, got {:?}", + live.keywords + ); + + let birth = state + .resolved_rules_journal + .entries() + .iter() + .skip(journal_start) + .filter_map(|entry| entry.command.clone()) + .find_map(|command| match command { + ResolvedRulesCommand::TokenCreation(command) + if command.object.object_id == token_id => + { + Some(command) + } + _ => None, + }) + .expect("the copy birth is journaled"); + // The journaled body is the UNSTRIPPED one — this is what makes the replay + // arm load-bearing rather than a redundant re-application. + let recorded_keywords = match &birth.body { + ResolvedTokenBody::Copy { copy, .. } => copy.values.keywords.clone(), + ResolvedTokenBody::Spec { .. } => panic!("a copy token must journal a Copy body"), + }; + assert!( + recorded_keywords + .iter() + .any(|keyword| matches!(keyword, engine::types::keywords::Keyword::Kicker(_))), + "the recorded copiable values predate the CR 707.2 strip, got {recorded_keywords:?}" + ); + + let mut replay = pre_state; + engine::game::effects::token::apply_resolved_token_creation(&mut replay, &birth) + .expect("the recorded copy birth must replay against its captured predecessor"); + let replayed = &replay.objects[&token_id]; + + // THE DISCRIMINATORS. Body-only materialization keeps both. + assert_eq!( + replayed.keywords, live.keywords, + "CR 707.2 + CR 702.33a: replay must apply the same cast-only keyword strip \ + the live copy seam did" + ); + assert_eq!( + replayed.trigger_definitions.len(), + live.trigger_definitions.len(), + "CR 603.4: replay must strip the same cast-payment-gated trigger — live {:?} vs \ + replayed {:?}", + live.trigger_definitions, + replayed.trigger_definitions + ); +} + #[test] fn copy_token_birth_journals_and_replays_exactly() { let mut scenario = GameScenario::new(); diff --git a/crates/engine/tests/integration/cr733_resolved_token_creation.rs b/crates/engine/tests/integration/cr733_resolved_token_creation.rs index 9b3fb50ebd..f218a35931 100644 --- a/crates/engine/tests/integration/cr733_resolved_token_creation.rs +++ b/crates/engine/tests/integration/cr733_resolved_token_creation.rs @@ -15,6 +15,7 @@ //! the existing component object's id — so it is not in this family. use engine::game::scenario::{GameScenario, P0}; +use engine::types::game_state::GameState; use engine::types::mana::ManaCost; use engine::types::phase::Phase; use engine::types::resolved_commands::ResolvedRulesCommand; @@ -22,6 +23,267 @@ use engine::types::zones::Zone; const SOLDIER_TOKEN_ORACLE: &str = "Create a 1/1 white Soldier creature token."; +/// Verbatim Scryfall Oracle text (Craft with Pride, {1}{R} sorcery). Chosen +/// because its whole text is ONE predefined-token creation: the CR 111.10a +/// Treasure ability is contributed by `inject_resolved_token_abilities`, never +/// by the `TokenSpec` the command carries, so it is exactly the class a +/// body-only replay drops. +const CRAFT_WITH_PRIDE_ORACLE: &str = "Create a Treasure token. (It's an artifact with \"{T}, \ + Sacrifice this token: Add one mana of any color.\")"; + +/// Verbatim Scryfall Oracle text (Audience with Trostani, {2}{G} sorcery). +/// +/// Chosen because it is the production shape this test needs: ONE resolution that +/// creates a token and THEN makes a journaled zone change (the draw's Library → +/// Hand move). A spell's own Stack → Graveyard move is not journaled as a +/// `ZoneChange` command, so a two-spell fixture would leave an unjournaled ledger +/// push between the two families and could not distinguish this defect. +const AUDIENCE_WITH_TROSTANI_ORACLE: &str = "Create a 0/1 green Plant creature token, then draw cards equal to the number of differently named creature tokens you control."; + +fn semantic_commands_after(state: &GameState, window: usize) -> Vec { + state + .resolved_rules_journal + .entries() + .iter() + .skip(window) + .filter_map(|entry| entry.command.clone()) + .collect() +} + +/// CR 400.7 + CR 403.3: a token birth and every later same-turn zone change share +/// ONE per-turn index allocator — `zone_changes_this_turn.len()`. The live token +/// authority records its entry through `restrictions::record_zone_change` +/// (`push_committed_token_entry_events`), so the draw that follows it records +/// index N+1. Replay must record the birth too, or the draw's recorded index is +/// compared against a ledger that never advanced and `apply_resolved_zone_change` +/// fails closed. +/// +/// This is the composition the rest of the `cr733_*` suite cannot see: those +/// fixtures apply ONE command against a captured predecessor, and a birth alone +/// asserts nothing about the ledger it must advance. +/// +/// REVERT-PROBE (discriminating, RUN): delete the `record_zone_change` call in +/// `apply_resolved_token_creation` ⇒ the draw's replay returns +/// `TurnRecordIndexMismatch { expected: 2, found: 1 }` and this test fails. +#[test] +fn token_birth_then_same_turn_zone_change_replays_in_index_lockstep() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = scenario + .add_spell_to_hand_from_oracle( + P0, + "Audience with Trostani", + false, + AUDIENCE_WITH_TROSTANI_ORACLE, + ) + .with_mana_cost(ManaCost::zero()) + .id(); + scenario.with_library_top(P0, &["First", "Second", "Third"]); + + let mut runner = scenario.build(); + let committed = runner.cast(spell).commit(); + let pre_state = committed.state().clone(); + let window = pre_state.resolved_rules_journal.entries().len(); + + let outcome = committed.resolve(); + let state = outcome.state().clone(); + let commands = semantic_commands_after(&state, window); + + // ── reach guards: the fixture really produced the composition under test ── + let token_id = *state + .last_created_token_ids + .first() + .expect("CR 111.1: the resolution must create the Plant token"); + let live_token_record = state + .zone_changes_this_turn + .iter() + .find(|record| record.object_id == token_id && record.to_zone == Zone::Battlefield) + .expect("CR 400.7: the live birth reaches the per-turn zone-change ledger") + .clone(); + let follower = commands + .iter() + .find_map(|command| match command { + ResolvedRulesCommand::ZoneChange(command) + if command.from == Zone::Library && command.to == Zone::Hand => + { + Some(command.clone()) + } + _ => None, + }) + .expect("the draw must journal a Library → Hand zone change AFTER the birth"); + // Without this the replay below would be trivially green: an index recorded at + // or below the birth's own slot cannot detect a ledger that failed to advance. + assert!( + follower.turn_zone_change_index > live_token_record.turn_zone_change_index, + "the journaled follower must sit PAST the birth on the ledger, got follower {} vs birth {}", + follower.turn_zone_change_index, + live_token_record.turn_zone_change_index + ); + + // ── replay the whole recorded window in order ── + let mut replay = pre_state; + for command in &commands { + match command { + ResolvedRulesCommand::TokenCreation(command) => { + engine::game::effects::token::apply_resolved_token_creation(&mut replay, command) + .expect("the recorded birth replays"); + } + ResolvedRulesCommand::ZoneChange(command) => { + // THE DISCRIMINATOR. A birth that records nothing leaves the ledger + // one entry short and this is `Err(TurnRecordIndexMismatch)`. + engine::game::zones::apply_resolved_zone_change(&mut replay, command) + .unwrap_or_else(|error| { + panic!( + "the recorded zone change must replay in index lockstep with the \ + token birth that preceded it: {error}" + ) + }); + } + ResolvedRulesCommand::StackRemoval(command) => { + engine::game::stack::apply_resolved_stack_removal(&mut replay, command.as_ref()) + .expect("the recorded stack removal replays"); + } + ResolvedRulesCommand::LedgerEdit(command) => { + engine::game::ledger::apply_resolved_ledger_edit(&mut replay, command) + .expect("the recorded ledger edit replays"); + } + other => panic!( + "fixture drift: this resolution journaled an unexpected family {other:?}; the \ + replay above must cover every command in the window, not a filtered subset" + ), + } + } + + // The birth landed on the replayed ledger at the SAME slot, carrying the same + // record the live authority pushed — index parity alone would also be + // satisfied by a placeholder that trigger look-back could not read. + let replayed_token_record = replay + .zone_changes_this_turn + .iter() + .find(|record| record.object_id == token_id && record.to_zone == Zone::Battlefield) + .expect("replay records the birth on the per-turn zone-change ledger"); + assert_eq!( + *replayed_token_record, live_token_record, + "the reconstructed entry record must equal the one the live birth recorded" + ); + assert_eq!( + replay + .zone_changes_this_turn + .iter() + .position(|record| record.object_id == follower.object.object_id + && record.to_zone == Zone::Hand), + Some(follower.turn_zone_change_index), + "the follower occupies its recorded ledger slot after replay" + ); + // CR 403.3: `record_zone_change` performs the battlefield-entry bookkeeping, + // so the replayed token is visible to entered-this-turn queries exactly once. + assert_eq!( + replay + .battlefield_entries_this_turn + .iter() + .filter(|record| record.object_id == token_id) + .count(), + 1, + "the replayed birth records exactly one CR 403.3 battlefield entry" + ); +} + +/// CR 111.3 + CR 111.10a: a predefined token's abilities are contributed by the +/// creating effect's injection pass (`inject_resolved_token_abilities`), NOT by +/// the `TokenSpec` the birth command carries — the spec for "Create a Treasure +/// token." holds a name, types and colors and no ability at all. A replay that +/// only materializes the body therefore installs an ABILITYLESS Treasure that +/// can never be sacrificed for mana. +/// +/// This class is structurally invisible to the record-equality assertion in +/// `token_birth_then_same_turn_zone_change_replays_in_index_lockstep`: +/// `ZoneChangeRecord`'s ability surface is `trigger_definitions` only, and the +/// Treasure ability is ACTIVATED, so the two records compare equal while the +/// objects differ. The assertion below reads the OBJECT. +/// +/// REVERT-PROBE (discriminating, RUN): delete the injection dispatch in +/// `apply_resolved_token_creation` ⇒ replayed `base_abilities` is empty while +/// live holds one, and this test fails on the count assertion. +#[test] +fn predefined_token_birth_replays_its_injected_abilities() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Craft with Pride", false, CRAFT_WITH_PRIDE_ORACLE) + .with_mana_cost(ManaCost::zero()) + .id(); + + let mut runner = scenario.build(); + let committed = runner.cast(spell).commit(); + let pre_state = committed.state().clone(); + let journal_start = pre_state.resolved_rules_journal.entries().len(); + + let outcome = committed.resolve(); + let state = outcome.state(); + + let token_id = *state + .last_created_token_ids + .first() + .expect("CR 111.1: the resolution must create the Treasure token"); + let live = &state.objects[&token_id]; + // Reach guard: this really is the predefined-token class, and the injection + // really ran live. Without it the parity assertion below would be satisfied + // by empty == empty on any vanilla token. + assert!( + live.card_types + .subtypes + .iter() + .any(|subtype| subtype == "Treasure"), + "CR 111.10a reach guard: the fixture must produce a Treasure token, got {:?}", + live.card_types.subtypes + ); + assert_eq!( + live.base_abilities.len(), + 1, + "CR 111.10a reach guard: the live Treasure carries its injected sacrifice-for-mana \ + ability, so a replay that drops it is observable" + ); + + let birth = state + .resolved_rules_journal + .entries() + .iter() + .skip(journal_start) + .filter_map(|entry| entry.command.clone()) + .find_map(|command| match command { + ResolvedRulesCommand::TokenCreation(command) + if command.object.object_id == token_id => + { + Some(command) + } + _ => None, + }) + .expect("the Treasure birth is journaled"); + + let mut replay = pre_state; + engine::game::effects::token::apply_resolved_token_creation(&mut replay, &birth) + .expect("the recorded birth must replay against its captured predecessor"); + let replayed = &replay.objects[&token_id]; + + // THE DISCRIMINATOR. Body-only materialization gives 0 here. + assert_eq!( + replayed.base_abilities.len(), + live.base_abilities.len(), + "CR 111.3: replay must contribute the same injected abilities the live \ + creation did — live {:?} vs replayed {:?}", + live.base_abilities, + replayed.base_abilities + ); + assert_eq!( + replayed.base_abilities, live.base_abilities, + "CR 111.10a: the replayed Treasure carries the SAME ability, not merely as many" + ); + assert_eq!( + replayed.token_rules_text, live.token_rules_text, + "CR 111.10: the injected display rules text is part of the same payload" + ); +} + #[test] fn token_creation_journals_an_exact_resolved_birth() { let mut scenario = GameScenario::new(); diff --git a/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs b/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs index 32fff3eab0..26b916dc84 100644 --- a/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs +++ b/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs @@ -294,18 +294,25 @@ fn kilo_reinjected_pinless_history_suppresses_offer() { ); } -/// Drive the APNAP accept of the ∞ offer through the PUBLIC `apply()` boundary: P0 (the -/// proposer) declares `Fixed(1)`, then every prompted opponent accepts in turn order until the -/// protocol closes back to ordinary priority (CR 800.4a). `template: None` skips declare-time -/// pin validation; the materialize re-derives from the intact `last_loop_action_sequence`. +/// Drive the APNAP accept of the ∞ offer through the PUBLIC `apply()` boundary at the harness +/// default of one cycle. CR 732.2c makes the accepted count BINDING on the boundary collapse +/// prompt, so a caller that later collapses to N must use [`drive_all_accept_n`]. fn drive_all_accept(state: &mut GameState) { + drive_all_accept_n(state, 1); +} + +/// Drive the APNAP accept at `n`: P0 (the proposer) declares `Fixed(n)`, then every prompted +/// opponent accepts in turn order until the protocol closes back to ordinary priority (CR +/// 800.4a). `template: None` skips declare-time pin validation; the materialize re-derives from +/// the intact `last_loop_action_sequence`. CR 732.2c: `n` bounds the CR 500.5 collapse prompt. +fn drive_all_accept_n(state: &mut GameState, n: u32) { use engine::analysis::decision_template::IterationCount; use engine::analysis::loop_check::ShortcutResponse; apply( state, P0, GameAction::DeclareShortcut { - count: IterationCount::Fixed(1), + count: IterationCount::Fixed(n), template: None, }, ) @@ -409,9 +416,40 @@ fn kilo_accept_marks_pentad_charge_as_unbounded_display_target() { "display-only: Pentad's REAL charge count is unchanged by the ∞ mark (CR 701.34a)" ); - // (3) DERIVED VIEW (FLIPS on revert): the projection surfaces Pentad's charge as ∞ for the - // FE, filtered to battlefield objects. - let views = derive_views(&state, None); + // (3a) R6a (CR 732.2c) — THE PER-SURFACE COUNTER-PILL GATE, on a REAL production fixture. + // This accept registers an observed-growth `DriveSequence` naming the charge-counter axis, + // so the collapse is already bounded at the accepted N and the pill must stop rendering ∞ + // in lockstep with its resource badge — a HUD that hides one and shows the other is + // internally inconsistent. Filter the PROJECTION, never the store: (2) above (the store + // write) is unchanged and still passes. + // + // REVERT-PROBE: delete the `collapse_scheduled(..)` guard in `derive_views`' counter-pill + // loop ⇒ the pill re-renders ⇒ THIS assertion FAILS while the pile and badge gates stay + // green (so a one-surface regression is visible). + assert!( + derive_views(&state, None).unbounded_counters.is_empty(), + "CR 732.2c: a scheduled finite collapse hides the ∞ charge pill (the store keeps it)" + ); + + // (3b) DERIVED VIEW (FLIPS on revert): with nothing scheduled the projection surfaces + // Pentad's charge as ∞ for the FE, filtered to battlefield objects. + // + // NOT A SYNTHETIC STATE — "counter targets present, stash absent" is engine-reachable, + // and this is the production sequence (cited so the next auditor need not re-derive it): + // a CR 732.2b DECLINE of the batched counter axis. A counter OBSERVER drifts onto the + // board inside the accept→boundary window, so at `SubmitPayAmount` the handler's + // `take_pending_materialization` empties the stash FIRST, then the `Counters` arm hits + // `if counter_observed_now { continue; }` (`game::engine_resolution_choices`) and never + // pushes that item into `collapsed`. `clear_collapsed_materializations`' `surviving_ + // targets` filter (`types::game_state`) therefore filters nothing ⇒ `unbounded_counter_ + // targets` is preserved with the stash already gone. + // The shared "display channel survives a stash the submit already emptied" half is ASSERTED + // (not merely argued) by the sibling `combo_infinite_pile::med_tokens_boundary_mint_pause_ + // preserves_replacement_choice`, whose closing two assertions measure exactly that shape on + // the pile channel after a real `SubmitPayAmount`. + let mut unscheduled = state.clone(); + unscheduled.pending_unbounded_materialization.clear(); + let views = derive_views(&unscheduled, None); assert_eq!( views.unbounded_counters.get(&PENTAD), Some(&vec![charge.clone()]), @@ -492,7 +530,8 @@ fn kilo_accept_collapses_at_boundary_to_exactly_n_counters() { // `materialize_object_growth_shortcut`, where `counter_growth_is_observed` is true for the // real proliferate loop, so a DriveSequence stash is REGISTERED (not grafted). Accept is // display-only: the real count is deferred to the boundary. - drive_all_accept(&mut state); + // CR 732.2c: the accepted count binds the boundary collapse, so accept at exactly N. + drive_all_accept_n(&mut state, N); assert_eq!( state.objects[&PENTAD].counters.get(&charge).copied(), Some(baseline), diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index bdba6d1fab..e77f9aea16 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -6288,3 +6288,536 @@ fn phase_reachable_ledger_observer_whose_filter_matches_the_class_still_suppress runner.state().waiting_for ); } + +// =========================================================================== +// R6a — the ∞ badge is a lie once the collapse is SCHEDULED, and CR 732.2c +// bounds the boundary prompt by the count the table accepted. +// =========================================================================== + +/// Sprout Swarm in P0's hand in the `witherbloom_sprout_lumaret_simple_4p` capture. +const R6A_SPROUT: ObjectId = ObjectId(405); +/// The one untapped P0 Saproling in that capture — the {G} convoke fodder. +const R6A_FODDER: ObjectId = ObjectId(1412); + +/// Load the simple 4p Witherbloom/Sprout capture and drive one real buyback+convoke +/// recast through the cast pipeline, returning the state AT the CR 732.2a offer. +fn r6a_offer_state() -> GameState { + let mut state = restore_dump(&gunzip_dump(include_bytes!( + "../fixtures/witherbloom_sprout_lumaret_simple_4p.json.gz" + ))); + state.loop_detection = LoopDetectionMode::On; + let outcome = GameRunner::from_state(state) + .cast(R6A_SPROUT) + .accept_optional() + .convoke_with(&[R6A_FODDER]) + .commit() + .resolve(); + outcome.state().clone() +} + +/// Proposer declares `Fixed(n)`; every living opponent accepts (APNAP). +fn r6a_declare_and_accept_all(state: &mut GameState, proposer: PlayerId, n: u32) { + apply( + state, + proposer, + GameAction::DeclareShortcut { + count: IterationCount::Fixed(n), + template: None, + }, + ) + .expect("the proposer declares the object-growth shortcut"); + while let WaitingFor::RespondToShortcut { player, .. } = state.waiting_for.clone() { + apply( + state, + player, + GameAction::RespondToShortcut { + response: ShortcutResponse::Accept, + }, + ) + .expect("each living opponent accepts"); + } +} + +/// Pass priority through the real production path until the CR 500.5 step/phase +/// boundary surfaces a non-`Priority` prompt (the `LoopCollapse` pay-amount) or the +/// phase advances with no prompt. Bounded so a wedge fails loudly. +fn r6a_drive_to_boundary(state: &mut GameState) { + let start_phase = state.phase; + for _ in 0..64 { + let WaitingFor::Priority { player } = state.waiting_for.clone() else { + return; + }; + apply(state, player, GameAction::PassPriority) + .expect("pass priority toward the next phase boundary"); + if !matches!(state.waiting_for, WaitingFor::Priority { .. }) || state.phase != start_phase { + return; + } + } + panic!("r6a_drive_to_boundary: no phase boundary within 64 passes"); +} + +/// R6a-1 (PRIMARY). MEASURED DEFECT: accepting the Witherbloom/Sprout loop writes +/// `unbounded_resources = {P0: [Life(0), TokensCreated]}` and that mark survives to the +/// CR 500.5 boundary, so the HUD renders an "∞ Life" badge beside P0's *finite*, growing +/// life total. CR 732.2c: once the last player accepted, the shortcut IS taken at the +/// named `Fixed(N)` — the growth is bounded, so no `∞` row may render for a scheduled axis. +/// +/// FILTER THE PROJECTION, NEVER THE STORE. The store must still carry the mark (it is what +/// CR 104.4b / CR 110.1 lockstep and `zones::apply_zone_exit_cleanup`'s defuse read until +/// the boundary applies the growth), so this row asserts BOTH halves. +/// +/// §15 NON-VACUITY: the emptiness assertion is paired with the store's NON-emptiness and a +/// non-empty `unbounded_loop_pile` — the same `state` the projection reads is measurably +/// populated, so the instrument demonstrably CAN report a row. Emptiness is asserted on the +/// WIRE (`derive_views`), never on `state`. +/// +/// REVERT-PROBES (both RUN, both observed to fail): +/// ⓐ delete the `if scheduled.contains(&axis) { continue; }` filter in `derive_views` +/// ⇒ the `Life(0)` and `TokensCreated` rows render ⇒ assertion (3) FAILS. +/// ⓑ make `GameState::scheduled_collapse_axes` return an empty set ⇒ (3) FAILS the same +/// way AND `clear_collapsed_materializations` stops removing at the boundary — which is +/// what proves the projection and the collapse share ONE authority rather than two +/// copies of the same match. +#[test] +fn scheduled_collapse_renders_no_unbounded_badge() { + let mut state = r6a_offer_state(); + + // (0) reach-guard: the real cast reached the CR 732.2a offer. + assert!( + matches!(state.waiting_for, WaitingFor::LoopShortcut { proposer, .. } if proposer == P0), + "reach-guard: the buyback+convoke recast must surface P0's offer, got {:?}", + state.waiting_for + ); + + r6a_declare_and_accept_all(&mut state, P0, 200); + + // (1) POSITIVE CONTROL — the accept really marked the ∞ axes in the STORE. Without + // this the emptiness in (3) would be vacuous. + let marked = state + .unbounded_resources + .get(&P0) + .expect("accept must mark P0's ∞ axes in the store") + .clone(); + assert!( + marked.contains(&ResourceAxis::Life(P0)), + "MEASURED defect axis: the accept marks Life(P0) ∞, got {marked:?}" + ); + assert!( + marked.contains(&ResourceAxis::TokensCreated), + "the accept marks TokensCreated ∞, got {marked:?}" + ); + assert!( + !state.unbounded_loop_pile.is_empty(), + "the object-growth accept writes a non-empty ∞ pile (store still populated)" + ); + assert_eq!( + state.pending_unbounded_materialization.len(), + 1, + "exactly one controller has a scheduled collapse" + ); + // The growth really is finite: P0's life is a concrete number, not ∞. + let life = state.players.iter().find(|p| p.id == P0).unwrap().life; + assert!( + life > 0, + "the axis the badge lies about is a finite life total, got {life}" + ); + + // (2) FAIL-CLOSED CONTROL, in the SAME state: every ∞ axis the accept scheduled is + // covered by the shared authority. An axis it does not name keeps its badge (R6a-2/-3). + let scheduled = state.scheduled_collapse_axes( + state + .pending_unbounded_materialization + .get(&P0) + .expect("stash present"), + ); + assert!( + marked.iter().all(|a| scheduled.contains(a)), + "every marked axis on this board is scheduled; marked={marked:?} scheduled={scheduled:?}" + ); + + // (3) DISCRIMINATOR — on the WIRE, for EVERY viewer (and the spectator view), no ∞ row. + // ALL THREE ∞ surfaces share the one authority, so the HUD can never hide a resource badge + // while a card group still renders ∞. The PER-SURFACE positive rows live on their own real + // fixtures — `combo_infinite_pile::real_4p_object_growth_accept_writes_infinite_pile` (pile) + // and `kilo_live_offer_from_real_dump::kilo_accept_marks_pentad_charge_as_unbounded_display_ + // target` (counter pills) — so a regression on ONE surface stays visible even though this + // row flips on all of them at once. + for viewer in [None, Some(P0), Some(P1), Some(P2), Some(PlayerId(3))] { + let views = engine::game::derived_views::derive_views(&state, viewer); + assert!( + views.unbounded_resources.is_empty(), + "CR 732.2c: a scheduled finite collapse must render NO ∞ row (viewer {viewer:?}), \ + got {:?}", + views.unbounded_resources + ); + assert!( + views.unbounded_pile.is_empty(), + "CR 732.2c: ...and no ∞ card group beside it (viewer {viewer:?}), got {:?}", + views.unbounded_pile + ); + } + + // (3b) A TRIPWIRE, NOT A SECOND PRODUCER. Multiplayer broadcasts (`phase-server`) and the + // WASM `wrap_filtered` getter go through `derive_filtered_views`, which CALLS + // `derive_views(filtered_state, viewer)` and then overrides only + // `unique_authorized_submitter` and `blocker_assignment_pairs`. It WRAPS; it does not + // bypass. So gating in `derive_views` alone could not have leaked ∞ to the broadcast + // path — there is no other producer of these three fields, and this row costs zero + // production code. + // + // What it DOES guard is the INPUT: `filter_state_for_viewer` is a clone-and-redact with + // ZERO `unbounded` references today, so it passes `pending_unbounded_materialization` + // through unredacted and the gate sees the same stash the hot-seat viewer does. If a + // future redaction ever drops that stash from the filtered clone, the gate goes silently + // INERT on the broadcast path only — filtered viewers get the ∞ rows back while the local + // viewer does not. That is the regression this row catches. + for viewer in [P0, P1, P2, PlayerId(3)] { + let filtered = engine::game::visibility::filter_state_for_viewer(&state, viewer); + let views = + engine::game::derived_views::derive_filtered_views(&state, &filtered, Some(viewer)); + assert!( + views.unbounded_resources.is_empty() && views.unbounded_pile.is_empty(), + "CR 732.2c: the viewer-FILTERED broadcast path hides the same rows (viewer \ + {viewer:?}), got {:?} / {:?}", + views.unbounded_resources, + views.unbounded_pile + ); + } + + // (4) THE STORE IS UNTOUCHED — the projection filtered, it did not mutate. + assert_eq!( + state.unbounded_resources.get(&P0), + Some(&marked), + "the ∞ store must survive the projection (CR 104.4b / CR 110.1 lockstep + the \ + zone-exit defuse still need it until the boundary)" + ); + assert!( + !state.unbounded_loop_pile.is_empty(), + "the ∞ pile must survive the projection too" + ); +} + +/// R6a-3 (FAIL-CLOSED). An ∞ axis the accept marked but NO registered materialization +/// collapses must keep rendering its badge — the filter is keyed on what is actually +/// scheduled, never on what is merely *labellable*. +/// +/// This is the row that kills the lazy-but-unsound filter: `LoopCollapseAxis` +/// `from_resource_axis` maps `TokensCreated` / `Counter(..)` / `Life(..)` to a label, so +/// building the hide-set from "does this axis have a collapse label" is a one-liner that +/// passes R6a-1 and silently hides an axis nothing will ever collapse. +/// +/// REVERT-PROBE (RUN): build the projection filter from +/// `LoopCollapseAxis::from_resource_axis(axis).is_some()` instead of from +/// `scheduled_collapse_axes` ⇒ this row's `TokensCreated` badge vanishes ⇒ FAILS, while +/// R6a-1 still passes. +#[test] +fn unregistered_axis_still_renders_its_infinity_badge() { + let mut state = r6a_offer_state(); + assert!( + matches!(state.waiting_for, WaitingFor::LoopShortcut { proposer, .. } if proposer == P0), + "reach-guard: at the offer, got {:?}", + state.waiting_for + ); + r6a_declare_and_accept_all(&mut state, P0, 200); + + // Keep the marks, DROP the registrations: the exact shape of an axis that is + // collapsible-LABELLED but has nothing scheduled to collapse it. + let marked = state + .unbounded_resources + .get(&P0) + .expect("accept marked the ∞ axes") + .clone(); + assert!( + marked.contains(&ResourceAxis::TokensCreated) && marked.contains(&ResourceAxis::Life(P0)), + "reach-guard: both labellable axes are marked, got {marked:?}" + ); + // Positive control on the SAME state, BEFORE the drop: with the stash present the rows + // are hidden, so the flip below is attributable to the missing registration alone. + assert!( + engine::game::derived_views::derive_views(&state, None) + .unbounded_resources + .is_empty(), + "control: with the stash present the scheduled rows are hidden" + ); + state.pending_unbounded_materialization.clear(); + + let rows = engine::game::derived_views::derive_views(&state, None).unbounded_resources; + let axes: Vec = rows.iter().map(|r| r.axis).collect(); + assert!( + axes.contains(&ResourceAxis::TokensCreated), + "FAIL-CLOSED: a collapsible-LABELLED axis with NO registered materialization is \ + still unbounded and must keep its ∞ badge, got {axes:?}" + ); + assert!( + axes.contains(&ResourceAxis::Life(P0)), + "FAIL-CLOSED: same for the life axis, got {axes:?}" + ); +} + +/// R4-C4b (CR 732.2c). "Once the last player has either accepted or shortened the shortcut +/// proposal, the shortcut is taken" — its ending point is fixed at the accepted N, so the +/// CR 500.5 boundary collapse prompt may not offer a WIDER range than the table agreed to. +/// BASE re-asked with `max = MAX_SHORTCUT_CYCLES` (1000), letting a controller who proposed +/// 7 cycles walk away with 1000. +/// +/// REVERT-PROBE (RUN): restore `max: crate::game::engine::MAX_SHORTCUT_CYCLES` ⇒ `max` +/// reads 1000 ⇒ FAILS. `min: 0` is asserted unchanged (a collapse-to-nothing stays legal). +#[test] +fn accepted_fixed_count_bounds_the_boundary_collapse_prompt() { + let mut state = r6a_offer_state(); + assert!( + matches!(state.waiting_for, WaitingFor::LoopShortcut { proposer, .. } if proposer == P0), + "reach-guard: at the offer, got {:?}", + state.waiting_for + ); + r6a_declare_and_accept_all(&mut state, P0, 7); + assert!( + state.pending_unbounded_materialization.contains_key(&P0), + "reach-guard: the accept scheduled a collapse, so the boundary WILL prompt" + ); + + r6a_drive_to_boundary(&mut state); + + match &state.waiting_for { + WaitingFor::PayAmountChoice { + player, + resource: engine::types::game_state::PayableResource::LoopCollapse { .. }, + min, + max, + .. + } => { + assert_eq!(*player, P0, "the loop controller is prompted"); + assert_eq!( + *max, 7, + "CR 732.2c: the accepted Fixed(7) bounds the collapse prompt (BASE: 1000)" + ); + assert_eq!(*min, 0, "a collapse-to-nothing stays legal"); + } + other => { + panic!("the CR 500.5 boundary must prompt P0 for the collapse count, got {other:?}") + } + } + + // REJECTION DISCRIMINATOR — the bound is ENFORCED by the reducer, not merely advertised + // in the prompt. This is the control a widened-`max` BASE cannot pass: with + // `max = MAX_SHORTCUT_CYCLES` a submit of 8 is ACCEPTED, so this assertion is what makes + // the range assertion above load-bearing rather than cosmetic. + let over = apply(&mut state, P0, GameAction::SubmitPayAmount { amount: 8 }); + assert!( + matches!(&over, Err(EngineError::InvalidAction(msg)) if msg.contains("[0, 7]")), + "CR 732.2c: collapsing PAST the accepted count must be rejected, got {over:?}" + ); + + // The bound is honored end-to-end: submitting exactly N is still accepted. + apply(&mut state, P0, GameAction::SubmitPayAmount { amount: 7 }) + .expect("collapsing at exactly the accepted count is legal"); +} + +/// R6a FIX-2 (CR 732.2c). MEASURED DEFECT in the first cut of the collapse bound: the stash +/// `register_pending_materialization` APPENDS ("two accepts by the same controller, coexist"), +/// but the bound was written with a bare `insert`, i.e. it OVERWROTE. A controller who accepts +/// `Fixed(1)` and then, in the SAME phase, accepts `Fixed(1000)` therefore ends up with a +/// two-item stash bounded at 1000 — and since the boundary applies ONE submitted amount to +/// EVERY item, the first accept's loop would materialize 1000 times though the table agreed to +/// exactly one. +/// +/// SHIPPED SEMANTICS, PINNED HERE EXPLICITLY: the bound is the MINIMUM of the accepted counts. +/// The second accept's agreed 1000 is UNDER-delivered down to 1. That is still a divergence +/// from what the table agreed to — but it is the safe polarity: no accept in the stash can ever +/// be over-materialized, which is the CR 732.2c violation ("the shortcut is taken" at the count +/// the last player accepted, not at some later, larger one). The exact per-accept bound needs +/// the flat stash to become accept-grouped and is deliberately NOT smuggled in here; the +/// boundary's pause-safety `sort_by_key` reorders that flat list, so a positional parallel +/// bound vector is not a valid shortcut to it. +/// +/// REVERT-PROBE (RUN): restore `pending_materialization_count.insert(proposal.proposer, n)` in +/// `materialize_fixed_shortcut` ⇒ the bound reads 1000, the prompt offers `max == 1000`, and the +/// out-of-range submit is ACCEPTED ⇒ assertions (4), (5) and (6) FAIL. +#[test] +fn two_accepts_in_one_phase_bound_the_collapse_to_the_smallest_accepted_count() { + let mut state = r6a_offer_state(); + + // (1) reach-guard: the first real cast reached the CR 732.2a offer. + assert!( + matches!(state.waiting_for, WaitingFor::LoopShortcut { proposer, .. } if proposer == P0), + "reach-guard: the first buyback+convoke recast must surface P0's offer, got {:?}", + state.waiting_for + ); + let phase_at_first_accept = state.phase; + r6a_declare_and_accept_all(&mut state, P0, 1); + assert_eq!( + state.pending_materialization_count.get(&P0).copied(), + Some(1), + "the first accept records its own Fixed(1) bound" + ); + + // (2) The buyback returned Sprout Swarm to hand and priority came back, so a SECOND real + // cast is available in the SAME phase — this is what makes the append reachable at all. + let fodder = *state + .battlefield + .iter() + .find(|id| { + state + .objects + .get(id) + .is_some_and(|o| o.controller == P0 && !o.tapped && o.name.contains("Saproling")) + }) + .expect("an untapped P0 Saproling remains to convoke the second cast"); + let mut state = GameRunner::from_state(state) + .cast(R6A_SPROUT) + .accept_optional() + .convoke_with(&[fodder]) + .commit() + .resolve() + .state() + .clone(); + + // (3) reach-guard: the second cast really produced a second offer, in the same phase. + assert!( + matches!(state.waiting_for, WaitingFor::LoopShortcut { proposer, .. } if proposer == P0), + "reach-guard: the second recast must surface a second offer, got {:?}", + state.waiting_for + ); + assert_eq!( + state.phase, phase_at_first_accept, + "both accepts land in ONE phase, so they share ONE stash and ONE boundary prompt" + ); + r6a_declare_and_accept_all(&mut state, P0, 1000); + + // (4) THE PREMISE + THE FIX. The stash APPENDED (two items, one boundary amount for both), + // and the bound is the MINIMUM — not the latest write. + assert_eq!( + state + .pending_unbounded_materialization + .get(&P0) + .map(Vec::len), + Some(2), + "premise: the two accepts coexist in ONE stash, so ONE amount will scale BOTH" + ); + assert_eq!( + state.pending_materialization_count.get(&P0).copied(), + Some(1), + "CR 732.2c: min(1, 1000) — the later Fixed(1000) may NOT re-scale the Fixed(1) accept \ + (BASE overwrite: 1000)" + ); + + let p0_permanents = |s: &GameState| { + s.battlefield + .iter() + .filter(|id| s.objects.get(id).is_some_and(|o| o.controller == P0)) + .count() + }; + let permanents_before = p0_permanents(&state); + let life_before = state.players.iter().find(|p| p.id == P0).unwrap().life; + + r6a_drive_to_boundary(&mut state); + + // (5) The prompt advertises the minimum. + let WaitingFor::PayAmountChoice { + player, + resource: engine::types::game_state::PayableResource::LoopCollapse { .. }, + max, + .. + } = &state.waiting_for + else { + panic!( + "the CR 500.5 boundary must prompt P0 for the collapse count, got {:?}", + state.waiting_for + ) + }; + assert_eq!(*player, P0, "the loop controller is prompted"); + assert_eq!( + *max, 1, + "CR 732.2c: the prompt is bounded by the SMALLEST accepted count (BASE: 1000)" + ); + + // (6) And the reducer ENFORCES it — the second accept's agreed 1000 is unreachable. + let over = apply(&mut state, P0, GameAction::SubmitPayAmount { amount: 1000 }); + assert!( + matches!(&over, Err(EngineError::InvalidAction(msg)) if msg.contains("[0, 1]")), + "CR 732.2c: the later accept's 1000 cannot be collapsed at, got {over:?}" + ); + + // (7) WHAT B'S 1000 ACTUALLY BECOMES: exactly 1. Each of the two stashed sequences replays + // ONCE — one new token and one life per sequence — so the first accept keeps precisely the + // single cycle the table agreed to, and the second is capped down to the same. + // + // NOT the BASE discriminator, and deliberately not claimed as one: this submits + // `amount: 1`, which the BASE overwrite ALSO materializes as Δ2. A bare-`insert` revert + // probe was RUN and fails only at assertion (5) (`left: Some(1000)`). What BASE gets + // wrong is that it ADVERTISES `max: 1000` and PERMITS a 1000× submit — assertions (4), + // (5) and (6) are the rows that catch that. This row exists to pin the post-collapse + // board, i.e. that the enforced bound is also the delivered one. + apply(&mut state, P0, GameAction::SubmitPayAmount { amount: 1 }) + .expect("collapsing at the minimum accepted count is legal"); + assert_eq!( + p0_permanents(&state) - permanents_before, + 2, + "one materialized cycle per stashed accept, never 1000" + ); + assert_eq!( + state.players.iter().find(|p| p.id == P0).unwrap().life - life_before, + 2, + "same for the life axis: one cycle per stashed accept" + ); +} + +/// R6a FIX-4 (CR 732.2c). The AI's `LoopCollapse` candidate was a hardcoded `amount: 1`, from +/// when the prompt's `max` was the fixed engine-wide `MAX_SHORTCUT_CYCLES`. Binding `max` to +/// the accepted count makes `max == 0` reachable — a shortcut everyone accepted at `Fixed(0)` +/// — and the reducer rejects `amount > max`, so the generator's SOLE candidate would be +/// illegal and an AI-seated controller would have no legal action at this prompt. +/// +/// Driven end-to-end: a real cast → a real `Fixed(0)` declaration → real APNAP accepts → the +/// real CR 500.5 boundary prompt → the production `ai_support::legal_actions` generator → the +/// production `apply()` reducer. +/// +/// REVERT-PROBE (RUN, MEASURED): restore `GameAction::SubmitPayAmount { amount: 1 }` in +/// `ai_support::candidates` ⇒ `legal_actions` returns `[]`. `legal_actions` validates its +/// candidates against the reducer, so the illegal `amount: 1` is not merely rejected on +/// submit — it is dropped, leaving the AI with NO legal action at this prompt. Assertion (3) +/// FAILS (`left: []`). +#[test] +fn ai_collapse_candidate_is_clamped_to_the_accepted_bound() { + let mut state = r6a_offer_state(); + assert!( + matches!(state.waiting_for, WaitingFor::LoopShortcut { proposer, .. } if proposer == P0), + "reach-guard: at the offer, got {:?}", + state.waiting_for + ); + r6a_declare_and_accept_all(&mut state, P0, 0); + r6a_drive_to_boundary(&mut state); + + // (1) reach-guard: a `Fixed(0)` accept really does register a stash and really does prompt. + // (2) ...with the zero-width range the clamp exists for. + let WaitingFor::PayAmountChoice { + resource: engine::types::game_state::PayableResource::LoopCollapse { .. }, + min, + max, + .. + } = &state.waiting_for + else { + panic!( + "reach-guard: a Fixed(0) accept must still reach the boundary prompt, got {:?}", + state.waiting_for + ) + }; + assert_eq!( + (*min, *max), + (0, 0), + "CR 732.2c: Fixed(0) bounds the prompt to exactly 0" + ); + + // (3) The production candidate generator offers the clamped amount (BASE: a hardcoded 1). + let candidates = engine::ai_support::legal_actions(&state); + assert_eq!( + candidates, + vec![GameAction::SubmitPayAmount { amount: 0 }], + "the AI's sole collapse candidate is clamped to the accepted bound" + ); + + // (4) ...and it is actually LEGAL — the assertion that makes (3) load-bearing rather than + // a restatement of the generator. + apply(&mut state, P0, candidates[0].clone()) + .expect("the AI's generated candidate must be accepted by the reducer"); +} diff --git a/crates/engine/tests/integration/loop_shortcut_mana_engine.rs b/crates/engine/tests/integration/loop_shortcut_mana_engine.rs index 14a4dc291e..75db5c1750 100644 --- a/crates/engine/tests/integration/loop_shortcut_mana_engine.rs +++ b/crates/engine/tests/integration/loop_shortcut_mana_engine.rs @@ -804,3 +804,368 @@ fn cond_a_nontargeted_opponent_depletion_noops_at_exhaustion_not_abort() { no finite opp-fuel loop exists ⇒ the break-on-err is a defensive guard (PATH-2)" ); } + +/// R6a-2 (FAIL-CLOSED DISCRIMINATOR for the CR 732.2c ∞-badge filter). A mana engine +/// registers NO deferred materialization — `current_period_fodder` finds no fodder, +/// `current_period_counter_growth` / `current_period_life_growth` are empty — so nothing +/// will ever collapse its `Mana(_)` axis at the CR 500.5 boundary. It is therefore still +/// genuinely unbounded within the phase (`refill_infinite_mana` holds the pool at +/// `INFINITE_MANA_PER_TYPE`) and MUST keep rendering its `∞` row on the wire. +/// +/// The 12 shipped rows in this file are the must-NOT-flip control set; THIS is the new +/// discriminator. It is the fail-closed half of the R6a filter: R6a-1 proves a scheduled +/// axis hides, this proves an UNscheduled one does not. +/// +/// REVERT-PROBE (RUN): key the `derive_views` filter on the ACCEPTED COUNT +/// (`state.pending_materialization_count.contains_key(&controller)`) instead of on the +/// axis set `scheduled_collapse_axes` returns — the count-keyed filter is written at every +/// `Fixed(n)` accept including this one, so the `Mana(_)` row vanishes ⇒ this row FAILS +/// while R6a-1 and the 12/12 control set stay green. +#[test] +fn mana_engine_accept_still_renders_its_infinity_badge() { + let Some(db) = shared_card_db() else { return }; + let mut rig = setup(true, LoopDetectionMode::Interactive, db); + let mana_idx = mana_ability_index(rig.runner.state(), rig.basalt).unwrap(); + let untap_idx = untap_ability_index(rig.runner.state(), rig.basalt).unwrap(); + drive_one_period(&mut rig, mana_idx, untap_idx); + assert!( + matches!( + rig.runner.state().waiting_for, + WaitingFor::LoopShortcut { .. } + ), + "precondition: the mana-engine offer must fire before acceptance" + ); + rig.runner + .act(GameAction::DeclareShortcut { + count: IterationCount::Fixed(1), + template: None, + }) + .expect("declare shortcut"); + rig.runner + .act(GameAction::RespondToShortcut { + response: ShortcutResponse::Accept, + }) + .expect("opponent accepts"); + + let state = rig.runner.state(); + // (1) reach-guard: the accept ran and marked a Mana axis in the store. + assert!( + state + .unbounded_resources + .get(&P0) + .is_some_and(|axes| axes.iter().any(|a| matches!(a, ResourceAxis::Mana(_)))), + "reach-guard: the mana-engine accept marks a Mana(_) ∞ axis" + ); + // (2) reach-guard: it registered NOTHING — this is the unscheduled-axis shape. + assert!( + state.pending_unbounded_materialization.is_empty(), + "reach-guard: a mana engine registers no deferred materialization, got {:?}", + state.pending_unbounded_materialization + ); + + // (3) DISCRIMINATOR — on the WIRE, the Mana row still renders for every viewer. + for viewer in [None, Some(P0), Some(P1)] { + let rows = engine::game::derived_views::derive_views(state, viewer).unbounded_resources; + assert!( + rows.iter().any(|r| matches!(r.axis, ResourceAxis::Mana(_))), + "FAIL-CLOSED: nothing is scheduled to collapse the mana axis, so its ∞ row must \ + still render (viewer {viewer:?}), got {rows:?}" + ); + } +} + +/// R6a FIX-ROUND-2 (CR 732.2c). MEASURED REGRESSION in the first cut of the collapse bound: +/// `materialize_fixed_shortcut` wrote `pending_materialization_count` UNCONDITIONALLY, before +/// either route ran. A mana engine reaches that function and registers NO deferred +/// materialization (proved by +/// [`mana_engine_accept_still_renders_its_infinity_badge`]'s reach-guard (2)) — so a +/// `Fixed(1)` mana accept left a bound with NOTHING to bound. +/// +/// That stray bound is UNCLEARABLE and PERSISTENT: all three clears +/// (`take_pending_materialization`, `clear_collapsed_materializations`, +/// `clear_unbounded_loop`) are keyed on the stash, `clear_unbounded_mana_loop` deliberately +/// does not touch it, and the field is `#[serde(default)]`. It therefore survives the phase, +/// the game and a save/load — and the NEXT accept that really does register a stash gets +/// `min(1, N)` = 1. A table that unanimously agreed to `Fixed(500)` object growth would be +/// offered `max: 1` at the CR 500.5 boundary and have `SubmitPayAmount { 500 }` REJECTED with +/// `"[0, 1]"`. BASE offered `MAX_SHORTCUT_CYCLES` and honored the agreed 500, so this was a +/// regression against BASE, not merely an incomplete fix. +/// +/// REVERT-PROBE (RUN, MEASURED): hoist the write back out of the stash-gate in +/// `materialize_fixed_shortcut` ⇒ assertion (3) FAILS with +/// `pending_materialization_count = {PlayerId(0): 1}`. (3) short-circuits the run, so (5) is +/// not reached in the same execution; a SECOND probe run with (3) temporarily downgraded to an +/// `eprintln!` reached it and observed (5) FAIL with `left: Some(1) right: Some(1000)`. +/// +/// HONEST SCOPE. Two real accepts — a stash-less one followed by a stash-bearing one — need a +/// board carrying BOTH a mana engine and an object-growth loop; that is not this rig and is +/// not reachable here without building a second combo. So the consequence at (4)/(5) is +/// pinned on the REAL `turns.rs` `max` read instead: the stash is grafted through the same +/// single-authority writer the accept path itself calls +/// (`GameState::register_pending_materialization`), and the CR 500.5 boundary is then reached +/// by passing priority through the real `apply()` reducer. +#[test] +fn mana_engine_accept_records_no_collapse_bound() { + let Some(db) = shared_card_db() else { return }; + let mut rig = setup(true, LoopDetectionMode::Interactive, db); + let mana_idx = mana_ability_index(rig.runner.state(), rig.basalt).unwrap(); + let untap_idx = untap_ability_index(rig.runner.state(), rig.basalt).unwrap(); + drive_one_period(&mut rig, mana_idx, untap_idx); + assert!( + matches!( + rig.runner.state().waiting_for, + WaitingFor::LoopShortcut { .. } + ), + "precondition: the mana-engine offer must fire before acceptance" + ); + rig.runner + .act(GameAction::DeclareShortcut { + count: IterationCount::Fixed(1), + template: None, + }) + .expect("declare shortcut"); + rig.runner + .act(GameAction::RespondToShortcut { + response: ShortcutResponse::Accept, + }) + .expect("opponent accepts"); + + // (1) REACH-GUARD: the accept really ran `materialize_fixed_shortcut` — it marked the + // Mana axis, which only the materialize path does. Without this the emptiness at (3) + // would be the vacuous "nothing happened" pass. + assert!( + rig.runner + .state() + .unbounded_resources + .get(&P0) + .is_some_and(|axes| axes.iter().any(|a| matches!(a, ResourceAxis::Mana(_)))), + "reach-guard: the mana-engine accept reaches materialize_fixed_shortcut and marks a \ + Mana(_) ∞ axis" + ); + // (2) REACH-GUARD: and it registered NOTHING — there is no stash for a bound to bound. + assert!( + rig.runner + .state() + .pending_unbounded_materialization + .is_empty(), + "reach-guard: a mana engine registers no deferred materialization, got {:?}", + rig.runner.state().pending_unbounded_materialization + ); + + // (3) DISCRIMINATOR: no bound is recorded either. Asserted on the SAME state the two + // reach-guards above measured as post-accept and stash-less. + assert!( + rig.runner.state().pending_materialization_count.is_empty(), + "CR 732.2c: a stash-less accept must record NO collapse bound (it would be \ + unclearable and would cap the next accept), got {:?}", + rig.runner.state().pending_materialization_count + ); + + // (4) CONSEQUENCE, on the real `turns.rs` read. Graft a stash through the production + // single-authority writer (as if a later object-growth accept had registered one) and + // reach the CR 500.5 boundary through real priority passes. + rig.runner.state_mut().register_pending_materialization( + P0, + engine::types::game_state::PersistentAxisMaterialization::Life { + player: P0, + per_cycle_delta: 1, + }, + ); + let mut prompt_max = None; + for _ in 0..64 { + if let WaitingFor::PayAmountChoice { + resource: engine::types::game_state::PayableResource::LoopCollapse { .. }, + max, + .. + } = rig.runner.state().waiting_for + { + prompt_max = Some(max); + break; + } + if rig.runner.act(GameAction::PassPriority).is_err() { + break; + } + } + + // (5) The grafted stash carries NO accepted bound, so the prompt falls back to the + // engine-wide safety bound. Under the unconditional write the stray `Fixed(1)` mana + // bound is still sitting in the map and caps this prompt at 1. + // 1_000 is `game::engine::MAX_SHORTCUT_CYCLES`, spelled literally because the const is + // `pub(crate)` and this is an integration test. + assert_eq!( + prompt_max, + Some(1_000), + "CR 732.2c: a stash-less mana accept must not bound a LATER stash's collapse prompt" + ); +} + +/// R6a FIX-ROUND-3 (CR 732.2c + CR 500.5). MEASURED DEFECT in the ∞-badge hide-set: the +/// `PersistentAxisMaterialization::DriveSequence` arm of `scheduled_collapse_axes` extends the +/// hide-set with the loop's WHOLE axis set (`collapsed_axes` == `proposal.unbounded`), so a +/// scheduled drive covering a `Mana(_)` axis suppressed that axis' `∞` row on the wire. +/// +/// That is the wrong side of the class rule. `Tokens` / `Counters` / `Life` each name a +/// DEFERRED materialization — the growth is not on the board until the boundary applies it, so +/// rendering `∞` for them is the lie R6a exists to kill. Mana is ALREADY MATERIALIZED at accept: +/// `mana_payment::refill_infinite_mana` re-tops the flagged pool to `INFINITE_MANA_PER_TYPE` off +/// `unbounded_resources` (the STORE, which the projection deliberately never filters) after every +/// action, so throughout the accept→boundary window the player can really spend an unbounded +/// pool while the HUD showed no `∞` — an internally inconsistent HUD, the inverse of an "∞ Life" +/// badge beside a finite life total. CR 500.5 is what legitimately ends the badge: the step/phase +/// end drains the pool and `turns::drain_pending_phase_transition_progress` clears the axis +/// (covered by `combo_infinite_pile`'s E4 mana axis-clear row, not re-proved here). +/// +/// HONEST SCOPE. Everything except one write is real: real cards through the real parser, a real +/// two-beat Basalt+Power period, a real `DeclareShortcut`/`RespondToShortcut` accept that marks +/// `Mana(Colorless)` and holds the pool at the cap. What is NOT reachable on this rig — and the +/// R6a reviewer could not reach it on any production board either — is a single loop spanning +/// BOTH a `Mana(_)` axis and an OBSERVED counter/life axis, which is what routes an accept into +/// the `DriveSequence` arm (`game::engine::materialize_object_growth_shortcut`). So the stash is +/// grafted through the same single-authority writers the accept path itself calls +/// (`GameState::mark_unbounded_loop` for the second axis, `register_pending_materialization` for +/// the item), with `collapsed_axes` set to exactly the store's mark set — byte-for-byte the +/// `proposal.unbounded.clone()` that production writes. Same graft technique as +/// `combo_infinite_pile::real_4p_observed_drive_sequence_replays_captured_period_n_times`. +/// +/// REVERT-PROBE (RUN): delete the `axes.retain(|a| !matches!(a, ResourceAxis::Mana(_)))` in +/// `derive_views`' hide-set ⇒ (5) FAILS — the `Mana(Colorless)` row vanishes from the wire while +/// the pool is still being refilled. (6) is the paired positive control that keeps the probe +/// honest: the genuinely deferred `Life(P0)` axis in the SAME stash MUST stay hidden, so a +/// blanket "disable the filter" is not a passing alternative. +#[test] +fn scheduled_drive_still_renders_the_already_spendable_mana_badge() { + use engine::types::game_state::PersistentAxisMaterialization; + + let Some(db) = shared_card_db() else { return }; + let mut rig = setup(true, LoopDetectionMode::Interactive, db); + let mana_idx = mana_ability_index(rig.runner.state(), rig.basalt).unwrap(); + let untap_idx = untap_ability_index(rig.runner.state(), rig.basalt).unwrap(); + drive_one_period(&mut rig, mana_idx, untap_idx); + assert!( + matches!( + rig.runner.state().waiting_for, + WaitingFor::LoopShortcut { .. } + ), + "precondition: the mana-engine offer must fire before acceptance" + ); + // The real captured two-beat period, read AT THE OFFER — `materialize_fixed_shortcut` + // clears `last_loop_action_sequence` on its way out, and production reads it at the same + // pre-clear point (`game::engine`'s capture-before-clear). + let sequence = rig.runner.state().last_loop_action_sequence.clone(); + assert!( + sequence.len() == 2, + "reach-guard: the offer carries the real two-beat Basalt+Power period the DriveSequence \ + would replay, got {} beats", + sequence.len() + ); + rig.runner + .act(GameAction::DeclareShortcut { + count: IterationCount::Fixed(1), + template: None, + }) + .expect("declare shortcut"); + rig.runner + .act(GameAction::RespondToShortcut { + response: ShortcutResponse::Accept, + }) + .expect("opponent accepts"); + + // (1) REACH-GUARD: the real accept marked the Mana axis in the STORE. Capture the exact + // axes — the graft below reuses them as `collapsed_axes`, mirroring production. + let mana_axes: Vec = rig + .runner + .state() + .unbounded_resources + .get(&P0) + .expect("reach-guard: the mana-engine accept marks P0's ∞ axes") + .iter() + .copied() + .filter(|a| matches!(a, ResourceAxis::Mana(_))) + .collect(); + assert!( + mana_axes.contains(&ResourceAxis::Mana(ManaType::Colorless)), + "reach-guard: Basalt+Power nets colorless, so the accept marks Mana(Colorless), got \ + {mana_axes:?}" + ); + + // (2) REACH-GUARD: that axis is ALREADY SPENDABLE — the pipeline refill holds the pool at + // the infinite-mana cap right now. This is what makes hiding the badge a lie rather than a + // harmless early cleanup. (`INFINITE_MANA_PER_TYPE` is `pub(crate)`; 100 spelled literally, + // matching `real_4p_basalt_power_artifact_refills_colorless_only`.) + let pool = colorless(rig.runner.state(), P0); + assert!( + pool >= 90, + "reach-guard: refill_infinite_mana holds P0's colorless pool at the cap (~100) during \ + the accept→CR-500.5 window, got {pool}" + ); + + // (3) GRAFT (see HONEST SCOPE): a second, genuinely DEFERRED axis plus the one + // `DriveSequence` an observed-growth accept would register over the real captured period. + // Both writes go through the production single-authority writers. + rig.runner + .state_mut() + .mark_unbounded_loop(P0, &[ResourceAxis::Life(P0)]); + let collapsed_axes: Vec = rig + .runner + .state() + .unbounded_resources + .get(&P0) + .expect("both axes marked") + .iter() + .copied() + .collect(); + rig.runner.state_mut().register_pending_materialization( + P0, + PersistentAxisMaterialization::DriveSequence { + sequence, + collapsed_axes: collapsed_axes.clone(), + }, + ); + + // (4) REACH-GUARD ON THE SEAM: the shared authority really does name the Mana axis, so the + // unfiltered hide-set WOULD have suppressed it. Without this, (5) could pass because the + // stash never reached the `DriveSequence` arm at all. + let state = rig.runner.state(); + let scheduled = state.scheduled_collapse_axes( + state + .pending_unbounded_materialization + .get(&P0) + .expect("the grafted stash is present"), + ); + assert!( + scheduled.contains(&ResourceAxis::Mana(ManaType::Colorless)) + && scheduled.contains(&ResourceAxis::Life(P0)), + "reach-guard: scheduled_collapse_axes returns BOTH axes unfiltered (the boundary must \ + still clear the mana one), got {scheduled:?}" + ); + + for viewer in [None, Some(P0), Some(P1)] { + let rows = engine::game::derived_views::derive_views(state, viewer).unbounded_resources; + let axes: Vec = rows.iter().map(|r| r.axis).collect(); + // (5) DISCRIMINATOR — the already-materialized mana axis keeps its ∞ row on the WIRE. + assert!( + axes.contains(&ResourceAxis::Mana(ManaType::Colorless)), + "CR 732.2c/CR 500.5: mana is already in the pool and still being refilled, so a \ + merely-scheduled drive must NOT hide its ∞ row (viewer {viewer:?}), got {axes:?}" + ); + // (6) POSITIVE CONTROL, SAME STATE — the genuinely deferred axis in the SAME + // `DriveSequence` is still hidden. Proves (5) is a targeted carve-out, not a disabled + // filter. + assert!( + !axes.contains(&ResourceAxis::Life(P0)), + "CR 732.2c: the deferred Life axis of the same scheduled drive stays hidden (viewer \ + {viewer:?}), got {axes:?}" + ); + } + + // (7) THE STORE IS UNTOUCHED — the projection filtered, it did not mutate. The boundary + // clear still reads both axes from here. + assert_eq!( + state + .unbounded_resources + .get(&P0) + .map(|a| a.iter().copied().collect::>()), + Some(collapsed_axes), + "the ∞ store survives the projection (CR 104.4b / CR 110.1 lockstep)" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index cffa92ece9..9a19ed640b 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1196,6 +1196,7 @@ mod there_are_no_permanents_state_trigger; mod they_gain_keyword_anaphor; mod three_blind_mice; mod token_storm_scaling_gate; +mod token_zone_change_index; mod tomb_cradle_atropal_deathtouch_runtime; mod tomb_oubliette_discard_sacrifice_runtime; mod tomb_sandfall_cell_life_loss_runtime; diff --git a/crates/engine/tests/integration/token_zone_change_index.rs b/crates/engine/tests/integration/token_zone_change_index.rs new file mode 100644 index 0000000000..267826c6d0 --- /dev/null +++ b/crates/engine/tests/integration/token_zone_change_index.rs @@ -0,0 +1,622 @@ +//! CR 603.6a + CR 603.2c + CR 400.7 — a token entering the battlefield must be recorded through +//! `restrictions::record_zone_change`, so its `ZoneChanged` event carries this turn's real +//! zone-change index. +//! +//! DEFECT: `GameObject::snapshot_for_zone_change` leaves `turn_zone_change_index` at its `0` +//! placeholder for the recorder to overwrite (`zones.rs` does exactly that for ordinary moves). +//! The two token emit sites built the record and emitted it WITHOUT ever reaching the recorder, so +//! every token entry shipped index `0`. The batched zone-change replay guard +//! (`triggers.rs::batched_zone_change_already_collected`) dedups on +//! `(definition_ref, turn_zone_change_index)` — CR 603.2c, "an ability triggers only once each +//! time its trigger event occurs" — so a SECOND same-turn token batch collided with the first on +//! `(def, 0)` and its fire was silently swallowed. + +use engine::game::effects::{incubate, token}; +use engine::game::scenario::{GameScenario, P0}; +use engine::game::triggers::{drain_order_triggers_with_identity, process_triggers}; +use engine::types::ability::{ + AbilityDefinition, AbilityKind, Effect, PtValue, QuantityExpr, ResolvedAbility, TargetFilter, + TriggerDefinition, +}; +use engine::types::events::GameEvent; +use engine::types::game_state::GameState; +use engine::types::identifiers::ObjectId; +use engine::types::phase::Phase; +use engine::types::triggers::TriggerMode; +use engine::types::zones::Zone; + +/// The batched enters-the-battlefield class (CR 603.6a + CR 603.2c): "Whenever one or more +/// creatures you control enter, you gain 1 life." Built directly rather than loaded from a card +/// because the behaviour under test is the ENGINE's batched-dedup KEY, which is card-agnostic — +/// and no card in `integration_cards.json` carries a batched ETB trigger that admits tokens +/// without an additional "only once each turn" clause that would mask the second fire. +fn batched_etb_life_trigger() -> TriggerDefinition { + let mut def = TriggerDefinition::new(TriggerMode::ChangesZone); + def.batched = true; + def.destination = Some(Zone::Battlefield); + def.trigger_zones = vec![Zone::Battlefield]; + def.execute = Some(Box::new(AbilityDefinition::new( + AbilityKind::Spell, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 1 }, + player: TargetFilter::Controller, + }, + ))); + def.description = + Some("Whenever one or more creatures you control enter, you gain 1 life.".to_string()); + def +} + +/// Resolve one `Effect::Token` batch of `count` tokens through the production token resolver, then +/// run the real trigger pipeline over the emitted events. Returns the emitted events so the test +/// can read the `turn_zone_change_index` the entries actually shipped. +fn mint_token_batch(state: &mut GameState, source: ObjectId, count: i32) -> Vec { + let ability = ResolvedAbility::new( + Effect::Token { + name: "Saproling".to_string(), + power: PtValue::Fixed(1), + toughness: PtValue::Fixed(1), + types: vec!["Creature".to_string()], + colors: Vec::new(), + keywords: Vec::new(), + tapped: false, + count: QuantityExpr::Fixed { value: count }, + owner: TargetFilter::Controller, + attach_to: None, + enters_attacking: false, + supertypes: Vec::new(), + static_abilities: Vec::new(), + enter_with_counters: Vec::new(), + }, + Vec::new(), + source, + P0, + ); + let mut events = Vec::new(); + token::resolve(state, &ability, &mut events).expect("the token batch resolves"); + process_triggers(state, &events); + drain_order_triggers_with_identity(state); + events +} + +/// Resolve one `Effect::Incubate` through the production incubate resolver, then run the real +/// trigger pipeline over the emitted events — the "other mechanism" half of the mixed-group case. +/// +/// `incubate.rs` was one of SEVEN battlefield-entry emit sites that built a `ZoneChanged` record +/// with `snapshot_for_zone_change` and emitted it without ever reaching the recorder, so it shipped +/// the index-`0` placeholder. It is routed through `record_zone_change` by this change because +/// these very tests drive it; the six that remain (`conjure.rs`, `counters.rs` ×2 — the `:526` +/// inline emit and `push_token_entry_events` — `token_copy.rs` ×2, `gift_delivery.rs`) are the +/// class-wide follow-up. +fn incubate_batch(state: &mut GameState, source: ObjectId, count: i32) -> Vec { + let ability = ResolvedAbility::new( + Effect::Incubate { + count: QuantityExpr::Fixed { value: count }, + }, + Vec::new(), + source, + P0, + ); + let mut events = Vec::new(); + incubate::resolve(state, &ability, &mut events).expect("the incubate resolves"); + process_triggers(state, &events); + drain_order_triggers_with_identity(state); + events +} + +fn zone_change_indices(events: &[GameEvent]) -> Vec { + events + .iter() + .filter_map(|e| match e { + GameEvent::ZoneChanged { record, .. } => Some(record.turn_zone_change_index), + _ => None, + }) + .collect() +} + +fn life_of_p0(state: &GameState) -> i32 { + state + .players + .iter() + .find(|p| p.id == P0) + .expect("P0 is seated") + .life +} + +fn token_ids(state: &GameState) -> Vec { + state + .battlefield + .iter() + .copied() + .filter(|id| state.objects.get(id).is_some_and(|o| o.is_token)) + .collect() +} + +/// R4 (CR 603.6a + CR 603.2c): TWO token batches in ONE turn, from ONE `batched: true` +/// `ChangesZone` trigger, must fire the trigger TWICE — once per batch — because each batch is a +/// distinct trigger event. +/// +/// REVERT-PROBE (discriminating, RUN): restore the direct +/// `snapshot_for_zone_change` emit in `push_committed_token_entry_events` (index left at the `0` +/// placeholder) ⇒ both batches key on `(def, 0)`, the second is dropped by +/// `batched_zone_change_already_collected`, and P0 gains 1 life instead of 2. +#[test] +fn second_same_turn_token_batch_still_triggers() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let host = scenario.add_creature(P0, "Batched Watcher", 1, 1).id(); + let mut runner = scenario.build(); + runner + .state_mut() + .objects + .get_mut(&host) + .expect("host permanent") + .trigger_definitions + .push(batched_etb_life_trigger()); + + let life_start = life_of_p0(runner.state()); + let turn_start = runner.state().turn_number; + + // ── BATCH 1 ── + let first = mint_token_batch(runner.state_mut(), host, 2); + runner.advance_until_stack_empty(); + let after_first = life_of_p0(runner.state()); + // POSITIVE reach-guard: the batched trigger really fires (a fixture that never triggers would + // make the second-batch assertion below vacuously "unchanged"). + assert_eq!( + after_first - life_start, + 1, + "one batch of 2 tokens fires the batched trigger exactly ONCE (CR 603.2c)" + ); + + // ── BATCH 2, SAME TURN ── + let second = mint_token_batch(runner.state_mut(), host, 2); + runner.advance_until_stack_empty(); + assert_eq!( + runner.state().turn_number, + turn_start, + "both batches are in the SAME turn (the dedup ledger is per-turn)" + ); + + // (1) DISCRIMINATOR: the second batch is a distinct trigger event and fires again. + assert_eq!( + life_of_p0(runner.state()) - life_start, + 2, + "a SECOND same-turn token batch fires the batched trigger again (index 0 ⇒ swallowed ⇒ 1)" + ); + + // (2) MECHANISM: the two batches carry DISJOINT zone-change indices — the dedup key that + // makes (1) possible. Under the defect every index is the `0` placeholder. + let first_ix = zone_change_indices(&first); + let second_ix = zone_change_indices(&second); + assert_eq!(first_ix.len(), 2, "batch 1 emits one ZoneChanged per token"); + assert_eq!( + second_ix.len(), + 2, + "batch 2 emits one ZoneChanged per token" + ); + assert!( + first_ix.iter().all(|a| second_ix.iter().all(|b| a != b)), + "the two batches must not share a zone-change index, got {first_ix:?} vs {second_ix:?}" + ); + let mut all = [first_ix, second_ix].concat(); + all.sort_unstable(); + all.dedup(); + assert_eq!( + all.len(), + 4, + "each of the 4 token entries gets its OWN index (all-0 placeholder ⇒ 1)" + ); +} + +/// MIXED-GROUP (CR 603.2c): a SIBLING mechanism's entry and a token entry in the SAME turn are two +/// distinct trigger events, so one `batched: true` `ChangesZone` trigger must fire for EACH. +/// +/// This is the case where routing entries through `record_zone_change` makes the engine dedup +/// LESS, not more: an emit site that never reaches the recorder ships the index-`0` placeholder, so +/// before this change a token entry collided with the Incubator's `0` at `(def, 0)` and the second +/// mechanism's fire was swallowed. CR 603.2c bounds an ability to one fire per *occurrence* of its +/// trigger event — two permanents entering are two occurrences, so the suppressed fire was never +/// rules-correct. +/// +/// Both mechanisms are routed now (`token.rs` and `incubate.rs`), so this passes in either order; +/// `mixed_group_sibling_last_also_fires` is the reversed-order twin. +/// +/// REVERT-PROBE (discriminating, RUN): restore the direct `snapshot_for_zone_change` emit in +/// `push_committed_token_entry_events` ⇒ the token batch ships index `0`, collides with the +/// Incubator's `0`, and P0 gains 1 life instead of 2. +#[test] +fn mixed_group_sibling_then_token_each_fire_the_batched_trigger() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let host = scenario.add_creature(P0, "Batched Watcher", 1, 1).id(); + let mut runner = scenario.build(); + runner + .state_mut() + .objects + .get_mut(&host) + .expect("host permanent") + .trigger_definitions + .push(batched_etb_life_trigger()); + + // The index arithmetic below is only legible if the per-turn ledger starts empty. + assert_eq!( + runner.state().zone_changes_this_turn.len(), + 0, + "the CR 400.7 per-turn zone-change ledger starts empty" + ); + let life_start = life_of_p0(runner.state()); + let turn_start = runner.state().turn_number; + + // ── SIBLING MECHANISM FIRST: Incubate (index-0 placeholder, pushed to the ledger directly) ── + let incubator = incubate_batch(runner.state_mut(), host, 2); + runner.advance_until_stack_empty(); + // POSITIVE reach-guard: the sibling entry really reaches the batched trigger. Without this the + // token assertion below would pass vacuously for a fixture that never triggered at all. + assert_eq!( + life_of_p0(runner.state()) - life_start, + 1, + "the Incubator entry fires the batched trigger once (CR 603.6a)" + ); + let sibling_ix = zone_change_indices(&incubator); + assert_eq!( + sibling_ix, + vec![0], + "the first entry of an empty-ledger turn takes index 0 (placeholder and real agree here)" + ); + + // ── TOKEN ENTRY, SAME TURN ── + let tokens = mint_token_batch(runner.state_mut(), host, 2); + runner.advance_until_stack_empty(); + assert_eq!( + runner.state().turn_number, + turn_start, + "both mechanisms are in the SAME turn (the dedup ledger is per-turn)" + ); + + // (1) DISCRIMINATOR: two mechanisms, two trigger events, two fires. + assert_eq!( + life_of_p0(runner.state()) - life_start, + 2, + "a token entry after a sibling-mechanism entry fires the batched trigger AGAIN \ + (token shipping index 0 ⇒ collides with the sibling ⇒ 1)" + ); + + // (2) MECHANISM: the token entries carry real, nonzero indices assigned past the sibling's. + let token_ix = zone_change_indices(&tokens); + assert_eq!( + token_ix, + vec![1, 2], + "token entries are indexed past the sibling's ledger entry (placeholder ⇒ [0, 0])" + ); +} + +/// REVERSED ORDER (CR 603.2c): the sibling mechanism enters SECOND. This is the half a +/// token-only fix cannot reach — `record_zone_change` assigns `zone_changes_this_turn.len()`, so +/// the first token of an empty-ledger turn legitimately takes index `0` and an unrouted sibling's +/// placeholder `0` collides with it. Routing `incubate.rs` through the recorder is what makes the +/// sibling's index real (`2`, past the two token entries) and its fire survive. +/// +/// REVERT-PROBE (discriminating, RUN): restore the direct +/// `zone_changes_this_turn.push_back(..)` + `record_battlefield_entry` emit in `incubate.rs` +/// (index left at the `0` placeholder) ⇒ the Incubator collides with the token batch's index `0`, +/// its fire is swallowed, and P0's delta stays 1 instead of 2. +#[test] +fn mixed_group_sibling_last_also_fires() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let host = scenario.add_creature(P0, "Batched Watcher", 1, 1).id(); + let mut runner = scenario.build(); + runner + .state_mut() + .objects + .get_mut(&host) + .expect("host permanent") + .trigger_definitions + .push(batched_etb_life_trigger()); + + let life_start = life_of_p0(runner.state()); + + let tokens = mint_token_batch(runner.state_mut(), host, 2); + runner.advance_until_stack_empty(); + // POSITIVE reach-guard: the token batch fires, so the unchanged total below is a genuine + // suppression and not a fixture that never triggered. + assert_eq!( + life_of_p0(runner.state()) - life_start, + 1, + "the token batch fires the batched trigger once" + ); + assert_eq!( + zone_change_indices(&tokens), + vec![0, 1], + "the first token of an empty-ledger turn legitimately takes index 0" + ); + + let incubator = incubate_batch(runner.state_mut(), host, 2); + runner.advance_until_stack_empty(); + // (1) DISCRIMINATOR: two mechanisms, two occurrences, two fires (CR 603.2c). + assert_eq!( + life_of_p0(runner.state()) - life_start, + 2, + "the sibling entry after a token batch fires the batched trigger AGAIN \ + (sibling shipping index 0 ⇒ collides with the token's legitimate 0 ⇒ 1)" + ); + // (2) MECHANISM: the sibling's index is assigned past the two token entries already on the + // ledger, so it can no longer alias onto the token batch's legitimate `0`. + assert_eq!( + zone_change_indices(&incubator), + vec![2], + "the sibling entry is indexed past the token batch (unrouted placeholder ⇒ [0])" + ); +} + +/// MUST-NOT-FLIP for the paired deletion: routing token entries through `record_zone_change` +/// (which performs the CR 403.3 battlefield-entry bookkeeping itself) means the emit sites must +/// NOT also call `record_battlefield_entry`. +/// +/// REVERT-PROBE (discriminating, RUN): re-add the deleted +/// `crate::game::restrictions::record_battlefield_entry` call in +/// `apply_create_token_after_replacement_with_created_ids` ⇒ every token appears TWICE in +/// `battlefield_entries_this_turn` and the per-id count assertion fails with 2. +#[test] +fn battlefield_entries_this_turn_counts_each_token_exactly_once() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let host = scenario.add_creature(P0, "Token Source", 1, 1).id(); + let mut runner = scenario.build(); + + let before: Vec = token_ids(runner.state()); + mint_token_batch(runner.state_mut(), host, 3); + let minted: Vec = token_ids(runner.state()) + .into_iter() + .filter(|id| !before.contains(id)) + .collect(); + + // POSITIVE reach-guard: tokens were actually created, so the counts below are non-vacuous. + assert_eq!(minted.len(), 3, "the batch minted 3 tokens"); + + for id in &minted { + let entries = runner + .state() + .battlefield_entries_this_turn + .iter() + .filter(|r| r.object_id == *id) + .count(); + assert_eq!( + entries, 1, + "token {id:?} is recorded in battlefield_entries_this_turn exactly once \ + (re-adding the deleted record_battlefield_entry ⇒ 2)" + ); + } + + // The same entries are also visible on the CR 400.7 zone-change ledger — the recorder that + // assigns the index. Before the fix, tokens never appeared here at all. + for id in &minted { + assert_eq!( + runner + .state() + .zone_changes_this_turn + .iter() + .filter(|r| r.object_id == *id && r.to_zone == Zone::Battlefield) + .count(), + 1, + "token {id:?} is recorded on the zone-change ledger exactly once" + ); + } +} + +// ───────── the SUPPRESS route (CR 403.3 + CR 603.6a) ───────── +// +// `finalize_committed_liminal_token_entry_from_action` records the entry through +// `push_committed_token_entry_events`, which is gated on `TokenEntryEventEmission::Emit`. The one +// `Suppress` caller is the liminal branch of `engine_replacement.rs::handle_copy_target_choice`, +// which emits the entry itself after the commit returns. Deleting the unconditional +// `record_battlefield_entry` from the finalize tail therefore leaves this route's CR 403.3 record +// entirely to that caller — which is what this test pins. +// +// HONEST SCOPE — the PAUSED sub-route (a liminal entry carrying counters, so the commit consults +// `add_counter_with_replacement` and may suspend mid-loop) is NOT covered here, and is deliberately +// NOT claimed unreachable. +// +// Half of it is structural. The commit concatenates two vectors into `counters_to_apply` +// (`token.rs`): the `LiminalEntry`'s and the `ProposedEvent::TokenEntry`'s. The entry's is empty by +// construction — `token_copy.rs` takes the liminal branch only when `etb_counters.is_empty()` and +// builds the entry with `Vec::new()`. +// +// The other half is not. The event's vector also starts `Vec::new()`, but it is passed through +// `replace_event` before the commit sees it, and `apply_single_replacement` appends +// `modifiers.etb_counters` to a `TokenEntry`'s vector — which `replacement_event_keys_for_event` +// matches under BOTH `ChangeZone` and `Moved`. So a non-`SelfRef` ETB-counter replacement is not +// structurally excluded from this route. +// +// MEASURED instead of argued: driving both liminal routes (this Embalm/copy-target one and a plain +// `CopyTokenOf`) with the only two external `Moved` ETB-counter grants in `data/card-data.json` +// that admit tokens at all (Spider-Punk's and Tesak's granted Riot/Unleash — every other one is +// either `SelfRef` or `NonToken`-guarded) left `counters_to_apply` empty in every arm; on the +// copy-target route the grant is not even offered, because the token has not yet chosen what to +// copy when the replacement pass runs and both grants are subtype-scoped. +// +// So: unreached by the current card pool, not impossible. The post-finalize emit handed to the +// commit for the paused case is kept for that reason — it keeps the record local to this route +// rather than resting on a `liminal_immediate ⇒ no counters` argument that spans two files and +// holds only as long as the card-pool measurement above does. + +/// Verbatim Oracle text (Amonkhet). The Embalm line is a keyword hint so the scenario's parse +/// pipeline synthesizes the graveyard-activated token-copy ability, exactly as +/// `vizier_of_many_faces_embalm_copy_panic_5278.rs` does — the token it creates is a copy of +/// Vizier, so it carries Vizier's own "enter as a copy" replacement and pauses for a copy target, +/// which is the only production route to `TokenEntryEventEmission::Suppress`. +const VIZIER_ORACLE: &str = "You may have this creature enter as a copy of any creature on the battlefield, except if this creature was embalmed, the token has no mana cost, it's white, and it's a Zombie in addition to its other types.\nEmbalm {3}{U}{U}"; + +/// CR 403.3 + CR 603.6a: a liminal copy-token entry committed with entry-event emission +/// SUPPRESSED must still land on both per-turn ledgers exactly once, and must still emit the +/// battlefield-entry event its caller defers. +/// +/// This is the route the paired deletion had to compensate. The finalize tail no longer records +/// the entry itself (`record_zone_change`, inside `push_committed_token_entry_events`, does), and +/// on this route that call is made by `handle_copy_target_choice` rather than by the finalize — +/// so if the emit and the record were ever separated again, the copy token would enter invisibly. +/// +/// REVERT-PROBE (discriminating, RUN): restore the direct `snapshot_for_zone_change` emit inside +/// `push_committed_token_entry_events` (the pre-change form that never reached the recorder) while +/// keeping the deleted `record_battlefield_entry` deleted ⇒ the Embalm copy token appears in +/// NEITHER ledger and both count assertions fail with 0. +#[test] +fn suppressed_liminal_copy_token_entry_is_recorded_once() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let vizier = scenario + .add_creature_to_graveyard(P0, "Vizier of Many Faces", 0, 0) + .with_mana_cost(engine::types::mana::ManaCost::Cost { + generic: 3, + shards: vec![engine::types::mana::ManaCostShard::Blue], + }) + .from_oracle_text_with_keywords(&["Embalm"], VIZIER_ORACLE) + .id(); + // The creature the Embalm token is asked to copy. + scenario.add_creature(P0, "Grizzly Bears", 3, 3); + + let mut runner = scenario.build(); + { + let dummy = ObjectId(0); + let pool = &mut runner.state_mut().players[0].mana_pool; + for m in [ + engine::types::mana::ManaType::Blue, + engine::types::mana::ManaType::Blue, + engine::types::mana::ManaType::Colorless, + engine::types::mana::ManaType::Colorless, + engine::types::mana::ManaType::Colorless, + ] { + pool.add(engine::types::mana::ManaUnit::new(m, dummy, false, vec![])); + } + } + + let embalm_index = runner.state().objects[&vizier] + .abilities + .iter() + .position(|a| matches!(&*a.effect, Effect::CopyTokenOf { .. })) + .expect("the synthesized Embalm ability is on the graveyard Vizier"); + runner + .act(engine::types::actions::GameAction::ActivateAbility { + source_id: vizier, + ability_index: embalm_index, + }) + .expect("activate Embalm"); + + // Drive the entry prompts: accept the enter-as-copy replacement, then pick the copy target. + // Answering the copy target is what routes the commit through the `Suppress` branch. + let mut token = None; + let mut prompts: Vec = Vec::new(); + let mut entry_events: Vec = Vec::new(); + for _ in 0..64 { + match runner.state().waiting_for.clone() { + engine::types::game_state::WaitingFor::ManaPayment { .. } + | engine::types::game_state::WaitingFor::Priority { .. } => { + if token.is_some() && runner.state().stack.is_empty() { + break; + } + runner + .act(engine::types::actions::GameAction::PassPriority) + .expect("pass priority"); + } + engine::types::game_state::WaitingFor::ReplacementChoice { candidates, .. } => { + prompts.push(format!("ReplacementChoice({})", candidates.len())); + runner + .act(engine::types::actions::GameAction::ChooseReplacement { index: 0 }) + .expect("accept the enter-as-copy replacement"); + } + engine::types::game_state::WaitingFor::CopyTargetChoice { + source_id, + valid_targets, + .. + } => { + prompts.push("CopyTargetChoice".to_string()); + let target = *valid_targets + .iter() + .find(|id| { + runner + .state() + .objects + .get(id) + .is_some_and(|o| o.name == "Grizzly Bears") + }) + .expect("the Bear is a legal copy target"); + token.get_or_insert(source_id); + let result = runner + .act(engine::types::actions::GameAction::ChooseTarget { + target: Some(engine::types::ability::TargetRef::Object(target)), + }) + .expect("choose the copy target"); + entry_events.extend(result.events.iter().filter_map(|e| match e { + GameEvent::ZoneChanged { record, to, .. } + if record.object_id == source_id && *to == Zone::Battlefield => + { + Some(record.turn_zone_change_index) + } + _ => None, + })); + } + other => { + prompts.push(format!("{other:?}")); + break; + } + } + } + // POSITIVE reach-guard: the copy-target prompt is the ONLY production entrance to the + // `Suppress` commit, so without it every assertion below would be about a different route. + let token = token.unwrap_or_else(|| { + panic!("the Embalm token must reach its copy-target prompt; prompts seen = {prompts:?}") + }); + runner.advance_until_stack_empty(); + + // (1) DISCRIMINATOR: the suppressed-emission entry is recorded exactly once (CR 403.3). + assert_eq!( + runner + .state() + .battlefield_entries_this_turn + .iter() + .filter(|r| r.object_id == token) + .count(), + 1, + "the Suppress-route copy token is recorded in battlefield_entries_this_turn exactly once" + ); + // (2) …through the CR 400.7 recorder, so it also carries a real zone-change index. + assert_eq!( + runner + .state() + .zone_changes_this_turn + .iter() + .filter(|r| r.object_id == token && r.to_zone == Zone::Battlefield) + .count(), + 1, + "the Suppress-route copy token reaches the zone-change ledger exactly once" + ); + // (3) The deferred emit really happened, carrying the recorder-assigned index (CR 603.6a + + // CR 400.7). Read off the `ActionResult` of the copy-target submission itself, which is + // the action that runs the whole Suppress tail. + assert_eq!( + entry_events.len(), + 1, + "the copy-target submission emits exactly one battlefield ZoneChanged for the token" + ); + let ledger_index = runner + .state() + .zone_changes_this_turn + .iter() + .position(|r| r.object_id == token && r.to_zone == Zone::Battlefield) + .expect("the entry is on the ledger"); + assert_eq!( + entry_events[0], ledger_index, + "the emitted event carries the index the recorder assigned (placeholder ⇒ 0 ≠ ledger slot)" + ); + // NOT asserted here, and deliberately: no board ETB trigger fires for this route. MEASURED — + // a ChangesZone→Battlefield trigger grafted onto a live permanent (layers flushed) gained 0 + // life, with `batched: true` AND with `batched: false`. That is a PRE-EXISTING gap in the + // copy-target-choice resume path, and the non-batched arm is what makes it independent of the + // batched dedup this change touches: the event IS emitted (assertion 3), it just fires nothing. + // The CAUSE is deliberately not named — an earlier draft blamed `state.deferred_entry_events` + // filtering the emit out at the priority boundary, which cannot be it + // (`replay_deferred_entry_events` takes that vector EMPTY before this emit happens). Recorded + // as a follow-up with the symptom only, not fixed here. +}