From 492df5b9d095b39c54ccde31b79897b04141132a Mon Sep 17 00:00:00 2001 From: lgray Date: Sat, 15 Aug 2026 20:43:19 -0500 Subject: [PATCH 01/26] fix(engine): draw the greatest single player's discard, not the cross-player sum Windfall's "the greatest number of cards a player discarded this way" resolved to the SUM across players: with hands 8/7/3/3 every player drew 21 instead of 8. `QuantityRef::PreviousEffectAmount` had no way to say *which* reduction to apply to the per-player table it reads, so every consumer got the total. Add an `aggregate: AggregateFunction` axis to the variant, mirroring the `DamageDealtThisTurn` precedent, and parameterize the resolver fold over it. `Sum` is the serde default and is elided, so the 147-card corpus projection is byte-identical except the three cards in the class (Windfall, Jace's Archivist, Whispering Madness). Parsing gains `parse_greatest_discarded_this_way`, a nom combinator covering the determiner-less and superlative-variant forms; the legacy `all_consuming` block that hard-coded the summed reading is deleted and its dispatcher delegates to the combinator, so the two readings can no longer disagree. Also corrects nine CR miscitations found while tracing the class (C1-C9): each cited a real rule for something it does not say. CR 120.6 is marked-damage persistence and never supported "the total amount dealt/lost/removed"; CR 107.1 does not license a maximizing extremum adjective. Every replacement number was greped and content-matched against docs/MagicCompRules.txt. CR 608.2h: the answer is determined only once, when the effect is applied. CR 608.2c + CR 608.2i: the "this way" back-reference and its look-back exception. CR 121.2c: the engine's APNAP serialization of the multiplayer draw is correct; only the leaked count was not. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/database/synthesis.rs | 2 + crates/engine/src/game/ability_scan.rs | 14 +- crates/engine/src/game/casting_tests.rs | 1 + crates/engine/src/game/coverage.rs | 23 ++- crates/engine/src/game/effects/mod.rs | 6 + crates/engine/src/game/quantity.rs | 58 ++++-- .../src/parser/oracle_effect/assembly.rs | 1 + .../src/parser/oracle_effect/imperative.rs | 1 + .../engine/src/parser/oracle_effect/mana.rs | 1 + .../engine/src/parser/oracle_effect/tests.rs | 1 + .../engine/src/parser/oracle_effect/token.rs | 1 + .../engine/src/parser/oracle_nom/condition.rs | 1 + .../engine/src/parser/oracle_nom/quantity.rs | 85 ++++++++- crates/engine/src/parser/oracle_quantity.rs | 101 +++++++++-- .../engine/src/parser/oracle_trigger_tests.rs | 2 + crates/engine/src/types/ability.rs | 50 +++++- .../coalition_relic_integration.rs | 7 +- .../excess_damage_quantity_channel.rs | 3 +- .../issue_6858_draw_that_many_discard.rs | 5 +- crates/engine/tests/integration/main.rs | 1 + ...alakut_exploration_end_step_exile_sweep.rs | 5 +- .../windfall_greatest_discard_aggregate.rs | 167 ++++++++++++++++++ crates/phase-ai/src/policies/x_cast_gate.rs | 2 + crates/phase-ai/src/policies/x_reference.rs | 8 +- 24 files changed, 493 insertions(+), 53 deletions(-) create mode 100644 crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs diff --git a/crates/engine/src/database/synthesis.rs b/crates/engine/src/database/synthesis.rs index dee7adee44..1d79c3fe0a 100644 --- a/crates/engine/src/database/synthesis.rs +++ b/crates/engine/src/database/synthesis.rs @@ -6009,6 +6009,7 @@ fn build_extort_trigger() -> TriggerDefinition { amount: QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: crate::types::ability::DamageChannel::Total, + aggregate: AggregateFunction::Sum, }, }, player: TargetFilter::Controller, @@ -15058,6 +15059,7 @@ mod extort_synthesis_tests { amount: QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: DamageChannel::Total, + aggregate: AggregateFunction::Sum, }, }, player: TargetFilter::Controller, diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index e67751d38f..f4e74246ee 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -2169,7 +2169,19 @@ fn scan_quantity_ref(x: &QuantityRef, mode: ScanMode) -> Axes { }, }, QuantityRef::ExiledFromHandThisResolution => Axes::NONE, - QuantityRef::PreviousEffectAmount { .. } => Axes::NONE, + // CR 608.2c + CR 608.2i: every channel and every aggregate reads + // resolution-local state — `last_effect_amount` / + // `last_effect_excess_amount` / `last_effect_counts_by_player`. All are + // cleared at depth-0 chain entry (`resolve_ability_chain`); `apply()` + // additionally clears `last_effect_count` and the per-player table at + // every player action. None is a triggering-event characteristic + // (event), a board-scoped mutable aggregate a sibling copy could mutate + // (sibling), or a player-level per-turn projected resource (projected). + // Destructured without `..` so a future field forces re-classification. + QuantityRef::PreviousEffectAmount { + channel: _, + aggregate: _, + } => Axes::NONE, QuantityRef::PreviousEffectCount => Axes::NONE, QuantityRef::LifeLostThisTurn { player } => { let mut acc = Axes { diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index 93962714c8..a146983461 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -7064,6 +7064,7 @@ fn x_spell_doubled_lose_life_drains_opponents_and_gains_controller() { amount: QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: crate::types::ability::DamageChannel::Total, + aggregate: AggregateFunction::Sum, }, }, player: TargetFilter::Controller, diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 2272f6b1fa..b27c2aec76 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -1585,7 +1585,28 @@ fn fmt_quantity_ref(qty: &QuantityRef) -> String { format!("# of counter kinds among {}", fmt_target(filter)) } QuantityRef::VoteCount { choice_index } => format!("# of votes for choice {choice_index}"), - QuantityRef::PreviousEffectAmount { .. } => "amount from preceding effect".into(), + QuantityRef::PreviousEffectAmount { channel, aggregate } => match (channel, aggregate) { + // Byte-identical to the pre-change string, so no existing card's + // coverage signature moves. Must stay FIRST: the Excess-channel + // corpus cards are all `Sum` and must keep hitting this arm. + (_, AggregateFunction::Sum) => "amount from preceding effect".into(), + // CR 120.10: excess damage is "equal to the difference" beyond lethal — + // one amount per damaged permanent, never a per-player tally. Naming a + // "single player's" extremum over it would describe a reduction that + // never happened. (The per-player table the Total channel publishes is + // an engine structure; no CR governs its shape, so none is cited for it.) + // No parser path builds that pair today; the arm exists so the renderer + // stays honest if one ever does. + (crate::types::ability::DamageChannel::Total, AggregateFunction::Max) => { + "greatest single player's amount from preceding effect".into() + } + (crate::types::ability::DamageChannel::Total, AggregateFunction::Min) => { + "least single player's amount from preceding effect".into() + } + (crate::types::ability::DamageChannel::Excess, _) => { + "excess amount from preceding effect".into() + } + }, QuantityRef::PreviousEffectCount => "count from preceding effect".into(), QuantityRef::TrackedSetSize => "cards moved".into(), QuantityRef::FilteredTrackedSetSize { filter, .. } => { diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 87ada1ad68..f1846a923e 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -18429,6 +18429,7 @@ mod tests { amount: QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: crate::types::ability::DamageChannel::Total, + aggregate: AggregateFunction::Sum, }, }, player: TargetFilter::Controller, @@ -23155,6 +23156,7 @@ mod tests { count: QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: crate::types::ability::DamageChannel::Total, + aggregate: AggregateFunction::Sum, }, }, target: TargetFilter::Controller, @@ -30333,6 +30335,7 @@ mod tests { count: QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: DamageChannel::Total, + aggregate: AggregateFunction::Sum, }, }, target: TargetFilter::Controller, @@ -30650,6 +30653,7 @@ mod tests { amount: QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: DamageChannel::Total, + aggregate: AggregateFunction::Sum, }, }, target: None, @@ -30665,6 +30669,7 @@ mod tests { count: QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: DamageChannel::Total, + aggregate: AggregateFunction::Sum, }, }, target: TargetFilter::Controller, @@ -30741,6 +30746,7 @@ mod tests { count: QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: DamageChannel::Total, + aggregate: AggregateFunction::Sum, }, }, target: TargetFilter::SelfRef, diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index acfaaa8b70..46a35b1f4c 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -3977,21 +3977,55 @@ fn resolve_ref( } count } - // CR 608.2c: Numeric result from the preceding effect in a sub_ability chain. - // The resolver stamps this from the parent effect's semantic event class. - // - // CR 120.6 / CR 120.10: `channel` picks WHICH tally the preceding effect - // left behind. Both are stamped by the damage effects and cleared at - // depth-0, so the two channels are read from the same resolution scope — - // this arm only chooses between them. Mirrors the condition peer - // `AbilityCondition::PreviousEffectAmount`, which already reads both. - QuantityRef::PreviousEffectAmount { channel } => match channel { - // CR 120.6: the total amount dealt/lost/removed. - DamageChannel::Total => state.last_effect_amount.unwrap_or(0), + // CR 608.2c + CR 608.2i: a "this way" look-back at the numeric result of + // the preceding instruction in this resolution. `channel` selects WHICH + // tally that instruction left behind; `aggregate` selects how the + // per-player table is reduced to the one number this reference reads. + QuantityRef::PreviousEffectAmount { channel, aggregate } => match channel { + DamageChannel::Total => { + let total = state.last_effect_amount.unwrap_or(0); + let per_player = state.last_effect_counts_by_player.values().copied(); + match aggregate { + AggregateFunction::Sum => total, + // An absent table means the producer published NO per-player + // breakdown: only `Effect::Discard | DiscardCard | + // ChangeZoneAll` populate it; every other producer takes the + // `None` arm in `install_previous_effect_counts_by_player`, + // which clears it. For a SINGLE-subject producer the scalar + // IS the extremum, so the fallback is exact. For a + // MULTI-subject non-count producer — `Effect::DamageEachPlayer`, + // `Effect::DamageAll`, `Effect::LoseLife` under `player_scope` + // — the scalar is a cross-player SUM and a Max read would + // over-report. Unreachable today, measured: the Scryfall + // census (2026-08-15) returns exactly 3 cards in the Max + // class and all 3 follow an `Effect::Discard`, a count + // producer. The real-zero case is also safe: the discard + // fan-out zero-fills an empty producer table with one entry + // per matching player, so a discard-of-nothing yields a + // non-empty all-zero table (Max = 0), never the fallback. + // + // The mirror-image hazard is a STALE PRESERVED table, not + // an absent one: `install_previous_effect_counts_by_player` + // KEEPS the prior table on its `None` arm when + // `preserve_counts_for_current_consumer` (`player_scope.is_none() + // && effect_consumes_event_context_amount`). In a chain + // A(count producer) -> B(EventContextAmount consumer, no + // player_scope) -> C(PreviousEffectAmount{Max}), C would + // fold A's table while `Sum` reads B's re-stamped scalar. + // Unreachable for the closed Max/Min class, measured: all 3 + // class cards are `Discard{All} -> Draw{PEA}` with the + // consumer in the IMMEDIATELY following link, so no B can + // interpose; and `Sum` is unaffected either way because it + // reads `last_effect_amount`, exactly as before this change. + AggregateFunction::Max => per_player.max().unwrap_or(total), + AggregateFunction::Min => per_player.min().unwrap_or(total), + } + } // CR 120.10: only the damage dealt BEYOND lethal — "the amount of // excess damage dealt to that creature this way" (Goblin // Negotiation, Hell to Pay, Lacerate Flesh), "that excess damage" - // (Contest of Claws). 0 when the preceding effect dealt no excess. + // (Contest of Claws). A scalar channel with no per-player table, so + // every aggregate reduces to it. 0 when no excess was dealt. DamageChannel::Excess => state.last_effect_excess_amount.unwrap_or(0), }, // Read the preceding continuation-local effect count directly. diff --git a/crates/engine/src/parser/oracle_effect/assembly.rs b/crates/engine/src/parser/oracle_effect/assembly.rs index 7f0db0704b..646e3aac89 100644 --- a/crates/engine/src/parser/oracle_effect/assembly.rs +++ b/crates/engine/src/parser/oracle_effect/assembly.rs @@ -2216,6 +2216,7 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { if let Effect::DamageEachPlayer { amount, .. } = def.effect.as_mut() { amount.rebind_event_context_amount(&QuantityRef::PreviousEffectAmount { channel: DamageChannel::Total, + aggregate: AggregateFunction::Sum, }); } } diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index 87be07b3a7..cdb7a5959e 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -1848,6 +1848,7 @@ pub(super) fn parse_targeted_action_ast( count = QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: crate::types::ability::DamageChannel::Total, + aggregate: crate::types::ability::AggregateFunction::Sum, }, }; } diff --git a/crates/engine/src/parser/oracle_effect/mana.rs b/crates/engine/src/parser/oracle_effect/mana.rs index 17b62ed0f1..3744258e9f 100644 --- a/crates/engine/src/parser/oracle_effect/mana.rs +++ b/crates/engine/src/parser/oracle_effect/mana.rs @@ -3508,6 +3508,7 @@ mod tests { QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: crate::types::ability::DamageChannel::Total, + aggregate: crate::types::ability::AggregateFunction::Sum, } }, "for-each tail must dispatch to PreviousEffectAmount" diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 3b2946b7ce..4abdfa2d75 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -38527,6 +38527,7 @@ fn for_each_prefix_pump_threads_self_ref_target() { Some(QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: crate::types::ability::DamageChannel::Total, + aggregate: AggregateFunction::Sum, }, }), "repeat_for should scale by counters removed in the activation cost" diff --git a/crates/engine/src/parser/oracle_effect/token.rs b/crates/engine/src/parser/oracle_effect/token.rs index fd9cf21dfb..dbda946870 100644 --- a/crates/engine/src/parser/oracle_effect/token.rs +++ b/crates/engine/src/parser/oracle_effect/token.rs @@ -1803,6 +1803,7 @@ mod tests { QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: crate::types::ability::DamageChannel::Total, + aggregate: crate::types::ability::AggregateFunction::Sum, }, }, ), diff --git a/crates/engine/src/parser/oracle_nom/condition.rs b/crates/engine/src/parser/oracle_nom/condition.rs index 32cdb5f96b..351ad1c903 100644 --- a/crates/engine/src/parser/oracle_nom/condition.rs +++ b/crates/engine/src/parser/oracle_nom/condition.rs @@ -9807,6 +9807,7 @@ pub fn parse_you_draw_this_way_condition(input: &str) -> OracleResult<'_, Abilit lhs: QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: crate::types::ability::DamageChannel::Total, + aggregate: AggregateFunction::Sum, }, }, comparator: Comparator::GE, diff --git a/crates/engine/src/parser/oracle_nom/quantity.rs b/crates/engine/src/parser/oracle_nom/quantity.rs index c1d6de9e94..c40b93c4d6 100644 --- a/crates/engine/src/parser/oracle_nom/quantity.rs +++ b/crates/engine/src/parser/oracle_nom/quantity.rs @@ -778,6 +778,7 @@ fn parse_excess_damage_ref(input: &str) -> OracleResult<'_, QuantityRef> { value( QuantityRef::PreviousEffectAmount { channel: DamageChannel::Excess, + aggregate: AggregateFunction::Sum, }, ( opt(alt((tag("the amount of "), tag("the ")))), @@ -797,6 +798,35 @@ fn parse_excess_damage_ref(input: &str) -> OracleResult<'_, QuantityRef> { .parse(input) } +/// CR 608.2c + CR 608.2i: "the greatest number of cards a player discarded this +/// way" — a look-back read of the completed discard instruction whose +/// SUPERLATIVE names the cross-player reduction. Windfall, Jace's Archivist, +/// Whispering Madness (Scryfall census 2026-08-15: exactly these three, +/// identical clause; zero "least/fewest" counterparts exist). +/// +/// The superlative is the AGGREGATE AXIS and must be REPORTED, not consumed and +/// thrown away: the legacy `oracle_quantity.rs` arm matched `greatest|highest` +/// and emitted a bare (Sum-equivalent) ref, so a four-player board with hands +/// 8/7/3/3 drew 21 — the cross-player SUM — instead of 8. Reuses the shipped +/// `parse_max_extremum_adjective` so `greatest`, `highest` and `largest` stay +/// ONE axis rather than three enumerated phrases. +pub(crate) fn parse_greatest_discarded_this_way(input: &str) -> OracleResult<'_, QuantityRef> { + value( + QuantityRef::PreviousEffectAmount { + channel: DamageChannel::Total, + aggregate: AggregateFunction::Max, + }, + ( + opt(tag("the ")), + parse_max_extremum_adjective, + tag(" number of cards "), + opt(alt((tag("a player "), tag("any player ")))), + tag("discarded this way"), + ), + ) + .parse(input) +} + /// CR 701.22a + CR 701.22d: "the number of cards looked at while scrying this /// way" — the effective (post-clamp) look count of the scry that fired the /// enclosing "whenever you scry" trigger (Elrond, Master of Healing: "put a @@ -1447,7 +1477,7 @@ fn parse_the_number_of(input: &str) -> OracleResult<'_, QuantityRef> { parse_number_of_inner(rest) } -/// CR 107.1: The maximizing extremum adjective. Oracle text prints several +/// The maximizing extremum adjective. Oracle text prints several /// interchangeable superlatives for the same `AggregateFunction::Max` /// ("greatest power", "highest mana value"); they are one axis, not one phrase /// each. Verdant Rejuvenation prints "highest". @@ -12180,4 +12210,57 @@ mod tests { "targeted of-form must stay TargetObjectManaValue, got {q:?}" ); } + + /// V7b — CR 608.2c + CR 608.2i: the widened "greatest number of cards a + /// player discarded this way" grammar, exercised where it lives. + /// + /// Both widenings over the deleted legacy arm are pinned here: the + /// superlative axis (`largest`, which the legacy `alt((greatest, highest))` + /// rejected) and the now-optional determiner. Revert + /// `parse_max_extremum_adjective` to `alt((greatest, highest))` and the + /// first two FAIL; make `tag("the ")` mandatory and the first FAILS. + #[test] + fn greatest_discarded_this_way_reports_the_max_aggregate() { + let max_ref = QuantityRef::PreviousEffectAmount { + channel: DamageChannel::Total, + aggregate: AggregateFunction::Max, + }; + + // Determiner-less AND widened adjective, both at once. + assert_eq!( + parse_greatest_discarded_this_way( + "largest number of cards a player discarded this way" + ) + .expect("determiner-less widened form must bind"), + ("", max_ref.clone()) + ); + assert_eq!( + parse_greatest_discarded_this_way( + "the largest number of cards any player discarded this way" + ) + .expect("widened adjective with determiner must bind"), + ("", max_ref.clone()) + ); + // The shipped production phrase. + assert_eq!( + parse_greatest_discarded_this_way( + "the greatest number of cards a player discarded this way" + ) + .expect("production Windfall phrase must bind"), + ("", max_ref) + ); + } + + /// V7b negative — the combinator cannot capture the superlative-free + /// `TrackedSetSize` phrase. Direct proof that adding this arm does not + /// steal "the number of cards a player discarded this way", which parses to + /// a tracked-set shape elsewhere. + #[test] + fn greatest_discarded_this_way_rejects_the_superlative_free_phrase() { + assert!( + parse_greatest_discarded_this_way("the number of cards a player discarded this way") + .is_err(), + "no superlative means no aggregate axis — must not match" + ); + } } diff --git a/crates/engine/src/parser/oracle_quantity.rs b/crates/engine/src/parser/oracle_quantity.rs index 57bcded329..61631b8024 100644 --- a/crates/engine/src/parser/oracle_quantity.rs +++ b/crates/engine/src/parser/oracle_quantity.rs @@ -165,6 +165,7 @@ pub(crate) fn parse_quantity_ref_with_context( if try_parse_counters_removed_this_way(rest) { return Some(QuantityRef::PreviousEffectAmount { channel: crate::types::ability::DamageChannel::Total, + aggregate: AggregateFunction::Sum, }); } } @@ -1267,7 +1268,7 @@ fn parse_owned_cards_in_zones_quantity( Ok((rest, expr)) } -/// CR 608.2c + CR 120.6 / CR 120.10: "[the] this way", reporting +/// CR 608.2c + CR 120.10: "[the] this way", reporting /// WHICH damage channel the phrase named. /// /// The damage arm carries a channel because "excess" is an independent qualifier @@ -1782,14 +1783,17 @@ pub(crate) fn parse_event_context_quantity(text: &str) -> Option { // - counter-removal chains: "counters removed", "counter removed" // (Sensational Spider-Man's "stun counters removed this way"; // `state.last_effect_amount` is stamped by the preceding RemoveCounter). - // PreviousEffectAmount reads `state.last_effect_amount` (CR 120.6) or + // PreviousEffectAmount reads `state.last_effect_amount` or // `state.last_effect_excess_amount` (CR 120.10), whichever channel the phrase // named — the combinator reports it rather than the caller assuming `Total`. // Assuming Total here is precisely what made "the excess damage dealt this // way" gain the FULL damage instead of the overkill (Razor Rings). if let Ok((_, channel)) = parse_previous_effect_amount_this_way(lower) { return Some(QuantityExpr::Ref { - qty: QuantityRef::PreviousEffectAmount { channel }, + qty: QuantityRef::PreviousEffectAmount { + channel, + aggregate: AggregateFunction::Sum, + }, }); } @@ -1812,21 +1816,14 @@ pub(crate) fn parse_event_context_quantity(text: &str) -> Option { }); } - if nom::combinator::all_consuming(( - tag::<_, _, OracleError<'_>>("the "), - alt((tag("greatest "), tag("highest "))), - tag("number of cards "), - nom::combinator::opt(alt((tag("a player "), tag("any player ")))), - tag("discarded this way"), - )) - .parse(lower) - .is_ok() - { - return Some(QuantityExpr::Ref { - qty: QuantityRef::PreviousEffectAmount { - channel: crate::types::ability::DamageChannel::Total, - }, - }); + // CR 608.2c + CR 608.2i: "the greatest number of cards a player discarded + // this way" — the superlative IS the aggregate axis and is REPORTED by the + // combinator, never matched and discarded. Grammar lives in + // `oracle_nom/quantity.rs` per the parser skill's single-authority + // doctrine; `Ok(("", …))` is the same full-consumption requirement the + // deleted `all_consuming` tuple expressed. + if let Ok(("", qty)) = nom_quantity::parse_greatest_discarded_this_way(lower) { + return Some(QuantityExpr::Ref { qty }); } // CR 614.1a: "that much/many [noun] (plus|minus) N" — Offset over the @@ -3211,6 +3208,7 @@ fn parse_for_each_clause_with_they_controller( { return Some(QuantityRef::PreviousEffectAmount { channel: crate::types::ability::DamageChannel::Total, + aggregate: AggregateFunction::Sum, }); } @@ -3334,6 +3332,7 @@ fn parse_for_each_clause_with_they_controller( if try_parse_counters_removed_this_way(&lower) { return Some(QuantityRef::PreviousEffectAmount { channel: crate::types::ability::DamageChannel::Total, + aggregate: AggregateFunction::Sum, }); } // CR 608.2c + CR 400.7: "nontoken creature you controlled that was @@ -4522,6 +4521,7 @@ mod tests { qty, QuantityRef::PreviousEffectAmount { channel: crate::types::ability::DamageChannel::Total, + aggregate: AggregateFunction::Sum, }, "{phrase:?} ({note})" ); @@ -4535,6 +4535,7 @@ mod tests { qty, QuantityRef::PreviousEffectAmount { channel: crate::types::ability::DamageChannel::Total, + aggregate: AggregateFunction::Sum, } ); } @@ -5920,7 +5921,7 @@ mod tests { ); } - /// CR 120.6: the TOTAL channel. Every phrase here names an unqualified + /// The TOTAL channel. Every phrase here names an unqualified /// numeric result — no "excess" qualifier — so it reads `last_effect_amount`. #[test] fn parse_event_context_quantity_previous_effect_this_way_variants() { @@ -5935,6 +5936,7 @@ mod tests { Some(QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: crate::types::ability::DamageChannel::Total, + aggregate: AggregateFunction::Sum, }, }), "phrase {phrase:?} must map to PreviousEffectAmount on the TOTAL channel" @@ -5942,6 +5944,66 @@ mod tests { } } + /// V7a — CR 608.2c + CR 608.2i: the PRODUCTION path reports the aggregate. + /// + /// `parse_event_context_quantity` is `pub(crate)`, so this guard must live + /// in-crate. It pins the delegation added at the deleted legacy block's + /// exact position: the superlative form must now carry + /// `AggregateFunction::Max` instead of the bare (Sum-equivalent) ref that + /// made Windfall draw the cross-player SUM. Revert the combinator's `Max` + /// to `Sum` and all three positives FAIL. + #[test] + fn parse_event_context_quantity_greatest_discarded_this_way_reports_max() { + for phrase in [ + "the greatest number of cards a player discarded this way", + "the highest number of cards a player discarded this way", + "the greatest number of cards any player discarded this way", + ] { + assert_eq!( + parse_event_context_quantity(phrase), + Some(QuantityExpr::Ref { + qty: QuantityRef::PreviousEffectAmount { + channel: crate::types::ability::DamageChannel::Total, + aggregate: AggregateFunction::Max, + }, + }), + "phrase {phrase:?} must report the MAX aggregate, not a bare ref" + ); + } + } + + /// V7a paired negatives — the superlative-free neighbours keep their + /// MEASURED shapes, so the new delegation cannot have stolen them. Each + /// `.expect(…)`s first, so neither can pass on a parse failure. + #[test] + fn parse_event_context_quantity_superlative_free_discard_phrases_are_unchanged() { + let bare = parse_event_context_quantity("the number of cards discarded this way") + .expect("superlative-free bare form must still parse"); + assert!( + matches!( + bare, + QuantityExpr::Ref { + qty: QuantityRef::FilteredTrackedSetSize { + caused_by: Some(ThisWayCause::Discarded), + .. + } + } + ), + "bare form must stay a filtered tracked-set read, got {bare:?}" + ); + + let per_player = + parse_event_context_quantity("the number of cards a player discarded this way") + .expect("superlative-free per-player form must still parse"); + assert_eq!( + per_player, + QuantityExpr::Ref { + qty: QuantityRef::TrackedSetSize + }, + "per-player superlative-free form must stay a tracked-set read" + ); + } + #[test] fn parse_event_context_quantity_opponents_dealt_damage_counts_event_players() { for phrase in [ @@ -5989,6 +6051,7 @@ mod tests { Some(QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: crate::types::ability::DamageChannel::Excess, + aggregate: AggregateFunction::Sum, }, }), "phrase {phrase:?} names EXCESS damage (CR 120.10) and must read the \ diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index 8d1fcc1f7b..ecc00adb23 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -19077,6 +19077,7 @@ fn trigger_coalition_relic_charge_counter_drain() { QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: crate::types::ability::DamageChannel::Total, + aggregate: AggregateFunction::Sum, } }, "for-each tail must dispatch to PreviousEffectAmount" @@ -28677,6 +28678,7 @@ fn valakut_exploration_end_step_trigger_hoists_gate_and_keeps_damage_shape() { qty: QuantityRef::PreviousEffectAmount { channel: DamageChannel::Total, + aggregate: AggregateFunction::Sum, }, }, player_filter: PlayerFilter::Opponent, diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 565f641081..29ab5f61a7 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -6907,7 +6907,7 @@ pub enum QuantityRef { /// behind — the same axis, and the same `DamageChannel`, already carried by /// the condition peer [`AbilityCondition::PreviousEffectAmount`]: /// - /// - [`DamageChannel::Total`] (default): the total amount (CR 120.6), via + /// - [`DamageChannel::Total`] (default): the total amount, via /// `GameState::last_effect_amount`. Every non-damage producer (life lost, /// counters removed, cards drawn) stamps only this channel. /// - [`DamageChannel::Excess`]: the EXCESS amount (CR 120.10) — damage dealt @@ -6918,14 +6918,46 @@ pub enum QuantityRef { /// /// A sibling `PreviousEffectExcessAmount` variant would be the textbook /// sibling-cluster smell: the channel is a leaf parameterization of one - /// structural axis, and it lies wholly inside CR 120 (120.6 total / - /// 120.10 excess), so it is a parameterization, not a new leaf. + /// structural axis, and both channels lie wholly inside CR 120 (the excess + /// channel at CR 120.10), so it is a parameterization, not a new leaf. /// /// `Total` is serde-elided, so every pre-existing serialized card is /// byte-identical. PreviousEffectAmount { #[serde(default, skip_serializing_if = "is_total_damage_channel")] channel: DamageChannel, + /// CR 608.2c + CR 608.2i: how the completed instruction's per-player + /// result table (`GameState::last_effect_counts_by_player`) is reduced + /// to the one number this look-back reference reads. + /// + /// - `Sum` (default): the cross-player TOTAL, read from + /// `GameState::last_effect_amount` — which + /// `install_previous_effect_counts_by_player` + /// (`game/effects/mod.rs`) stamps as the sum of the table, and the + /// ONLY channel a non-per-player producer (damage, life, counters, + /// draw, die roll) leaves behind. Byte-identical to the + /// pre-`aggregate` behaviour for every existing consumer. + /// - `Max`: the GREATEST single player's contribution — Windfall, + /// Jace's Archivist, Whispering Madness ("draws cards equal to the + /// greatest number of cards a player discarded this way"). Scryfall + /// census 2026-08-15 returns exactly these three. + /// - `Min`: no printed card uses it (same census: zero + /// "least/fewest … this way" cards). Present because + /// `AggregateFunction` is a shared 3-valued enum matched + /// exhaustively; the arm is a one-line `.min()`, not a stub. + /// + /// The `Excess` channel (CR 120.10) publishes a scalar, not a table + /// (`GameState::last_effect_excess_amount`), so on that channel + /// Max/Min/Sum of the single value coincide — degenerate by + /// construction, not silently ignored. + /// + /// `Sum` is serde-elided, so every pre-existing serialized card, + /// scenario and IR snapshot is byte-identical. + #[serde( + default = "default_sum_aggregate", + skip_serializing_if = "is_sum_aggregate" + )] + aggregate: AggregateFunction, }, /// Engine bookkeeping for the immediately preceding resolution-local effect /// count. This reads `GameState::last_effect_count` directly, defaults an @@ -7133,8 +7165,8 @@ pub enum QuantityRef { source: Box, target: Box, #[serde( - default = "default_damage_aggregate", - skip_serializing_if = "is_default_damage_aggregate" + default = "default_sum_aggregate", + skip_serializing_if = "is_sum_aggregate" )] aggregate: AggregateFunction, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -15388,7 +15420,7 @@ fn default_counter_transfer_mode() -> CounterTransferMode { CounterTransferMode::Move } -fn default_damage_aggregate() -> AggregateFunction { +fn default_sum_aggregate() -> AggregateFunction { AggregateFunction::Sum } @@ -15457,7 +15489,7 @@ fn default_most_prevalent_scope() -> ControllerRef { ControllerRef::You } -fn is_default_damage_aggregate(a: &AggregateFunction) -> bool { +fn is_sum_aggregate(a: &AggregateFunction) -> bool { matches!(a, AggregateFunction::Sum) } @@ -30005,7 +30037,7 @@ mod tests { let modern_total = QuantityRef::DamageDealtThisTurn { source: Box::new(TargetFilter::Any), target: Box::new(TargetFilter::Any), - aggregate: default_damage_aggregate(), + aggregate: default_sum_aggregate(), group_by: None, damage_kind: default_damage_kind(), channel: DamageChannel::Total, @@ -30046,7 +30078,7 @@ mod tests { let modern_excess = QuantityRef::DamageDealtThisTurn { source: Box::new(TargetFilter::Any), target: Box::new(TargetFilter::Any), - aggregate: default_damage_aggregate(), + aggregate: default_sum_aggregate(), group_by: None, damage_kind: default_damage_kind(), channel: DamageChannel::Excess, diff --git a/crates/engine/tests/integration/coalition_relic_integration.rs b/crates/engine/tests/integration/coalition_relic_integration.rs index ea673a4e5e..36d754b977 100644 --- a/crates/engine/tests/integration/coalition_relic_integration.rs +++ b/crates/engine/tests/integration/coalition_relic_integration.rs @@ -34,8 +34,8 @@ use engine::game::effects; use engine::game::scenario::{GameScenario, P0}; use engine::game::zones::create_object; use engine::types::ability::{ - AbilityCondition, AbilityKind, DamageChannel, Effect, ManaContribution, ManaProduction, - QuantityExpr, QuantityRef, ResolvedAbility, TargetFilter, TargetRef, + AbilityCondition, AbilityKind, AggregateFunction, DamageChannel, Effect, ManaContribution, + ManaProduction, QuantityExpr, QuantityRef, ResolvedAbility, TargetFilter, TargetRef, }; use engine::types::actions::GameAction; use engine::types::card_type::CoreType; @@ -64,9 +64,10 @@ fn build_coalition_relic_drain(controller: PlayerId, source: ObjectId) -> Resolv produced: ManaProduction::AnyOneColor { count: QuantityExpr::Ref { // CR 608.2c: the counters-removed count is a TOTAL-channel - // amount (CR 120.6) — the excess channel is damage-only. + // amount — the excess channel is damage-only. qty: QuantityRef::PreviousEffectAmount { channel: DamageChannel::Total, + aggregate: AggregateFunction::Sum, }, }, color_options: vec![ diff --git a/crates/engine/tests/integration/excess_damage_quantity_channel.rs b/crates/engine/tests/integration/excess_damage_quantity_channel.rs index 70368cd095..706b63f36e 100644 --- a/crates/engine/tests/integration/excess_damage_quantity_channel.rs +++ b/crates/engine/tests/integration/excess_damage_quantity_channel.rs @@ -31,7 +31,7 @@ use engine::game::quantity::resolve_quantity; use engine::game::scenario::{GameScenario, P0, P1}; use engine::parser::parse_oracle_text; -use engine::types::ability::{DamageChannel, Effect, QuantityExpr, QuantityRef}; +use engine::types::ability::{AggregateFunction, DamageChannel, Effect, QuantityExpr, QuantityRef}; use engine::types::game_state::{CastOfferKind, WaitingFor}; use engine::types::mana::ManaCost; use engine::types::phase::Phase; @@ -152,6 +152,7 @@ fn total_channel_is_unchanged_and_still_reads_last_effect_amount() { let total = QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: DamageChannel::Total, + aggregate: AggregateFunction::Sum, }, }; let resolved = resolve_quantity(runner.state(), &total, P0, source); diff --git a/crates/engine/tests/integration/issue_6858_draw_that_many_discard.rs b/crates/engine/tests/integration/issue_6858_draw_that_many_discard.rs index 190ecbccc6..c1d6305354 100644 --- a/crates/engine/tests/integration/issue_6858_draw_that_many_discard.rs +++ b/crates/engine/tests/integration/issue_6858_draw_that_many_discard.rs @@ -17,8 +17,8 @@ use engine::game::scenario::{GameRunner, GameScenario, P0}; use engine::types::ability::{ - AbilityDefinition, AbilityKind, CardSelectionMode, DamageChannel, Effect, QuantityExpr, - QuantityRef, TargetFilter, + AbilityDefinition, AbilityKind, AggregateFunction, CardSelectionMode, DamageChannel, Effect, + QuantityExpr, QuantityRef, TargetFilter, }; use engine::types::actions::GameAction; use engine::types::game_state::WaitingFor; @@ -63,6 +63,7 @@ fn draw_then_discard_that_many(draw: Effect) -> AbilityDefinition { count: QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: DamageChannel::Total, + aggregate: AggregateFunction::Sum, }, }, target: TargetFilter::Controller, diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 267db59c7e..9f12ab7be8 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1377,6 +1377,7 @@ mod wheel_of_misfortune_secret_numbers; mod where_x_coverage_runtime; mod where_x_quantity_channel_binds; mod where_x_totality_guard; +mod windfall_greatest_discard_aggregate; mod winding_way_reveal_partition_2931; mod witchs_oven_food_tokens; mod xantid_swarm_defending_player_cant_cast; diff --git a/crates/engine/tests/integration/valakut_exploration_end_step_exile_sweep.rs b/crates/engine/tests/integration/valakut_exploration_end_step_exile_sweep.rs index 16172bf626..e46e4134f6 100644 --- a/crates/engine/tests/integration/valakut_exploration_end_step_exile_sweep.rs +++ b/crates/engine/tests/integration/valakut_exploration_end_step_exile_sweep.rs @@ -3,7 +3,9 @@ //! tests drive the real Oracle parser and trigger-resolution pipeline. use engine::game::scenario::{GameScenario, P0, P1}; -use engine::types::ability::{DamageChannel, Effect, PlayerFilter, QuantityExpr, QuantityRef}; +use engine::types::ability::{ + AggregateFunction, DamageChannel, Effect, PlayerFilter, QuantityExpr, QuantityRef, +}; use engine::types::game_state::{ExileLink, ExileLinkKind}; use engine::types::identifiers::ObjectId; use engine::types::phase::Phase; @@ -62,6 +64,7 @@ fn assert_queued_total_damage_continuation( amount: QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: DamageChannel::Total, + aggregate: AggregateFunction::Sum, }, }, player_filter: PlayerFilter::Opponent, diff --git a/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs b/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs new file mode 100644 index 0000000000..35019f9a9f --- /dev/null +++ b/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs @@ -0,0 +1,167 @@ +//! Windfall's cross-player MAX aggregate — "the greatest number of cards a +//! player discarded this way". +//! +//! Oracle (Scryfall, verified verbatim 2026-08-15): +//! "Each player discards their hand, then draws cards equal to the greatest +//! number of cards a player discarded this way." +//! +//! Class: Windfall, Jace's Archivist, Whispering Madness — identical text. +//! +//! CR 608.2e: the discard action is processed simultaneously for every player, +//! then the draw action reads that completed action's result. +//! CR 608.2h: the draw count is determined ONCE, when the draw action is +//! applied — not re-derived per player as the fan-out proceeds. +//! CR 608.2i: that determination is a look-back at the already-completed +//! discard action, the exception to CR 608.2h this clause relies on. +//! CR 701.9a: to discard a card is to move it from hand to graveyard. +//! CR 121.2: drawing N cards is N individual card draws. +//! +//! The regression this pins: the engine reduces the per-player discard counts to +//! ONE untyped scalar whose aggregate lived on the PRODUCER. With the producer +//! set to a cross-player SUM, Windfall drew 8+7+3+3 = 21 for every player +//! instead of the greatest single player's 8. + +use engine::game::scenario::{GameScenario, Outcome, P0, P1}; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +const WINDFALL: &str = "Each player discards their hand, then draws cards equal to the greatest number of cards a player discarded this way."; + +/// Syphon Mind's shape — the cross-player SUM sibling that must STAY a sum. +/// Guards against a "fix" that flips the shared aggregate back to MAX globally. +const SYPHON_MIND: &str = + "Each other player discards a card. You draw a card for each card discarded this way."; + +const P2: PlayerId = PlayerId(2); +const P3: PlayerId = PlayerId(3); +const SEATS: [PlayerId; 4] = [P0, P1, P2, P3]; + +/// Deep enough that no draw in these tests is library-limited. +const LIBRARY_DEPTH: usize = 60; + +fn seed_library(scenario: &mut GameScenario, player: PlayerId, n: usize) { + for i in 0..n { + scenario.add_card_to_library_top(player, &format!("Filler {i}")); + } +} + +fn seed_hand(scenario: &mut GameScenario, player: PlayerId, n: usize) { + for i in 0..n { + scenario.add_card_to_hand(player, &format!("Hand Filler {i}")); + } +} + +fn zone_len(outcome: &Outcome, player: PlayerId, zone: Zone) -> usize { + let p = outcome + .state() + .players + .iter() + .find(|p| p.id == player) + .expect("player exists"); + match zone { + Zone::Hand => p.hand.len(), + Zone::Library => p.library.len(), + Zone::Graveyard => p.graveyard.len(), + other => panic!("zone_len does not cover {other:?}"), + } +} + +/// CR 608.2e + CR 121.2: four seats, hands 8/7/3/3 (the USER-reported board). +/// CR 608.2h: the greatest number of cards any one player discarded is 8 and is +/// determined once when the draw action is applied, so EVERY player draws +/// exactly 8. +/// +/// P0's eight are the cards held BESIDE Windfall: CR 601.2a removes the spell +/// from hand when the cast commits to the stack, so it is not itself discarded. +/// +/// Non-vacuous and discriminating: the four hand sizes make MAX (8), SUM (21), +/// MIN (3), and per-player (8/7/3/3) four mutually distinguishable outcomes, so +/// the assertion fails under every wrong aggregate, not merely the one that +/// shipped. The graveyard assertion is the reach guard — it proves the discard +/// step actually ran, so a spell that failed to parse or resolve cannot pass a +/// bare hand-size check for the wrong reason. +#[test] +fn windfall_draws_the_greatest_single_players_discard_not_the_cross_player_sum() { + let mut scenario = GameScenario::new_n_player(4, 42); + scenario.at_phase(Phase::PreCombatMain); + for (seat, hand) in SEATS.iter().zip([8usize, 7, 3, 3]) { + seed_hand(&mut scenario, *seat, hand); + seed_library(&mut scenario, *seat, LIBRARY_DEPTH); + } + let windfall = scenario + .add_spell_to_hand_from_oracle(P0, "Windfall", false, WINDFALL) + .with_mana_cost(ManaCost::zero()) + .id(); + let mut runner = scenario.build(); + + let outcome = runner.cast(windfall).resolve(); + + let drawn: Vec = SEATS + .iter() + .map(|p| LIBRARY_DEPTH - zone_len(&outcome, *p, Zone::Library)) + .collect(); + let hands: Vec = SEATS + .iter() + .map(|p| zone_len(&outcome, *p, Zone::Hand)) + .collect(); + let graveyards: Vec = SEATS + .iter() + .map(|p| zone_len(&outcome, *p, Zone::Graveyard)) + .collect(); + eprintln!("PROBE windfall/cast: drawn={drawn:?} hands={hands:?} graveyards={graveyards:?}"); + + // CR 701.9a reach guard: every player really did discard their whole hand. + assert!( + graveyards[0] >= 8 && graveyards[1] >= 7 && graveyards[2] >= 3 && graveyards[3] >= 3, + "reach guard: each player's hand must have reached the graveyard, got {graveyards:?}" + ); + assert_eq!( + drawn, + vec![8, 8, 8, 8], + "each player draws the GREATEST single-player discard (8), not the cross-player sum (21)" + ); + assert_eq!( + hands, + vec![8, 8, 8, 8], + "each hand holds exactly the freshly drawn cards" + ); +} + +/// The SUM sibling stays a sum. Syphon Mind in a four-player game: the three +/// other players each discard one card and the controller draws 3 — the +/// cross-player TOTAL. A global flip back to MAX would draw 1 here. +#[test] +fn syphon_mind_shape_still_draws_the_cross_player_total() { + let mut scenario = GameScenario::new_n_player(4, 42); + scenario.at_phase(Phase::PreCombatMain); + for seat in SEATS { + seed_hand(&mut scenario, seat, 1); + seed_library(&mut scenario, seat, LIBRARY_DEPTH); + } + let syphon = scenario + .add_spell_to_hand_from_oracle(P0, "Syphon Mind", false, SYPHON_MIND) + .with_mana_cost(ManaCost::zero()) + .id(); + let mut runner = scenario.build(); + + let outcome = runner.cast(syphon).resolve(); + + let drawn = LIBRARY_DEPTH - zone_len(&outcome, P0, Zone::Library); + let opponents_discarded: usize = [P1, P2, P3] + .iter() + .map(|p| zone_len(&outcome, *p, Zone::Graveyard)) + .sum(); + eprintln!("PROBE syphon/cast: drawn={drawn} opponents_discarded={opponents_discarded}"); + + // Reach guard: the discard step ran for all three opponents (CR 701.9a). + assert_eq!( + opponents_discarded, 3, + "reach guard: each of the three other players must discard one card" + ); + assert_eq!( + drawn, 3, + "controller draws one per card discarded across all opponents (sum), not the max (1)" + ); +} diff --git a/crates/phase-ai/src/policies/x_cast_gate.rs b/crates/phase-ai/src/policies/x_cast_gate.rs index 63af64899f..ac3b4c6c41 100644 --- a/crates/phase-ai/src/policies/x_cast_gate.rs +++ b/crates/phase-ai/src/policies/x_cast_gate.rs @@ -401,6 +401,7 @@ mod tests { amount: QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: engine::types::ability::DamageChannel::Total, + aggregate: engine::types::ability::AggregateFunction::Sum, }, }, player: TargetFilter::Controller, @@ -772,6 +773,7 @@ mod tests { count: QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: engine::types::ability::DamageChannel::Total, + aggregate: engine::types::ability::AggregateFunction::Sum, }, }, target: TargetFilter::Controller, diff --git a/crates/phase-ai/src/policies/x_reference.rs b/crates/phase-ai/src/policies/x_reference.rs index f0326de2d9..e221e73f74 100644 --- a/crates/phase-ai/src/policies/x_reference.rs +++ b/crates/phase-ai/src/policies/x_reference.rs @@ -354,9 +354,11 @@ fn is_cost_x_paid(qty: &QuantityRef) -> bool { } fn is_previous_amount(qty: &QuantityRef) -> bool { - // CR 120.6 / CR 120.10: both channels (total and excess) are amounts left by - // the preceding effect, so the AI's X-reference detection treats them alike — - // it cares that the value is chain-derived, not which tally it came from. + // CR 120.10: both channels (total and excess) are amounts left by the + // preceding effect, so the AI's X-reference detection treats them alike — + // it cares that the value is chain-derived, not which tally it came from, + // and every aggregate reduces the same table, so the detection is + // aggregate-agnostic too. matches!(qty, QuantityRef::PreviousEffectAmount { .. }) } From 8dd440382cbcd1a2f185e3854e70b654fae39bbe Mon Sep 17 00:00:00 2001 From: lgray Date: Sat, 15 Aug 2026 20:43:36 -0500 Subject: [PATCH 02/26] test(engine): make the Windfall aggregate tests discriminating Both incumbent tests passed identically before and after the aggregate fix, which is why #7277 shipped. Neither was measuring what its name claimed. `player_scope_discard_then_windfall_draws_greatest_discard_count` seeded 3-card libraries against hands of 3 and 1, so MAX 3 and SUM 4 both capped at 3 and the assertions held under either reading. Rebuild it over a shared board with 6-card libraries, where MAX 3, SUM 4, MIN 1 and the per-player reading 3/1 are four mutually distinguishable outcomes, and drive both aggregates through one builder so the Sum member is a same-board control for the Max member. `windfall_draw_uses_previous_discard_max_for_each_player` asserted the count via a `PreviousEffectAmount { .. }` wildcard, which matches every aggregate. Tighten it to the full literal so the parse is pinned to Max. Verified by revert probe rather than by inspection: reverting the resolver's Max arm turns the rebuilt test red with left [4, 4] / right [3, 3], while its Sum member on the same board stays green; reverting the combinator turns the wildcard test red on an assertion, not a compile error. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/effects/mod.rs | 62 +++++++++++++++++-- .../engine/src/parser/oracle_effect/tests.rs | 30 ++++++--- 2 files changed, 77 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index f1846a923e..9989b61c86 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -22813,8 +22813,20 @@ mod tests { assert_eq!(state.players[1].graveyard.len(), 1); } - #[test] - fn player_scope_discard_then_windfall_draws_greatest_discard_count() { + /// CR 608.2c + CR 701.9a + CR 121.2: the discard→draw "this way" + /// back-reference, exercised on ONE board across both aggregates. + /// + /// Libraries hold 6 (not 3, as the vacuous predecessor did) so neither + /// aggregate is library-capped: hands 3/1 make MAX 3, SUM 4, MIN 1 and the + /// per-player reading 3/1 four mutually distinguishable outcomes. The + /// predecessor seeded 3-card libraries, so MAX 3 and SUM 4 both capped at 3 + /// and its assertions held under either aggregate — which is why #7277 + /// shipped with the bug. + /// + /// Returns `(hands, graveyards)` per seat. + fn run_player_scope_discard_then_draw( + aggregate: AggregateFunction, + ) -> (Vec, Vec) { let mut state = GameState::new_two_player(42); for i in 0..3 { create_object( @@ -22824,6 +22836,8 @@ mod tests { format!("P0 Hand {i}"), Zone::Hand, ); + } + for i in 0..6 { create_object( &mut state, CardId(60 + i), @@ -22869,6 +22883,7 @@ mod tests { count: QuantityExpr::Ref { qty: QuantityRef::PreviousEffectAmount { channel: crate::types::ability::DamageChannel::Total, + aggregate, }, }, target: TargetFilter::Controller, @@ -22883,10 +22898,45 @@ mod tests { let mut events = Vec::new(); resolve_ability_chain(&mut state, &ability, &mut events, 0).unwrap(); - assert_eq!(state.players[0].hand.len(), 3); - assert_eq!(state.players[1].hand.len(), 3); - assert_eq!(state.players[0].graveyard.len(), 3); - assert_eq!(state.players[1].graveyard.len(), 1); + ( + vec![state.players[0].hand.len(), state.players[1].hand.len()], + vec![ + state.players[0].graveyard.len(), + state.players[1].graveyard.len(), + ], + ) + } + + /// V6 discriminator — CR 608.2c + CR 608.2i: `Max` reads the GREATEST single + /// player's discard (3), not the cross-player total. Flip this test's + /// `aggregate` to `Sum` and the hands become `[4, 4]` ⇒ FAILS. That field + /// swap on an otherwise byte-identical board IS the discrimination evidence; + /// neither member compiles at BASE, where the field does not exist. + #[test] + fn player_scope_discard_then_draw_greatest_uses_max_aggregate() { + let (hands, graveyards) = run_player_scope_discard_then_draw(AggregateFunction::Max); + assert_eq!( + hands, + vec![3, 3], + "Max must draw the greatest single player's discard (3), not the sum (4)" + ); + // CR 701.9a reach guard: the discard step really ran and moved 3 and 1 + // cards to the graveyards, so this cannot pass on an unresolved chain. + assert_eq!(graveyards, vec![3, 1], "both players must have discarded"); + } + + /// V6 same-board control — the `Sum` default keeps reading the cross-player + /// total (4) on the identical board, so the Max assertion above is pinned by + /// a measured contrast rather than by a single reading. + #[test] + fn player_scope_discard_then_draw_total_uses_sum_aggregate() { + let (hands, graveyards) = run_player_scope_discard_then_draw(AggregateFunction::Sum); + assert_eq!( + hands, + vec![4, 4], + "Sum must draw the cross-player total (3 + 1), the pre-change behaviour" + ); + assert_eq!(graveyards, vec![3, 1], "both players must have discarded"); } /// CR 608.2c + CR 118.12 + CR 701.9: Read the Runes — draw X, then for diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 4abdfa2d75..6f7636a9c8 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -12575,15 +12575,27 @@ fn windfall_draw_uses_previous_discard_max_for_each_player() { .as_ref() .expect("expected draw continuation"); assert_eq!(sub.player_scope, Some(PlayerFilter::All)); - assert!(matches!( - &*sub.effect, - Effect::Draw { - count: QuantityExpr::Ref { - qty: QuantityRef::PreviousEffectAmount { .. } - }, - target: TargetFilter::Controller, - } - )); + // CR 608.2c + CR 608.2i: the superlative names the cross-player reduction, + // so the draw count must carry `Max` — the whole point of this test's name. + // A `{ .. }` wildcard here pinned NOTHING and passed at BASE while Windfall + // drew the cross-player SUM. Revert the combinator's `Max` to `Sum`, or drop + // the `oracle_quantity.rs` delegation, and this FAILS. + assert!( + matches!( + &*sub.effect, + Effect::Draw { + count: QuantityExpr::Ref { + qty: QuantityRef::PreviousEffectAmount { + channel: crate::types::ability::DamageChannel::Total, + aggregate: AggregateFunction::Max, + } + }, + target: TargetFilter::Controller, + } + ), + "expected PreviousEffectAmount {{ Total, Max }}, got {:?}", + sub.effect + ); } /// CR 608.2c + CR 701.9 + CR 118.12: Read the Runes — draw X, then for each From 8c4a45dc1ab830c6a05b80d966ae1726a6f82534 Mon Sep 17 00:00:00 2001 From: lgray Date: Sat, 15 Aug 2026 22:08:03 -0500 Subject: [PATCH 03/26] fix(engine): freeze a draw clause's count once, per CR 608.2h `QuantityRef::PreviousEffectAmount` was re-read by every completed draw in a fan-out tail, so each draw re-stamped the shared scalar and every player after the first drew the wrong number: Windfall on hands 8/7/3/3 produced [5,5,5,5] where the rules require [5,8,8,8]. CR 608.2h fixes such a value "only once", when the spell or ability resolves -- not once per player the instruction fans out to. CR 608.2i's look-back exception is scoped to objects (zone, criteria), and so does not exempt the number. Admit `PreviousEffectAmount` to `collect_clause_minimum_refs` / `capture_clause_minimum_snapshot`, the existing CR 608.2h freeze mechanism, and read the snapshot before the live scalar in `game/quantity.rs`. The prompt census in `game/engine.rs` pins producer coordinates in `effects/mod.rs` by line; this commit's four hunks land above all three, so the pins move +24 uniformly. Re-pinned per that file's own drift-log protocol, with identity re-established rather than assumed: the 41-line window at each producer is sha256-identical to its old coordinate. Test-only, no production surface. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/effects/mod.rs | 50 ++++++-- crates/engine/src/game/engine.rs | 30 ++++- crates/engine/src/game/quantity.rs | 111 +++++++++++------- crates/engine/src/types/game_state.rs | 32 +++-- .../windfall_greatest_discard_aggregate.rs | 52 ++++++++ 5 files changed, 199 insertions(+), 76 deletions(-) diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 9989b61c86..f9a009a05c 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -4436,12 +4436,14 @@ fn detach_after_multi_target_player_local_chain( tail } -/// CR 608.2e: Collect cross-player equalization quantity references from a -/// `QuantityExpr`. These are the refs whose value would shift as an APNAP -/// fan-out mutates the board — `ControlledByEachPlayer` (battlefield extremum) -/// and `HandSize { AllPlayers }` (hand extremum). The per-player `left` operand -/// of a `Difference` is intentionally NOT collected: it must re-resolve per -/// iterating player. +/// CR 608.2h + CR 608.2e: Collect the quantity references whose answer is +/// determined only once, when the clause is applied (608.2h), across an APNAP +/// fan-out that is one action processed simultaneously (608.2e). Three classes +/// are admitted: `ControlledByEachPlayer` (battlefield extremum), +/// `HandSize { AllPlayers }` (hand extremum), and `PreviousEffectAmount` +/// (a look-back at a COMPLETED instruction's result — see the arm below). The +/// per-player `left` operand of a `Difference` is intentionally NOT collected: +/// it must re-resolve per iterating player. fn collect_clause_minimum_refs<'a>(expr: &'a QuantityExpr, out: &mut Vec<&'a QuantityRef>) { match expr { QuantityExpr::Ref { qty } => { @@ -4451,6 +4453,23 @@ fn collect_clause_minimum_refs<'a>(expr: &'a QuantityExpr, out: &mut Vec<&'a Qua | QuantityRef::HandSize { player: PlayerScope::AllPlayers { .. } } + // CR 608.2h: "the answer is determined only once, when the + // effect is applied." A `PreviousEffectAmount` is a look-back + // (CR 608.2i) at a COMPLETED instruction's result — it has no + // per-iteration reading at all (that reading is + // `EventContextAmount`, `game/quantity.rs`'s + // `QuantityRef::EventContextAmount` arm), so every channel + // and every aggregate is clause-frozen. Without this, each + // player's own completed action re-stamps the shared scalar + // (`install_previous_effect_counts_by_player`'s post-stamp → + // `previous_effect_amount_from_events`' `Effect::Draw` arm) + // and a later player inherits an earlier player's DELIVERED + // count — Windfall with a short library drew [5,5,5,5] + // instead of [5,8,8,8]. CR 608.2e supports it (one action, + // processed simultaneously); CR 121.2c confirms the + // SERIALIZATION of the multiplayer draw is itself correct — + // only the leaked count is not. + | QuantityRef::PreviousEffectAmount { .. } ) { out.push(qty); } @@ -4476,11 +4495,14 @@ fn collect_clause_minimum_refs<'a>(expr: &'a QuantityExpr, out: &mut Vec<&'a Qua } } -/// CR 608.2e (§8): Capture this `player_scope` link's equalization extrema -/// against the board as it stands NOW — before the APNAP fan-out begins. The -/// snapshot is stored on `state.clause_minimum_snapshot` and consulted by the -/// `ControlledByEachPlayer` / `HandSize { AllPlayers }` resolver arms so every -/// player in the fan-out sees the same pre-clause minimum. +/// CR 608.2h + CR 608.2e (§8): Capture this `player_scope` link's clause-frozen +/// quantities against the board as it stands NOW — before the APNAP fan-out +/// begins — because the answer is determined only once, when the effect is +/// applied (608.2h), and the fan-out is one action processed simultaneously +/// (608.2e). The snapshot is stored on `state.clause_minimum_snapshot` and +/// consulted by the `ControlledByEachPlayer` / `HandSize { AllPlayers }` / +/// `PreviousEffectAmount` resolver arms so every player in the fan-out sees the +/// same pre-clause value. /// /// Always overwrites `state.clause_minimum_snapshot` — to `Some` when the /// clause carries a cross-player extremum, to `None` otherwise. This makes @@ -4501,8 +4523,10 @@ fn collect_clause_minimum_refs<'a>(expr: &'a QuantityExpr, out: &mut Vec<&'a Qua /// structural rather than relying on the three cards' clauses using /// pairwise-distinct `QuantityRef` keys. fn capture_clause_minimum_snapshot(state: &mut GameState, scoped_template: &ResolvedAbility) { - // CR 608.2e: values are locked when the clause starts resolving, so each - // clause must capture against its own pre-clause board. + // CR 608.2h + CR 608.2e: the answer is determined only once, when the + // effect is applied, and the clause's fan-out is one action processed + // simultaneously — so values are locked when the clause starts resolving + // and each clause must capture against its own pre-clause board. // // Per-link reset: clear any previous clause's snapshot before resolving so // the live-resolve below sees a clean slate and a stale value is never diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 8b369025e2..1326cbe0d1 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -19012,11 +19012,31 @@ mod stage2_injector_tests { // Identity re-established, not assumed: `9869a19f28c791ee`, // `2bc316e3aa0297f8`, `8df98486627bfe15` at the new coordinates — the same // three digests this log has carried since the first merge. - // `PreviousEffectCount` classification adds one line above all three producers, - // so they move uniformly to `:7003/:7080/:10318`; no prompt site changes. - "game/effects/mod.rs:7003".to_string(), - "game/effects/mod.rs:7080".to_string(), - "game/effects/mod.rs:10318".to_string(), + // `PreviousEffectCount` classification (upstream) adds one line above all + // three producers, moving them `:7002/:7079/:10317 ⇒ :7003/:7080/:10318`; + // no prompt site changes. + // + // Windfall CR 608.2h clause-freeze (this branch, phase 2), rebased onto + // upstream/main: `:7003/:7080/:10318 ⇒ :7027/:7104/:10342`, uniform `+24` + // above all three. This commit's hunks in effects/mod.rs are all inside + // `collect_clause_minimum_refs` / `capture_clause_minimum_snapshot`, i.e. + // ABOVE every producer, and their net `+24` equals the whole-file delta, + // so nothing was added below the third. + // + // Coordinates located by CONTENT in THIS worktree, never by indexing a + // line of `upstream/main` — that ref moves, and using it as a coordinate + // origin once produced a phantom 79-line discrepancy in this very lane. + // Re-measured at the rebased coordinates rather than carried forward: the + // 41-line window centred on each producer hashes to `ad615ce4…`, + // `a958f070…`, `d7fd67fd…` — byte-for-byte the pre-rebase triple — with the + // off-by-one neighbour (`8251728c…`, `e3830eb5…`, `85037f22…`) differing as + // a control at all three. (Those digests are this branch's window + // convention; the `9869a19f…` triple recorded above is main's own, over a + // different span. Two conventions, same three producers — do not compare + // them to each other.) + "game/effects/mod.rs:7027".to_string(), + "game/effects/mod.rs:7104".to_string(), + "game/effects/mod.rs:10342".to_string(), // UNMOVED across the rebase, and that is itself evidence the SET did not // move: a census that had gained or lost a producer would not leave this // entry both byte-identical AND at the same coordinate. diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index 46a35b1f4c..8533af3f08 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -3981,53 +3981,74 @@ fn resolve_ref( // the preceding instruction in this resolution. `channel` selects WHICH // tally that instruction left behind; `aggregate` selects how the // per-player table is reduced to the one number this reference reads. - QuantityRef::PreviousEffectAmount { channel, aggregate } => match channel { - DamageChannel::Total => { - let total = state.last_effect_amount.unwrap_or(0); - let per_player = state.last_effect_counts_by_player.values().copied(); - match aggregate { - AggregateFunction::Sum => total, - // An absent table means the producer published NO per-player - // breakdown: only `Effect::Discard | DiscardCard | - // ChangeZoneAll` populate it; every other producer takes the - // `None` arm in `install_previous_effect_counts_by_player`, - // which clears it. For a SINGLE-subject producer the scalar - // IS the extremum, so the fallback is exact. For a - // MULTI-subject non-count producer — `Effect::DamageEachPlayer`, - // `Effect::DamageAll`, `Effect::LoseLife` under `player_scope` - // — the scalar is a cross-player SUM and a Max read would - // over-report. Unreachable today, measured: the Scryfall - // census (2026-08-15) returns exactly 3 cards in the Max - // class and all 3 follow an `Effect::Discard`, a count - // producer. The real-zero case is also safe: the discard - // fan-out zero-fills an empty producer table with one entry - // per matching player, so a discard-of-nothing yields a - // non-empty all-zero table (Max = 0), never the fallback. - // - // The mirror-image hazard is a STALE PRESERVED table, not - // an absent one: `install_previous_effect_counts_by_player` - // KEEPS the prior table on its `None` arm when - // `preserve_counts_for_current_consumer` (`player_scope.is_none() - // && effect_consumes_event_context_amount`). In a chain - // A(count producer) -> B(EventContextAmount consumer, no - // player_scope) -> C(PreviousEffectAmount{Max}), C would - // fold A's table while `Sum` reads B's re-stamped scalar. - // Unreachable for the closed Max/Min class, measured: all 3 - // class cards are `Discard{All} -> Draw{PEA}` with the - // consumer in the IMMEDIATELY following link, so no B can - // interpose; and `Sum` is unaffected either way because it - // reads `last_effect_amount`, exactly as before this change. - AggregateFunction::Max => per_player.max().unwrap_or(total), - AggregateFunction::Min => per_player.min().unwrap_or(total), + QuantityRef::PreviousEffectAmount { channel, aggregate } => { + // CR 608.2h + CR 608.2e: if this clause's `player_scope` link + // captured a snapshot, the answer was determined ONCE when the + // effect was applied (608.2h) and the whole fan-out is one action + // processed simultaneously (608.2e) — so every player in the + // fan-out must read that frozen value, not the scalar their own + // completed action re-stamped. CR 121.2c: the serialization of the + // multiplayer draw is itself correct; only the leaked count is not. + // Mirrors the snapshot-first shape of the `HandSize { AllPlayers }` + // and `ControlledByEachPlayer` arms. Placed before the channel + // match because the snapshot is keyed on the WHOLE `QuantityRef` + // (`ClauseMinimumSnapshot::get`), so it is channel- and + // aggregate-correct by key. + if let Some(v) = state + .clause_minimum_snapshot + .as_ref() + .and_then(|s| s.get(qty)) + { + return v; + } + match channel { + DamageChannel::Total => { + let total = state.last_effect_amount.unwrap_or(0); + let per_player = state.last_effect_counts_by_player.values().copied(); + match aggregate { + AggregateFunction::Sum => total, + // An absent table means the producer published NO per-player + // breakdown: only `Effect::Discard | DiscardCard | + // ChangeZoneAll` populate it; every other producer takes the + // `None` arm in `install_previous_effect_counts_by_player`, + // which clears it. For a SINGLE-subject producer the scalar + // IS the extremum, so the fallback is exact. For a + // MULTI-subject non-count producer — `Effect::DamageEachPlayer`, + // `Effect::DamageAll`, `Effect::LoseLife` under `player_scope` + // — the scalar is a cross-player SUM and a Max read would + // over-report. Unreachable today, measured: the Scryfall + // census (2026-08-15) returns exactly 3 cards in the Max + // class and all 3 follow an `Effect::Discard`, a count + // producer. The real-zero case is also safe: the discard + // fan-out zero-fills an empty producer table with one entry + // per matching player, so a discard-of-nothing yields a + // non-empty all-zero table (Max = 0), never the fallback. + // + // The mirror-image hazard is a STALE PRESERVED table, not + // an absent one: `install_previous_effect_counts_by_player` + // KEEPS the prior table on its `None` arm when + // `preserve_counts_for_current_consumer` (`player_scope.is_none() + // && effect_consumes_event_context_amount`). In a chain + // A(count producer) -> B(EventContextAmount consumer, no + // player_scope) -> C(PreviousEffectAmount{Max}), C would + // fold A's table while `Sum` reads B's re-stamped scalar. + // Unreachable for the closed Max/Min class, measured: all 3 + // class cards are `Discard{All} -> Draw{PEA}` with the + // consumer in the IMMEDIATELY following link, so no B can + // interpose; and `Sum` is unaffected either way because it + // reads `last_effect_amount`, exactly as before this change. + AggregateFunction::Max => per_player.max().unwrap_or(total), + AggregateFunction::Min => per_player.min().unwrap_or(total), + } } + // CR 120.10: only the damage dealt BEYOND lethal — "the amount of + // excess damage dealt to that creature this way" (Goblin + // Negotiation, Hell to Pay, Lacerate Flesh), "that excess damage" + // (Contest of Claws). A scalar channel with no per-player table, + // so every aggregate reduces to it. 0 when no excess was dealt. + DamageChannel::Excess => state.last_effect_excess_amount.unwrap_or(0), } - // CR 120.10: only the damage dealt BEYOND lethal — "the amount of - // excess damage dealt to that creature this way" (Goblin - // Negotiation, Hell to Pay, Lacerate Flesh), "that excess damage" - // (Contest of Claws). A scalar channel with no per-player table, so - // every aggregate reduces to it. 0 when no excess was dealt. - DamageChannel::Excess => state.last_effect_excess_amount.unwrap_or(0), - }, + } // Read the preceding continuation-local effect count directly. // An unavailable count resolves to zero. QuantityRef::PreviousEffectCount => state.last_effect_count.unwrap_or(0), diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 84cf0b5d64..b5b68f453b 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -14474,18 +14474,22 @@ impl StackEntryKind { } } -/// CR 608.2e: A clause-local snapshot of an equalization minimum/maximum, -/// frozen when a `player_scope` link begins so every player in that clause's -/// APNAP fan-out resolves its disposal count against the same pre-clause board. +/// CR 608.2h + CR 608.2e: A clause-local snapshot of a quantity whose answer is +/// determined only once, when the effect is applied (608.2h), frozen when a +/// `player_scope` link begins so every player in that clause's APNAP fan-out — +/// one action processed simultaneously (608.2e) — resolves against the same +/// pre-clause board. /// /// Balance's three clauses ("sacrifice lands", "discard cards", "sacrifice /// creatures") each compute an independent extremum at a different time. The /// `player_scope` driver re-resolves the effect's `count` expression on every /// per-player iteration; without a snapshot, after APNAP player 0 sacrifices /// down to the minimum, player 1 would recompute a smaller minimum. The -/// snapshot freezes only the cross-player aggregate (`ControlledByEachPlayer` / -/// `HandSize { AllPlayers }`); the per-player `left` operand still re-resolves -/// per iteration, which is correct. +/// snapshot freezes only the three clause-frozen classes +/// (`ControlledByEachPlayer` / `HandSize { AllPlayers }` / +/// `PreviousEffectAmount`, the last being a CR 608.2i look-back at a completed +/// instruction's result); the per-player `left` operand still re-resolves per +/// iteration, which is correct. /// /// Transient — never serialized. Captured before a `player_scope` link's /// fan-out and cleared when the link completes, so the next clause re-enters @@ -16827,13 +16831,15 @@ declare_game_state! { #[serde(serialize_with = "crate::types::deterministic_serde::hash_map")] pub last_effect_counts_by_player: HashMap, - /// CR 608.2e: Clause-local equalization snapshot. Each `player_scope` link - /// (e.g. a Balance clause) captures its cross-player extremum here before - /// the APNAP fan-out begins and clears it when the link completes, so every - /// player in that clause resolves against the same pre-clause board. The - /// per-link lifecycle is deliberately narrower than `last_vote_ballots`' - /// per-chain reset — three Balance clauses are three links in one chain and - /// must each snapshot independently. Transient. + /// CR 608.2h + CR 608.2e: Clause-local snapshot of the quantities whose + /// answer is determined only once, when the effect is applied. Each + /// `player_scope` link (e.g. a Balance clause, or Windfall's draw link) + /// captures its cross-player extremum or `PreviousEffectAmount` look-back + /// here before the APNAP fan-out begins and clears it when the link + /// completes, so every player in that clause resolves against the same + /// pre-clause board. The per-link lifecycle is deliberately narrower than + /// `last_vote_ballots`' per-chain reset — three Balance clauses are three + /// links in one chain and must each snapshot independently. Transient. #[serde(skip)] pub clause_minimum_snapshot: Option, diff --git a/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs b/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs index 35019f9a9f..bfa75e9d93 100644 --- a/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs +++ b/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs @@ -165,3 +165,55 @@ fn syphon_mind_shape_still_draws_the_cross_player_total() { "controller draws one per card discarded across all opponents (sum), not the max (1)" ); } + +/// PROBE for the second, independent defect the code map surfaced: the draw +/// tail keeps `player_scope: All` and re-fans-out, and each player's completed +/// draw re-stamps the shared scalar with that player's DELIVERED count. So a +/// player whose library ran short does not just draw fewer cards — they +/// redefine how many every LATER player draws. +/// +/// CR 608.2h: the draw action's count is determined only once, when the +/// effect is applied — one player's short library cannot change another +/// player's count. CR 608.2e: the whole fan-out is one action processed +/// simultaneously. CR 121.2c: the SERIALIZATION (the active player performs +/// all of their draws first, then each other player in turn order) is itself +/// rules-correct — only the leaked count is not. +/// +/// Discriminating: P0's library holds 5, everyone else 60. Correct = [5,8,8,8]. +/// Leaked-delivered-count = [5,5,5,5]. The two differ on three seats. +#[test] +fn windfall_short_library_does_not_shrink_later_players_draws() { + let mut scenario = GameScenario::new_n_player(4, 42); + scenario.at_phase(Phase::PreCombatMain); + for (seat, hand) in SEATS.iter().zip([8usize, 7, 3, 3]) { + seed_hand(&mut scenario, *seat, hand); + seed_library( + &mut scenario, + *seat, + if *seat == P0 { 5 } else { LIBRARY_DEPTH }, + ); + } + let windfall = scenario + .add_spell_to_hand_from_oracle(P0, "Windfall", false, WINDFALL) + .with_mana_cost(ManaCost::zero()) + .id(); + let mut runner = scenario.build(); + let outcome = runner.cast(windfall).resolve(); + + let drawn: Vec = SEATS + .iter() + .map(|p| { + let depth = if *p == P0 { 5 } else { LIBRARY_DEPTH }; + depth - zone_len(&outcome, *p, Zone::Library) + }) + .collect(); + eprintln!( + "PROBE windfall/short-library: drawn={drawn:?} waiting={:?}", + outcome.final_waiting_for() + ); + assert_eq!( + drawn, + vec![5, 8, 8, 8], + "P0's short library caps only P0; every later player still draws the greatest discard (8)" + ); +} From 2df1514659f4a24ad66230ed9e9a5540df91bfc6 Mon Sep 17 00:00:00 2001 From: lgray Date: Sun, 16 Aug 2026 09:44:14 -0500 Subject: [PATCH 04/26] fix(engine): count zero-contributors in the previous-effect table Review of the Windfall aggregate work surfaced seven findings; this commit applies all seven. The load-bearing one is a producer defect. The per-player table a completed instruction publishes is built from emitted events, so a player the clause applied to who contributed nothing -- an empty hand facing "each player discards their hand" -- emitted no event and was simply absent. An aggregate then reduced over a domain that omitted them. CR 608.2c: that player still discarded zero this way. The fill is now per player rather than all-or-nothing, extracted as `fill_zero_contributors`. Two of the three aggregates are blind to the omission, which is why it survived: Sum reads `last_effect_amount`, and Max cannot be raised by zeros. Only Min sees it -- hands 8/7/3/0 published {8,7,3} and answered 3 where the answer is 0. The defect is the reduction domain, not the Min arm. Also: a control that claimed to guard the cross-aggregate axis could not detect it (Syphon Mind builds no PreviousEffectAmount node at all) and is relabelled as the non-interference guard it actually is, with the aggregate axis discriminated at unit level where a populated table can be constructed; CR 120.6 struck from the condition peer it was still miscited on, matching the correction already applied to its QuantityRef twin; the categorical-boundary justification re-grounded on CR 608.2c/608.2i, since the Total channel is stamped by non-damage producers and has no CR 120 anchor; the clause_minimum_snapshot read added to the ability_scan enumeration whose stated purpose is to force re-classification; a unit sibling pair for the newly admitted freeze class; and the admission arm's unenforced precondition documented with the 44-card classification behind it. Every new test is revert-probed: each was made to fail on a value before being kept, and the probes isolate rather than overlap -- reverting the zero-fill reddens exactly one of its three tests. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/ability_scan.rs | 4 +- crates/engine/src/game/effects/mod.rs | 121 ++++++++++- crates/engine/src/game/engine.rs | 23 ++- crates/engine/src/game/quantity.rs | 104 ++++++++++ crates/engine/src/types/ability.rs | 32 ++- .../windfall_greatest_discard_aggregate.rs | 189 +++++++++++++++++- 6 files changed, 446 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index f4e74246ee..d472e2c11d 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -2171,7 +2171,9 @@ fn scan_quantity_ref(x: &QuantityRef, mode: ScanMode) -> Axes { QuantityRef::ExiledFromHandThisResolution => Axes::NONE, // CR 608.2c + CR 608.2i: every channel and every aggregate reads // resolution-local state — `last_effect_amount` / - // `last_effect_excess_amount` / `last_effect_counts_by_player`. All are + // `last_effect_excess_amount` / `last_effect_counts_by_player` / + // `clause_minimum_snapshot`, the last read FIRST (`game/quantity.rs`, + // the `PreviousEffectAmount` arm) as the CR 608.2h frozen value. All are // cleared at depth-0 chain entry (`resolve_ability_chain`); `apply()` // additionally clears `last_effect_count` and the per-player table at // every player action. None is a triggering-event characteristic diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index f9a009a05c..f6c1c2a6ef 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -4469,6 +4469,24 @@ fn collect_clause_minimum_refs<'a>(expr: &'a QuantityExpr, out: &mut Vec<&'a Qua // processed simultaneously); CR 121.2c confirms the // SERIALIZATION of the multiplayer draw is itself correct — // only the leaked count is not. + // + // PRECONDITION, unenforced by construction: this admission is + // unconditional, and `capture_clause_minimum_snapshot` walks + // the WHOLE scoped sub-chain, so it rests on the convention + // stated above — `PreviousEffectAmount` is clause-wide, + // `EventContextAmount` is per-iteration. A sub-link RETAINED + // inside the scoped template whose `PreviousEffectAmount` is + // meant to read *that iteration's* preceding effect would be + // frozen at the pre-clause value instead. No card does this + // today, measured: of the 44 corpus cards carrying both a + // `player_scope` and a `PreviousEffectAmount`, 4 hold it in + // the scoped node itself (Windfall, Jace's Archivist, + // Whispering Madness, Thorna and Twigtooth) and the other 40 + // are the drain shape `LoseLife` → `GainLife { PEA }`, which + // DETACHES because `effect_has_iteration_bound_recipient` + // returns false for `GainLife`. If a future card needs a + // per-iteration reading here, it wants `EventContextAmount`, + // not a guard on this arm. | QuantityRef::PreviousEffectAmount { .. } ) { out.push(qty); @@ -8338,6 +8356,32 @@ fn previous_effect_counts_by_player_from_events( /// instruction. `Some(empty)` is a real zero-result producer and must replace /// an older table; `None` means this effect has no such count channel, so clear /// the old table before callers preserve their ordinary scalar/excess fallback. +/// CR 608.2c: give every player the clause applied to an entry in the +/// completed-instruction table, defaulting a non-contributor to zero. +/// +/// The table is built from emitted events, so a player who contributed nothing — +/// an empty hand facing "each player discards their hand" — emits no event and +/// would otherwise be absent. They still discarded zero *this way*, and the +/// table is what an aggregate reduces over, so an omission is a wrong reduction +/// domain rather than a missing convenience. +/// +/// The omission is invisible to two of the three aggregates, which is why it +/// survived: `Sum` reads `last_effect_amount` (and adding zeros could not move a +/// sum anyway) and `Max` cannot be raised by zeros. Only `Min` sees it — hands +/// 8/7/3/**0** publish `{8,7,3}` and answer 3 where the answer is 0. The defect +/// is the domain, not the `Min` arm. +/// +/// Existing entries are never overwritten: a player who contributed 3 keeps 3. +fn fill_zero_contributors( + mut counts_by_player: HashMap, + matching_players: &[PlayerId], +) -> HashMap { + for player in matching_players.iter().copied() { + counts_by_player.entry(player).or_insert(0); + } + counts_by_player +} + fn install_previous_effect_counts_by_player( state: &mut GameState, counts_by_player: Option>, @@ -9888,17 +9932,8 @@ fn resolve_chain_body( scoped_template.source_id, scoped_events, ); - // CR 608.2c: A completed scoped count producer that moved/discarded - // nothing still produced a zero for every player in this fan-out. Keep - // that provenance distinct from the absence of a count producer: the - // nonempty zero table takes precedence over an enclosing scalar event - // when the detached scoped "that many" consumer resolves. - let counts_by_player = counts_by_player.map(|mut counts_by_player| { - if counts_by_player.is_empty() { - counts_by_player.extend(matching_players.iter().copied().map(|player| (player, 0))); - } - counts_by_player - }); + let counts_by_player = + counts_by_player.map(|counts| fill_zero_contributors(counts, &matching_players)); if !install_previous_effect_counts_by_player(state, counts_by_player, false) { if let Some(amount) = previous_effect_amount_from_events(state, &scoped_template, scoped_events) @@ -18430,6 +18465,70 @@ mod tests { ))); } + /// CR 608.2c: the PRODUCER half of the zero-contributor fix. A player the + /// clause applied to who emitted no event still discarded zero *this way* + /// and must hold an entry, or an aggregate reduces over a domain that omits + /// them. + /// + /// Board 8/7/3/**0**: P3's empty hand emits no discard event, so the + /// event-built table arrives as `{8,7,3}`. Discriminating on the axis that + /// matters — `Min` over the filled table is 0, over the unfilled one 3. + /// (`Sum` and `Max` are provably blind to the omission, which is why this + /// needs its own test rather than riding an existing one.) + #[test] + fn fill_zero_contributors_adds_the_absent_player_as_zero() { + let mut counts = HashMap::new(); + counts.insert(PlayerId(0), 8); + counts.insert(PlayerId(1), 7); + counts.insert(PlayerId(2), 3); + let seats = [PlayerId(0), PlayerId(1), PlayerId(2), PlayerId(3)]; + + let unfilled_min = counts.values().copied().min(); + let filled = fill_zero_contributors(counts, &seats); + + let mut rows: Vec<(u8, i32)> = filled.iter().map(|(p, n)| (p.0, *n)).collect(); + rows.sort(); + assert_eq!( + rows, + vec![(0, 8), (1, 7), (2, 3), (3, 0)], + "the absent player is present holding 0" + ); + assert_eq!(unfilled_min, Some(3), "control: unfilled, Min answers 3"); + assert_eq!( + filled.values().copied().min(), + Some(0), + "filled, Min answers 0 — the whole point of the fix" + ); + } + + /// Contributors are never overwritten, and an already-complete table is + /// unchanged — so the fill cannot corrupt the common case it runs on every + /// time. + #[test] + fn fill_zero_contributors_preserves_existing_counts() { + let mut counts = HashMap::new(); + counts.insert(PlayerId(0), 5); + counts.insert(PlayerId(1), 2); + let seats = [PlayerId(0), PlayerId(1)]; + + let filled = fill_zero_contributors(counts, &seats); + + assert_eq!(filled.get(&PlayerId(0)).copied(), Some(5)); + assert_eq!(filled.get(&PlayerId(1)).copied(), Some(2)); + assert_eq!(filled.len(), 2, "no phantom entries added"); + } + + /// The previously-handled case still behaves identically: an entirely empty + /// table becomes an all-zero table, one entry per matching player. + #[test] + fn fill_zero_contributors_fills_an_entirely_empty_table() { + let seats = [PlayerId(0), PlayerId(1), PlayerId(2)]; + let filled = fill_zero_contributors(HashMap::new(), &seats); + let mut rows: Vec<(u8, i32)> = filled.iter().map(|(p, n)| (p.0, *n)).collect(); + rows.sort(); + assert_eq!(rows, vec![(0, 0), (1, 0), (2, 0)]); + } + #[test] fn previous_effect_amount_for_damage_ignores_counter_side_effects() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 1326cbe0d1..1360ad8dce 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -19034,9 +19034,26 @@ mod stage2_injector_tests { // convention; the `9869a19f…` triple recorded above is main's own, over a // different span. Two conventions, same three producers — do not compare // them to each other.) - "game/effects/mod.rs:7027".to_string(), - "game/effects/mod.rs:7104".to_string(), - "game/effects/mod.rs:10342".to_string(), + // + // Review-fix round (same branch, still phase 2): + // `:7027/:7104/:10342 ⇒ :7045/:7122/:10377`. NON-UNIFORM — `+18/+18/+35` + // — because two separate edits land above the third producer but only one + // lands above the first two: the `collect_clause_minimum_refs` + // admission-arm precondition comment and, below it, the extraction of the + // zero-contributor fill into `fill_zero_contributors` plus its unit tests. + // A non-uniform shift is exactly the shape a "+N to all three" assumption + // gets wrong. + // + // This `+18/+18/+35` has now been derived FOUR times from four different + // origins — pre-rebase off this branch, and three more times off three + // different upstream tips — and came out identical each time. A shift + // invariant under change of origin is pure line movement, not a changed + // producer set. Window hashes `ad615ce4…`, `a958f070…`, `d7fd67fd…` + // re-measured at these coordinates, with the off-by-one neighbour + // (`8251728c…`, `e3830eb5…`, `85037f22…`) differing as a control. + "game/effects/mod.rs:7045".to_string(), + "game/effects/mod.rs:7122".to_string(), + "game/effects/mod.rs:10377".to_string(), // UNMOVED across the rebase, and that is itself evidence the SET did not // move: a census that had gained or lost a producer would not leave this // entry both byte-identical AND at the same coordinate. diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index 8533af3f08..3cb2e8dda6 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -17554,6 +17554,110 @@ mod tests { assert_eq!(resolve_quantity(&state, &qty, PlayerId(0), ObjectId(0)), 5); } + #[test] + fn previous_effect_amount_prefers_clause_snapshot() { + // CR 608.2h: the third class admitted to the clause freeze. The live + // tally is deliberately set to a DIFFERENT value than the frozen one, so + // the assertion fails if the snapshot read is removed or ordered after + // the channel match. This is the unit-level peer of the integration + // test `windfall_short_library_does_not_shrink_later_players_draws`, + // where the live value is what a completed draw re-stamped. + let mut state = GameState::new_two_player(42); + let qref = QuantityRef::PreviousEffectAmount { + channel: DamageChannel::Total, + aggregate: AggregateFunction::Max, + }; + let mut snap = crate::types::game_state::ClauseMinimumSnapshot::default(); + snap.insert(qref.clone(), 8); + state.clause_minimum_snapshot = Some(snap); + // Live state says 5 — the post-fan-out value the freeze must override. + state.last_effect_amount = Some(5); + state.last_effect_counts_by_player.insert(PlayerId(0), 5); + let qty = QuantityExpr::Ref { qty: qref }; + assert_eq!(resolve_quantity(&state, &qty, PlayerId(0), ObjectId(0)), 8); + } + + #[test] + fn previous_effect_amount_live_when_no_snapshot() { + // The fallback arm: with no clause snapshot the ref reads live state, so + // `Max` over the per-player table {P0:8, P1:3} is 8. Pairs with the test + // above — together they show the snapshot is PREFERRED, not the only + // path, so a fix that always returned the snapshot would fail here. + let mut state = GameState::new_two_player(42); + state.last_effect_amount = Some(11); + state.last_effect_counts_by_player.insert(PlayerId(0), 8); + state.last_effect_counts_by_player.insert(PlayerId(1), 3); + let qty = QuantityExpr::Ref { + qty: QuantityRef::PreviousEffectAmount { + channel: DamageChannel::Total, + aggregate: AggregateFunction::Max, + }, + }; + assert_eq!(resolve_quantity(&state, &qty, PlayerId(0), ObjectId(0)), 8); + } + + /// All three reductions of ONE table must be mutually distinguishable, or a + /// test that pins any of them proves nothing about the others. Table + /// {8,7,3} with `last_effect_amount` 18: Sum 18 / Max 8 / Min 3 — three + /// distinct values, so each assertion below fails if its arm is swapped for + /// either sibling. + #[test] + fn previous_effect_amount_aggregates_are_mutually_distinct() { + let mut state = GameState::new_two_player(42); + state.last_effect_amount = Some(18); + for (p, n) in [(0, 8), (1, 7), (2, 3)] { + state.last_effect_counts_by_player.insert(PlayerId(p), n); + } + let read = |agg| { + resolve_quantity( + &state, + &QuantityExpr::Ref { + qty: QuantityRef::PreviousEffectAmount { + channel: DamageChannel::Total, + aggregate: agg, + }, + }, + PlayerId(0), + ObjectId(0), + ) + }; + assert_eq!(read(AggregateFunction::Sum), 18, "Sum reads the total"); + assert_eq!(read(AggregateFunction::Max), 8, "Max reads the greatest"); + assert_eq!(read(AggregateFunction::Min), 3, "Min reads the least"); + } + + /// CR 608.2c: a player the clause applied to who contributed NOTHING still + /// contributed zero *this way*, so the table must carry them. + /// + /// This is the resolver half of the producer fix: given a table that + /// includes the zero-contributor, `Min` must be 0. The producer half — that + /// the table actually gets that entry — is + /// `windfall_empty_hand_player_is_in_the_per_player_table`. + /// + /// Board 8/7/3/**0**. Before the producer fix the table omitted the + /// zero-contributor and published {8,7,3}, so `Min` answered 3. `Max` is + /// immune to the omission (zeros cannot raise a maximum) and `Sum` reads + /// `last_effect_amount`, which is why the shipped Max class never saw it. + #[test] + fn previous_effect_amount_min_counts_the_zero_contributor() { + let mut state = GameState::new_two_player(42); + state.last_effect_amount = Some(18); + for (p, n) in [(0, 8), (1, 7), (2, 3), (3, 0)] { + state.last_effect_counts_by_player.insert(PlayerId(p), n); + } + let qty = QuantityExpr::Ref { + qty: QuantityRef::PreviousEffectAmount { + channel: DamageChannel::Total, + aggregate: AggregateFunction::Min, + }, + }; + assert_eq!( + resolve_quantity(&state, &qty, PlayerId(0), ObjectId(0)), + 0, + "the empty-handed player discarded 0 this way; the minimum is 0, not 3" + ); + } + #[test] fn hand_size_all_players_min_live_when_no_snapshot() { // Without a snapshot, `HandSize { AllPlayers { Min } }` resolves live — diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 29ab5f61a7..fc57a202f4 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -6918,11 +6918,21 @@ pub enum QuantityRef { /// /// A sibling `PreviousEffectExcessAmount` variant would be the textbook /// sibling-cluster smell: the channel is a leaf parameterization of one - /// structural axis, and both channels lie wholly inside CR 120 (the excess - /// channel at CR 120.10), so it is a parameterization, not a new leaf. + /// structural axis, so it is a parameterization, not a new leaf. + /// + /// The categorical boundary is CR 608.2c / CR 608.2i — this reference's OWN + /// section. Both channels are readings of the same look-back at the one + /// completed instruction; they differ only in which tally that instruction + /// left behind. (An earlier revision justified the boundary as "both + /// channels lie wholly inside CR 120". That is wrong and is struck: CR 120 + /// is Damage, while the `Total` channel is stamped by non-damage producers — + /// life lost, counters removed, cards drawn — as three lines above already + /// say. CR 120.10 is cited only where it does apply, for what "excess" + /// means.) /// /// `Total` is serde-elided, so every pre-existing serialized card is - /// byte-identical. + /// byte-identical — a parse-diff fidelity property, not a save-compatibility + /// promise. PreviousEffectAmount { #[serde(default, skip_serializing_if = "is_total_damage_channel")] channel: DamageChannel, @@ -21403,19 +21413,27 @@ pub enum AbilityCondition { comparator: Comparator, rhs: QuantityExpr, }, - /// CR 608.2c + CR 120.6 + CR 120.10: Compares the numeric result tracked from + /// CR 608.2c + CR 120.10: Compares the numeric result tracked from /// the previous instruction in the same resolution against `rhs`. The /// `channel` selects which resolution-local tally is read: - /// - `DamageChannel::Total` (default): the *total* amount (CR 120.6) via + /// - `DamageChannel::Total` (default): the total amount via /// `last_effect_amount` — the same channel that feeds - /// `QuantityRef::PreviousEffectAmount` / `EventContextAmount`. + /// `QuantityRef::PreviousEffectAmount` / `EventContextAmount`. Every + /// non-damage producer (life lost, counters removed, cards drawn) stamps + /// only this channel, so no CR 120 rule governs it; the look-back itself + /// is CR 608.2c. /// - `DamageChannel::Excess`: the *excess* amount (CR 120.10) via /// `last_effect_excess_amount` — damage dealt beyond lethal /// ("if excess damage was dealt … this way"). + /// + /// CR 120.6 was cited here for the `Total` channel and is struck: it governs + /// marked damage persisting until cleanup, not an amount left behind by a + /// preceding effect. Mirrors the identical correction on the `QuantityRef` + /// peer — the two must not disagree about the same channel. PreviousEffectAmount { comparator: Comparator, rhs: QuantityExpr, - /// CR 120.6 / CR 120.10: which resolution-local channel to compare + /// CR 608.2c / CR 120.10: which resolution-local channel to compare /// against. Reuses the committed `DamageChannel`; `Total` is serde-elided /// so every existing card is byte-identical. #[serde(default, skip_serializing_if = "is_total_damage_channel")] diff --git a/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs b/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs index bfa75e9d93..658e37877a 100644 --- a/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs +++ b/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs @@ -29,11 +29,41 @@ use engine::types::zones::Zone; const WINDFALL: &str = "Each player discards their hand, then draws cards equal to the greatest number of cards a player discarded this way."; -/// Syphon Mind's shape — the cross-player SUM sibling that must STAY a sum. -/// Guards against a "fix" that flips the shared aggregate back to MAX globally. +/// Syphon Mind's shape — the NON-superlative "discarded this way" neighbour. +/// +/// This does NOT guard the aggregate axis, and an earlier revision of this file +/// claimed that it did. Syphon Mind parses to `FilteredTrackedSetSize` and +/// carries no `PreviousEffectAmount` node at all, so it is structurally +/// incapable of detecting a change to `QuantityRef::PreviousEffectAmount`'s +/// aggregate — measured: it stays green under BOTH the aggregate revert and the +/// clause-freeze revert. What it does guard is real and worth keeping: that the +/// superlative combinator did not STEAL the non-superlative phrasing, i.e. this +/// card still reaches `FilteredTrackedSetSize` and still sums. +/// +/// The aggregate axis is guarded at unit level instead — see +/// `game/quantity.rs`'s `previous_effect_amount_live_when_no_snapshot` and +/// `previous_effect_amount_aggregates_are_mutually_distinct`. Measured: no +/// printed card yields a clean integration-level Sum-vs-Max discriminator. const SYPHON_MIND: &str = "Each other player discards a card. You draw a card for each card discarded this way."; +/// Blood Tithe — the drain shape, and the class the corpus actually populates: +/// 40 of the 44 cards carrying both a `player_scope` and a +/// `PreviousEffectAmount` are this `LoseLife` → `GainLife { PreviousEffectAmount }` +/// form. +/// +/// Unlike Syphon Mind this DOES build `PreviousEffectAmount`, with `aggregate` +/// absent and therefore `Sum`. CR 119.3: an effect causing a player to gain or +/// lose life adjusts that life total accordingly — one rule covers both +/// directions here. "The life lost this way" is the cross-player TOTAL, 9. +/// +/// It is a REACH guard, not an aggregate discriminator: `Effect::LoseLife` +/// publishes no per-player table, so `Max`/`Min` fall back to the total and all +/// three reductions coincide at 9. Measured, not reasoned — see the degeneracy +/// note on the test itself. +const BLOOD_TITHE: &str = + "Each opponent loses 3 life. You gain life equal to the life lost this way."; + const P2: PlayerId = PlayerId(2); const P3: PlayerId = PlayerId(3); const SEATS: [PlayerId; 4] = [P0, P1, P2, P3]; @@ -129,9 +159,18 @@ fn windfall_draws_the_greatest_single_players_discard_not_the_cross_player_sum() ); } -/// The SUM sibling stays a sum. Syphon Mind in a four-player game: the three -/// other players each discard one card and the controller draws 3 — the -/// cross-player TOTAL. A global flip back to MAX would draw 1 here. +/// NON-INTERFERENCE, not an aggregate guard. Syphon Mind in a four-player game: +/// the three other players each discard one card and the controller draws 3. +/// +/// What this discriminates: that the superlative combinator did not swallow the +/// non-superlative "discarded this way" phrasing — this card must still reach +/// `FilteredTrackedSetSize` and still sum. What it does NOT discriminate: the +/// aggregate axis. Syphon Mind builds no `PreviousEffectAmount` node, so it +/// cannot see a change to that ref's `aggregate` and stays green under both +/// revert arms. The cross-aggregate guard lives at unit level, in +/// `game/quantity.rs`'s `previous_effect_amount_aggregates_are_mutually_distinct` +/// and `previous_effect_amount_live_when_no_snapshot` — no printed card gives a +/// clean integration-level Sum-vs-Max discriminator. #[test] fn syphon_mind_shape_still_draws_the_cross_player_total() { let mut scenario = GameScenario::new_n_player(4, 42); @@ -166,6 +205,146 @@ fn syphon_mind_shape_still_draws_the_cross_player_total() { ); } +/// CR 608.2c: a zero-contributor board must not disturb the Max class. +/// +/// Board 8/7/3/**0** — P3 has an empty hand, so "each player discards their +/// hand" emits no discard event for them and the event-built table arrives as +/// `{8,7,3}` with P3 absent. The producer fills that gap with a 0 so an +/// aggregate reduces over every subject. +/// +/// SCOPE — this asserts the NON-REGRESSION half only: the greatest discard is +/// still 8, so every player including the empty-handed one still draws 8. It +/// does NOT assert the table's contents, and deliberately so: +/// `last_effect_counts_by_player` is cleared at the player-action boundary, so +/// it reads `[]` from `outcome.state()` regardless of the fix. An earlier +/// revision asserted on it and failed with `left: []` — an INSTRUMENT failure, +/// not a fix failure. The table's contents are asserted where they survive, at +/// unit level: `game/effects/mod.rs`'s `fill_zero_contributors_*` tests, which +/// pin `Min` at 0 filled versus 3 unfilled. +#[test] +fn windfall_zero_contributor_board_still_draws_the_greatest() { + let mut scenario = GameScenario::new_n_player(4, 42); + scenario.at_phase(Phase::PreCombatMain); + seed_hand(&mut scenario, P0, 8); + seed_hand(&mut scenario, P1, 7); + seed_hand(&mut scenario, P2, 3); + // P3: no hand at all — the zero contributor. + for seat in SEATS { + seed_library(&mut scenario, seat, LIBRARY_DEPTH); + } + let windfall = scenario + .add_spell_to_hand_from_oracle(P0, "Windfall", false, WINDFALL) + .with_mana_cost(ManaCost::zero()) + .id(); + let mut runner = scenario.build(); + + let outcome = runner.cast(windfall).resolve(); + + let drawn: Vec = SEATS + .iter() + .map(|p| LIBRARY_DEPTH - zone_len(&outcome, *p, Zone::Library)) + .collect(); + let graveyards: Vec = SEATS + .iter() + .map(|p| zone_len(&outcome, *p, Zone::Graveyard)) + .collect(); + eprintln!("PROBE windfall/zero-contributor: drawn={drawn:?} graveyards={graveyards:?}"); + + // CR 701.9a reach guard: the discard step ran, and P3 really contributed + // nothing — without this, an all-8 draw could pass on a board that never + // had a zero contributor at all. + assert_eq!( + graveyards[3], 0, + "reach guard: P3 must be the zero contributor, got {graveyards:?}" + ); + assert!( + graveyards[0] >= 8, + "reach guard: the discard step must have run, got {graveyards:?}" + ); + assert_eq!( + drawn, + vec![8, 8, 8, 8], + "non-regression: the greatest discard is still 8, so every player draws 8" + ); +} + +/// REACH + non-regression guard for the drain class — NOT an aggregate +/// discriminator. Read the measured degeneracy below before trusting it as one. +/// +/// Blood Tithe in a four-player game: each of the three opponents loses 3 life, +/// so "the life lost this way" is 3 + 3 + 3 = 9 (CR 119.3) and the controller +/// gains 9. This is the shape 40 of the 44 corpus cards carrying both a +/// `player_scope` and a `PreviousEffectAmount` take, so it is the widest +/// non-regression this file has. +/// +/// WHAT IT DISCRIMINATES, measured by sentinel probe: the ref is genuinely +/// reached — forcing an early `return 999` at the top of the +/// `QuantityRef::PreviousEffectAmount` arm moves this card to 1019 life. So a +/// change that stopped routing the drain class through that arm fails here. +/// +/// WHAT IT DOES **NOT** DISCRIMINATE: the aggregate axis. `Effect::LoseLife` +/// publishes no per-player breakdown — only `Discard` / `DiscardCard` / +/// `ChangeZoneAll` populate `last_effect_counts_by_player` — so the table is +/// EMPTY here and `Max`/`Min` both fall back to `unwrap_or(total)`. All three +/// reductions coincide: +/// +/// Sum -> 9 Max -> 9 Min -> 9 (degenerate) +/// +/// Measured, not reasoned: forcing `AggregateFunction::Sum => per_player.max() +/// .unwrap_or(total)` leaves this test green at 29. An earlier revision of this +/// comment claimed `Max -> 3` and that a global flip would fail here. That was +/// wrong, and it is the same error as the Syphon Mind control above — a +/// discriminating claim derived from the parse tree and never revert-probed. +/// +/// The aggregate axis IS discriminated, at unit level where a populated table +/// can be constructed directly: `game/quantity.rs`'s +/// `previous_effect_amount_live_when_no_snapshot` asserts `Max` = 8 over +/// `{P0:8, P1:3}` with `last_effect_amount` = 11, so `Sum` fails it. +#[test] +fn blood_tithe_drain_still_gains_the_cross_player_total() { + let mut scenario = GameScenario::new_n_player(4, 42); + scenario.at_phase(Phase::PreCombatMain); + for seat in SEATS { + seed_library(&mut scenario, seat, LIBRARY_DEPTH); + } + let tithe = scenario + .add_spell_to_hand_from_oracle(P0, "Blood Tithe", false, BLOOD_TITHE) + .with_mana_cost(ManaCost::zero()) + .id(); + let mut runner = scenario.build(); + + let outcome = runner.cast(tithe).resolve(); + + let life: Vec = SEATS + .iter() + .map(|p| { + outcome + .state() + .players + .iter() + .find(|pl| pl.id == *p) + .expect("player exists") + .life + }) + .collect(); + eprintln!("PROBE blood-tithe/cast: life={life:?}"); + + // Reach guard: the loss step actually ran for all three opponents, so the + // per-player table really does hold three entries. Without this, a gain of 9 + // could be read off a table that never fanned out. + assert_eq!( + &life[1..], + &[17, 17, 17], + "reach guard: each of the three opponents loses exactly 3 (CR 119.3)" + ); + assert_eq!( + life[0], 29, + "controller gains the cross-player TOTAL life lost (9) via \ + PreviousEffectAmount — a reach guard for the 40-card drain class, not an \ + aggregate discriminator (see the degeneracy note above)" + ); +} + /// PROBE for the second, independent defect the code map surfaced: the draw /// tail keeps `player_scope: All` and re-fans-out, and each player's completed /// draw re-stamps the shared scalar with that player's DELIVERED count. So a From b5d2e79139ecf225f8c3cee00f600dd5c647774d Mon Sep 17 00:00:00 2001 From: lgray Date: Sun, 16 Aug 2026 10:59:45 -0500 Subject: [PATCH 05/26] test(engine): cover the zero-fill's production wire, and correct its census Delta re-review of the zero-contributor fix returned nine findings. The core fix was confirmed correct and at the right seam; this commit applies all nine. The load-bearing one is a test-coverage defect in my own work. All three `fill_zero_contributors_*` tests call the helper directly, so deleting the driver's call to it left the entire integration binary green -- including the zero-contributor integration test, which does reach the fill. The helper was tested; the wire was not. `player_scope_fan_out_publishes_a_zero_for_the_empty_handed_seat` now drives a real `player_scope: All` discard fan-out over four seats, hands 1/1/1/0, through `resolve_ability_chain` and asserts on the table the driver actually published. Removing the wire fails it on exactly the omission. The admission arm's corpus classification was wrong in a way that mattered. It claimed 4 cards hold the ref "in the scoped node itself" and the other 40 take the drain shape -- but those sets are not a partition. Measured: 44 cards carry both a `player_scope` and a `PreviousEffectAmount`; 3 hold it only in a condition; of the 41 quantity-position carriers, ALL hold it inside the scoped subtree, so the axis that discriminates is which effect carries it -- 38 `GainLife`, 3 `Draw`, 1 `LoseLife`, with Thorna and Twigtooth holding two and belonging to both of the old buckets at once. That correction also supplied the precondition audit the comment had asserted without performing. Thorna is the only retained-side carrier, hence the only card that could falsify "no card wants a per-iteration reading here." It does not: "each opponent loses X life ... where X is the number of counters removed this way" fixes X once for the whole clause, which is exactly the pre-clause value the freeze supplies. One behavioural fix rides along. On the interactive-pause path the fill's reduction domain was the full `matching_players`, so a pause after the first seat published a zero for three seats that had not yet had the chance to contribute. The domain is now narrowed to the seats that completed. Also: the Sum-vs-Max claim corrected (no card in the Sum class yields an integration-level discriminator -- the Max class does, and it is the first test in the file); a test comment that contradicted its own doc about whether the per-player table is populated; the clearing mechanism restated (the card's own draw tail takes the non-producer arm, not the player-action boundary); `install_previous_effect_counts_by_player`'s doc comment restored after the new helper captured it; CR 120.6 struck at its two remaining sibling sites, since marked-damage-until-cleanup does not govern a resolution-local carry-forward; the non-scoped install site's absent zero-fill documented; and five leftover debug `eprintln!`s removed. Census pins re-derived by content and confirmed by window hash against an off-by-one control: `:6816/:6893/:10148` to `:6828/:6905/:10171`. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/effects/mod.rs | 131 +++++++++++++++--- crates/engine/src/game/engine.rs | 28 +++- .../windfall_greatest_discard_aggregate.rs | 67 +++++---- 3 files changed, 173 insertions(+), 53 deletions(-) diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index f6c1c2a6ef..c0c25bddcd 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -4477,16 +4477,32 @@ fn collect_clause_minimum_refs<'a>(expr: &'a QuantityExpr, out: &mut Vec<&'a Qua // `EventContextAmount` is per-iteration. A sub-link RETAINED // inside the scoped template whose `PreviousEffectAmount` is // meant to read *that iteration's* preceding effect would be - // frozen at the pre-clause value instead. No card does this - // today, measured: of the 44 corpus cards carrying both a - // `player_scope` and a `PreviousEffectAmount`, 4 hold it in - // the scoped node itself (Windfall, Jace's Archivist, - // Whispering Madness, Thorna and Twigtooth) and the other 40 - // are the drain shape `LoseLife` → `GainLife { PEA }`, which - // DETACHES because `effect_has_iteration_bound_recipient` - // returns false for `GainLife`. If a future card needs a - // per-iteration reading here, it wants `EventContextAmount`, - // not a guard on this arm. + // frozen at the pre-clause value instead. + // + // No card does this today, measured over the corpus: 44 CARDS + // carry both a `player_scope` and a `PreviousEffectAmount` + // somewhere. 3 of them (Parallax Nexus/Tide/Wave) hold it + // only OUTSIDE the scoped subtree, in a condition, so they + // never reach this arm. The other 41 hold it inside the + // scoped subtree in a quantity position — 42 NODES, because + // Thorna and Twigtooth holds two. (Node and card counts + // differ here; an earlier revision of this comment conflated + // them and mis-partitioned the result.) + // + // The split that discriminates is which effect carries the + // ref: 38 `GainLife` — the drain tail, which DETACHES because + // `effect_has_iteration_bound_recipient` has no `GainLife` + // arm — 3 `Draw` (Windfall, Jace's Archivist, Whispering + // Madness), and 1 `LoseLife` (Thorna). + // + // Thorna is the only RETAINED-side carrier, so it is the one + // card that could falsify the precondition — it does not: + // "each opponent loses X life ... where X is the number of + // counters removed this way" fixes X once for the whole + // clause, which is exactly the pre-clause value the freeze + // supplies. If a future card needs a per-iteration reading + // here, it wants `EventContextAmount`, not a guard on this + // arm. | QuantityRef::PreviousEffectAmount { .. } ) { out.push(qty); @@ -8352,10 +8368,6 @@ fn previous_effect_counts_by_player_from_events( Some(counts) } -/// CR 608.2c: Install the terminal-window per-player counts for a completed -/// instruction. `Some(empty)` is a real zero-result producer and must replace -/// an older table; `None` means this effect has no such count channel, so clear -/// the old table before callers preserve their ordinary scalar/excess fallback. /// CR 608.2c: give every player the clause applied to an entry in the /// completed-instruction table, defaulting a non-contributor to zero. /// @@ -8382,6 +8394,10 @@ fn fill_zero_contributors( counts_by_player } +/// CR 608.2c: Install the terminal-window per-player counts for a completed +/// instruction. `Some(empty)` is a real zero-result producer and must replace +/// an older table; `None` means this effect has no such count channel, so clear +/// the old table before callers preserve their ordinary scalar/excess fallback. fn install_previous_effect_counts_by_player( state: &mut GameState, counts_by_player: Option>, @@ -9843,6 +9859,12 @@ fn resolve_chain_body( let initial_waiting_for = state.waiting_for.clone(); let mut paused = false; + // CR 608.2c: the zero-fill's reduction domain is the set of players the + // clause has actually applied to. A mid-fan-out pause leaves the tail + // unresolved, so filling them as zero would publish a contribution they + // have not had the chance to make; narrow the domain to the players who + // completed before the pause and let the continuation extend it. + let mut applied_domain_end = matching_players.len(); // CR 608.2e: each clause's equalization minimum is fixed when that // clause begins; the snapshot is per `player_scope` link, captured // before fan-out (the board is now exactly the clause's pre-clause @@ -9922,6 +9944,7 @@ fn resolve_chain_body( if tail.is_some() { append_to_pending_continuation(state, tail); } + applied_domain_end = i + 1; paused = true; break; } @@ -9932,16 +9955,20 @@ fn resolve_chain_body( scoped_template.source_id, scoped_events, ); - let counts_by_player = - counts_by_player.map(|counts| fill_zero_contributors(counts, &matching_players)); + let counts_by_player = counts_by_player + .map(|counts| fill_zero_contributors(counts, &matching_players[..applied_domain_end])); if !install_previous_effect_counts_by_player(state, counts_by_player, false) { if let Some(amount) = previous_effect_amount_from_events(state, &scoped_template, scoped_events) { state.last_effect_amount = Some(amount); - // CR 120.10: stamp the resolution-local excess channel alongside the - // CR 120.6 total so a follow-up "if excess damage was dealt this way" - // condition reads overkill-beyond-lethal. + // CR 120.10: stamp the resolution-local excess channel alongside + // the running total so a follow-up "if excess damage was dealt + // this way" condition reads overkill-beyond-lethal. CR 120.6 was + // cited for that total and is struck: it governs damage MARKED on + // a creature until the cleanup step, not the amount one clause + // leaves for a later clause in the same resolution — that + // carry-forward is CR 608.2c. let excess = previous_effect_excess_amount_from_events( state, &scoped_template, @@ -11075,6 +11102,11 @@ fn resolve_chain_body( ability.source_id, parent_events, ); + // No `fill_zero_contributors` here, unlike the `player_scope` loop: the + // reduction domain of a fan-out is the set of players the clause applied to, + // and this path has no such set to fill from — a bare effect applies to whom + // its own target names, and a player who emitted no event was never in the + // domain rather than being a zero contributor within it. let preserve_counts_for_current_consumer = ability.player_scope.is_none() && effect_consumes_event_context_amount(&ability.effect); if !install_previous_effect_counts_by_player( @@ -11085,8 +11117,11 @@ fn resolve_chain_body( if let Some(amount) = previous_effect_amount_from_events(state, ability, parent_events) { state.last_effect_amount = Some(amount); // CR 120.10: stamp the resolution-local excess channel alongside the - // CR 120.6 total so a follow-up "if excess damage was dealt this way" - // condition reads overkill-beyond-lethal. + // running total so a follow-up "if excess damage was dealt this way" + // condition reads overkill-beyond-lethal. CR 120.6 was cited for that + // total and is struck: it governs damage MARKED on a creature until + // the cleanup step, not the amount one clause leaves for a later + // clause in the same resolution — that carry-forward is CR 608.2c. let excess = previous_effect_excess_amount_from_events(state, ability, parent_events); state.last_effect_excess_amount = excess; } @@ -18529,6 +18564,60 @@ mod tests { assert_eq!(rows, vec![(0, 0), (1, 0), (2, 0)]); } + /// CR 608.2c: the PRODUCTION wire, not the helper. The three tests above call + /// `fill_zero_contributors` directly, so they stay green even if the driver + /// stops calling it — this one drives a real `player_scope` fan-out through + /// `resolve_ability_chain` and reads the table the driver actually published. + /// + /// Four seats, hands 1/1/1/**0**: the empty-handed seat emits no discard event + /// and is therefore absent from the event-derived table. It is still a player + /// the clause applied to, so the published reduction domain must carry it as a + /// zero rather than omit it. + #[test] + fn player_scope_fan_out_publishes_a_zero_for_the_empty_handed_seat() { + let mut state = GameState::new(FormatConfig::standard(), 4, 42); + for seat in 0..3u8 { + create_object( + &mut state, + CardId(10 + u64::from(seat)), + PlayerId(seat), + format!("P{seat} Card"), + Zone::Hand, + ); + } + // PlayerId(3) is dealt no card: the zero contributor under test. + + let mut ability = ResolvedAbility::new( + Effect::Discard { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::ScopedPlayer, + selection: crate::types::ability::CardSelectionMode::Chosen, + unless_filter: None, + filter: None, + }, + vec![], + ObjectId(100), + PlayerId(0), + ); + ability.player_scope = Some(PlayerFilter::All); + + let mut events = Vec::new(); + resolve_ability_chain(&mut state, &ability, &mut events, 0).unwrap(); + + let mut rows: Vec<(u8, i32)> = state + .last_effect_counts_by_player + .iter() + .map(|(p, n)| (p.0, *n)) + .collect(); + rows.sort(); + assert_eq!( + rows, + vec![(0, 1), (1, 1), (2, 1), (3, 0)], + "the driver must publish the empty-handed seat as a zero contributor, \ + not omit it from the reduction domain" + ); + } + #[test] fn previous_effect_amount_for_damage_ignores_counter_side_effects() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 1360ad8dce..a3f6ff4ef4 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -19051,9 +19051,31 @@ mod stage2_injector_tests { // producer set. Window hashes `ad615ce4…`, `a958f070…`, `d7fd67fd…` // re-measured at these coordinates, with the off-by-one neighbour // (`8251728c…`, `e3830eb5…`, `85037f22…`) differing as a control. - "game/effects/mod.rs:7045".to_string(), - "game/effects/mod.rs:7122".to_string(), - "game/effects/mod.rs:10377".to_string(), + // + // Delta-re-review round: a further non-uniform `+16/+16/+27` + // (`:7045/:7122/:10377 ⇒ :7061/:7138/:10404`) for the same structural + // reason — the corrected corpus comment sits above all three, while the + // paused-path domain narrowing, the CR 120.6 strike and the + // non-scoped-install note land above only the third. The production-wire + // test sits above none of them: it is in `mod tests`, below every producer. + // + // RE-PINNED TWICE IN THAT ROUND. The first measurement was correct and was + // then invalidated by a LATER edit of mine above all three producers; the + // suite had already gone green before that edit, so nothing in the run + // would have caught it. A pin measurement is only valid against the tree + // actually committed — re-measure after the FINAL edit, not the first. + // + // This census has been re-pinned on this branch at every review round that + // inserted a line above a producer AND at every rebase, several of them + // forced by unrelated upstream commits. That is the standing argument for + // anchoring it on a symbol or a stable marker rather than a line number; + // see the PR's scope-expansion disclosure. Every coordinate here was + // located by CONTENT in this worktree — never by indexing a line of + // `upstream/main`, which moves and once produced a phantom 79-line + // discrepancy in this very lane. + "game/effects/mod.rs:7061".to_string(), + "game/effects/mod.rs:7138".to_string(), + "game/effects/mod.rs:10404".to_string(), // UNMOVED across the rebase, and that is itself evidence the SET did not // move: a census that had gained or lost a producer would not leave this // entry both byte-identical AND at the same coordinate. diff --git a/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs b/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs index 658e37877a..4759520505 100644 --- a/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs +++ b/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs @@ -42,15 +42,17 @@ const WINDFALL: &str = "Each player discards their hand, then draws cards equal /// /// The aggregate axis is guarded at unit level instead — see /// `game/quantity.rs`'s `previous_effect_amount_live_when_no_snapshot` and -/// `previous_effect_amount_aggregates_are_mutually_distinct`. Measured: no -/// printed card yields a clean integration-level Sum-vs-Max discriminator. +/// `previous_effect_amount_aggregates_are_mutually_distinct`. Measured: no card +/// in the **Sum class** yields a clean integration-level Sum-vs-Max +/// discriminator — the Max class does, and it is the first test in this file. const SYPHON_MIND: &str = "Each other player discards a card. You draw a card for each card discarded this way."; -/// Blood Tithe — the drain shape, and the class the corpus actually populates: -/// 40 of the 44 cards carrying both a `player_scope` and a -/// `PreviousEffectAmount` are this `LoseLife` → `GainLife { PreviousEffectAmount }` -/// form. +/// Blood Tithe — the drain shape, and the class the corpus actually populates. +/// Measured: 44 cards carry both a `player_scope` and a `PreviousEffectAmount` +/// somewhere; 3 hold it only outside the scoped subtree, in a condition. Of the +/// 41 that hold it inside, in a quantity position, 38 carry it on a `GainLife` — +/// this `LoseLife` → `GainLife { PreviousEffectAmount }` form. /// /// Unlike Syphon Mind this DOES build `PreviousEffectAmount`, with `aggregate` /// absent and therefore `Sum`. CR 119.3: an effect causing a player to gain or @@ -140,7 +142,6 @@ fn windfall_draws_the_greatest_single_players_discard_not_the_cross_player_sum() .iter() .map(|p| zone_len(&outcome, *p, Zone::Graveyard)) .collect(); - eprintln!("PROBE windfall/cast: drawn={drawn:?} hands={hands:?} graveyards={graveyards:?}"); // CR 701.9a reach guard: every player really did discard their whole hand. assert!( @@ -169,8 +170,11 @@ fn windfall_draws_the_greatest_single_players_discard_not_the_cross_player_sum() /// cannot see a change to that ref's `aggregate` and stays green under both /// revert arms. The cross-aggregate guard lives at unit level, in /// `game/quantity.rs`'s `previous_effect_amount_aggregates_are_mutually_distinct` -/// and `previous_effect_amount_live_when_no_snapshot` — no printed card gives a -/// clean integration-level Sum-vs-Max discriminator. +/// and `previous_effect_amount_live_when_no_snapshot` — no card in the **Sum +/// class** gives a clean integration-level Sum-vs-Max discriminator. (The Max +/// class does: `windfall_draws_the_greatest_single_players_discard_not_the_cross_player_sum` +/// above separates MAX 8 / SUM 21 / MIN 3. The gap is specific to the Sum class, +/// whose producers publish no per-player table for an aggregate to reduce over.) #[test] fn syphon_mind_shape_still_draws_the_cross_player_total() { let mut scenario = GameScenario::new_n_player(4, 42); @@ -192,7 +196,6 @@ fn syphon_mind_shape_still_draws_the_cross_player_total() { .iter() .map(|p| zone_len(&outcome, *p, Zone::Graveyard)) .sum(); - eprintln!("PROBE syphon/cast: drawn={drawn} opponents_discarded={opponents_discarded}"); // Reach guard: the discard step ran for all three opponents (CR 701.9a). assert_eq!( @@ -214,13 +217,24 @@ fn syphon_mind_shape_still_draws_the_cross_player_total() { /// /// SCOPE — this asserts the NON-REGRESSION half only: the greatest discard is /// still 8, so every player including the empty-handed one still draws 8. It -/// does NOT assert the table's contents, and deliberately so: -/// `last_effect_counts_by_player` is cleared at the player-action boundary, so -/// it reads `[]` from `outcome.state()` regardless of the fix. An earlier -/// revision asserted on it and failed with `left: []` — an INSTRUMENT failure, -/// not a fix failure. The table's contents are asserted where they survive, at -/// unit level: `game/effects/mod.rs`'s `fill_zero_contributors_*` tests, which -/// pin `Min` at 0 filled versus 3 unfilled. +/// does NOT assert the table's contents, and deliberately so: by the time +/// `outcome.state()` is readable the table is already `[]` regardless of the +/// fix. An earlier revision asserted on it and failed with `left: []` — an +/// INSTRUMENT failure, not a fix failure. +/// +/// The clearer is this card's OWN draw tail, not the player-action boundary: +/// `Effect::Draw` is not a count producer, so its postlude calls +/// `install_previous_effect_counts_by_player(.., None, ..)` and takes the arm +/// that clears — inside the same resolution, long before `apply()`'s +/// start-of-action clear could matter. Two tests bracket this: a bare `Discard` +/// fan-out with no tail leaves the table populated +/// (`game/effects/mod.rs`'s `player_scope_fan_out_publishes_a_zero_for_the_empty_handed_seat`), +/// and adding the draw tail — this test — empties it. +/// +/// The table's contents are therefore asserted where they survive, at unit +/// level: that same production-wire test pins the zero entry, and +/// `game/quantity.rs`'s `previous_effect_amount_min_counts_the_zero_contributor` +/// pins `Min` at 0 filled versus 3 unfilled. #[test] fn windfall_zero_contributor_board_still_draws_the_greatest() { let mut scenario = GameScenario::new_n_player(4, 42); @@ -248,7 +262,6 @@ fn windfall_zero_contributor_board_still_draws_the_greatest() { .iter() .map(|p| zone_len(&outcome, *p, Zone::Graveyard)) .collect(); - eprintln!("PROBE windfall/zero-contributor: drawn={drawn:?} graveyards={graveyards:?}"); // CR 701.9a reach guard: the discard step ran, and P3 really contributed // nothing — without this, an all-8 draw could pass on a board that never @@ -273,8 +286,8 @@ fn windfall_zero_contributor_board_still_draws_the_greatest() { /// /// Blood Tithe in a four-player game: each of the three opponents loses 3 life, /// so "the life lost this way" is 3 + 3 + 3 = 9 (CR 119.3) and the controller -/// gains 9. This is the shape 40 of the 44 corpus cards carrying both a -/// `player_scope` and a `PreviousEffectAmount` take, so it is the widest +/// gains 9. This is the shape 38 of the 41 quantity-position corpus carriers +/// take (see `BLOOD_TITHE`'s note for the full split), so it is the widest /// non-regression this file has. /// /// WHAT IT DISCRIMINATES, measured by sentinel probe: the ref is genuinely @@ -327,11 +340,11 @@ fn blood_tithe_drain_still_gains_the_cross_player_total() { .life }) .collect(); - eprintln!("PROBE blood-tithe/cast: life={life:?}"); - // Reach guard: the loss step actually ran for all three opponents, so the - // per-player table really does hold three entries. Without this, a gain of 9 - // could be read off a table that never fanned out. + // Reach guard: the loss step actually ran for all three opponents, so the 9 + // really is a three-way total and not a single 3 read off a fan-out that + // never happened. (It is NOT evidence about the per-player table, which is + // empty here — see the degeneracy note above.) assert_eq!( &life[1..], &[17, 17, 17], @@ -340,7 +353,7 @@ fn blood_tithe_drain_still_gains_the_cross_player_total() { assert_eq!( life[0], 29, "controller gains the cross-player TOTAL life lost (9) via \ - PreviousEffectAmount — a reach guard for the 40-card drain class, not an \ + PreviousEffectAmount — a reach guard for the 38-card drain class, not an \ aggregate discriminator (see the degeneracy note above)" ); } @@ -386,10 +399,6 @@ fn windfall_short_library_does_not_shrink_later_players_draws() { depth - zone_len(&outcome, *p, Zone::Library) }) .collect(); - eprintln!( - "PROBE windfall/short-library: drawn={drawn:?} waiting={:?}", - outcome.final_waiting_for() - ); assert_eq!( drawn, vec![5, 8, 8, 8], From 7e9f950edf49f8632e39e0ff3d168f76b4b3119c Mon Sep 17 00:00:00 2001 From: lgray Date: Sun, 16 Aug 2026 12:53:55 -0500 Subject: [PATCH 06/26] fix(engine): exclude the paused seat from the zero-fill's domain Final review at the rebased tip returned one MED and three LOW. This commit applies everything that belongs to this change; the MED is a pre-existing defect in the continuation machinery and is disclosed rather than repaired here (see below). The off-by-one was mine. `applied_domain_end = i + 1` included the seat that had just paused on a choice it has not answered, publishing it as a zero contributor when it has not yet had the chance to contribute at all. A `Min` read taken mid-pause answered 0 off that entry. The bound is `i`: only seats that COMPLETED before the pause belong to the reduction domain. Where a seat paused after its own producing clause it already holds a real entry, so the fill is a no-op for it either way. The comment above that line was worse than the code. It claimed the continuation would "extend" the domain. It does not: each resumed leg REPLACES the table -- `install_previous_effect_counts_by_player`'s `Some` arm assigns `last_effect_counts_by_player` outright, and `split_player_scope_chain` clears `player_scope` on the resumed legs, so every leg publishes only its own entry. Measured on four seats: `{P0:1, P1:0}` at the pause, `{P2:1}` after the next leg. That claim was the thing hiding the defect, so it is replaced with the measured behaviour and the shape of the repair. That defect is NOT introduced or widened here, measured rather than asserted: `last_effect_amount` is derived from the same table (`.values().sum()`), so the Sum class loses exactly the same counts and did so before this branch existed. It is reachable for this PR's three cards -- the forced whole-hand discard branch can still pause on a replacement choice, at a site that already documents its own related `EffectResolved` gap. Repairing it means making the per-clause table accumulate across continuation legs, which is resume-machinery work affecting every count-producing fan-out including the 38-card drain shape, and does not belong in a draw-count change. Also: `fmt_quantity_ref`'s `(_, Sum)` arm carries a "Must stay FIRST" comment that nothing enforced -- reordering it would silently move the coverage signature of every Excess-channel corpus card, reddening CI's coverage check with no indication of the cause. Five assertions now pin all four channel/aggregate renderings, including the order-dependent Excess+Sum pair. Both new tests were probed red before being kept: restoring `i + 1` gives `left: [(0,1),(1,0)] / right: [(0,1)]`, and moving the Excess arm above `(_, Sum)` gives `left: "excess amount from preceding effect" / right: "amount from preceding effect"`. Census pins re-measured after the last edit of the round, by content and confirmed by window hash against an off-by-one control: `:10324` to `:10347`. Only the third producer moved this time -- the first two are unchanged, because these edits land between them. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/coverage.rs | 38 ++++++++++ crates/engine/src/game/effects/mod.rs | 99 ++++++++++++++++++++++++++- crates/engine/src/game/engine.rs | 9 ++- 3 files changed, 142 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index b27c2aec76..3baba214b1 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -15987,4 +15987,42 @@ mod tests { "CantHaveKeyword(Flying) should be covered by is_data_carrying_static()" ); } + /// The `fmt_quantity_ref` `PreviousEffectAmount` arms are ORDER-DEPENDENT: + /// the `(_, Sum)` arm must stay first so every Excess-channel corpus card + /// (all of which are `Sum`) keeps rendering the pre-change string. Nothing + /// enforced that ordering — reordering the arms would silently move the + /// coverage signature of every Excess card, reddening CI's coverage check + /// with no indication of the cause. These four assertions are that guard. + #[test] + fn previous_effect_amount_renders_all_four_channel_aggregate_pairs() { + use crate::types::ability::{AggregateFunction, DamageChannel}; + let render = |channel, aggregate| { + fmt_quantity_ref(&QuantityRef::PreviousEffectAmount { channel, aggregate }) + }; + + // Order-dependent: `(_, Sum)` is matched before the Excess catch-all, so + // the Excess+Sum pair renders the SUM string, not the excess one. + assert_eq!( + render(DamageChannel::Total, AggregateFunction::Sum), + "amount from preceding effect" + ); + assert_eq!( + render(DamageChannel::Excess, AggregateFunction::Sum), + "amount from preceding effect", + "the (_, Sum) arm must stay FIRST: Excess+Sum is the shape the corpus \ + actually holds, and it must keep the pre-change signature" + ); + assert_eq!( + render(DamageChannel::Total, AggregateFunction::Max), + "greatest single player's amount from preceding effect" + ); + assert_eq!( + render(DamageChannel::Total, AggregateFunction::Min), + "least single player's amount from preceding effect" + ); + assert_eq!( + render(DamageChannel::Excess, AggregateFunction::Max), + "excess amount from preceding effect" + ); + } } diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index c0c25bddcd..2ea9b38e8c 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -9862,8 +9862,28 @@ fn resolve_chain_body( // CR 608.2c: the zero-fill's reduction domain is the set of players the // clause has actually applied to. A mid-fan-out pause leaves the tail // unresolved, so filling them as zero would publish a contribution they - // have not had the chance to make; narrow the domain to the players who - // completed before the pause and let the continuation extend it. + // have not had the chance to make. Narrow the domain to the players who + // COMPLETED before the pause — the pausing player is excluded too: they + // are sitting on a choice they have not answered, so a `Min` read taken + // mid-pause must not see them as a zero contributor. Where the pause + // came after their own producing clause they already hold a real entry, + // and the fill is a no-op for them. + // + // WHAT THIS DOES NOT FIX, measured: each resumed continuation leg + // REPLACES the table rather than extending it — + // `install_previous_effect_counts_by_player`'s `Some` arm assigns + // `last_effect_counts_by_player` outright, and `split_player_scope_chain` + // clears `player_scope` on the resumed legs, so each leg publishes only + // its own entry. A paused four-seat fan-out measured `{P0:1, P1:0}` at + // the pause and `{P2:1}` after the next leg. That is PRE-EXISTING and + // not specific to an aggregate: `last_effect_amount` is derived from the + // same table (`.values().sum()`), so the `Sum` class loses the same + // counts. It is reachable here because the forced whole-hand discard + // branch can still pause on a replacement choice + // (`effects/discard.rs`, which documents its own related + // `EffectResolved` gap at that site). Repairing it means making the + // per-clause table accumulate across continuation legs, which is + // resume-machinery work well outside a draw-count change. let mut applied_domain_end = matching_players.len(); // CR 608.2e: each clause's equalization minimum is fixed when that // clause begins; the snapshot is per `player_scope` link, captured @@ -9944,7 +9964,10 @@ fn resolve_chain_body( if tail.is_some() { append_to_pending_continuation(state, tail); } - applied_domain_end = i + 1; + // `i`, not `i + 1`: player `i` is the one who just paused, so + // they have NOT completed the clause and must not be filled as + // a zero contributor. + applied_domain_end = i; paused = true; break; } @@ -18618,6 +18641,76 @@ mod tests { ); } + /// CR 608.2c: the zero-fill's domain on a PAUSED fan-out. Seat 1 holds two + /// cards facing a "discard a card" fan-out, so its iteration stops on a + /// `DiscardChoice` it has not answered. Seat 0 completed; seats 1..3 did not. + /// + /// The bound is `i`, not `i + 1`: publishing the pausing seat as a zero says + /// it contributed nothing, when in fact it has not yet been given the chance + /// to contribute — a `Min` read taken mid-pause would answer 0 off that. + /// + /// This pins the domain only. It deliberately does NOT assert that the table + /// survives the continuation: each resumed leg replaces it rather than + /// extending it, which is pre-existing and documented at the fill site. + #[test] + fn paused_fan_out_excludes_the_seat_that_has_not_answered_its_choice() { + let mut state = GameState::new(FormatConfig::standard(), 4, 42); + // Seat 0: exactly one card — a forced discard, no choice, completes. + // Seat 1: two cards — must choose, so the fan-out pauses here. + for (seat, cards) in [(0u8, 1u32), (1, 2), (2, 1), (3, 1)] { + for n in 0..cards { + create_object( + &mut state, + CardId(100 + u64::from(seat) * 10 + u64::from(n)), + PlayerId(seat), + format!("P{seat} Card {n}"), + Zone::Hand, + ); + } + } + + let mut ability = ResolvedAbility::new( + Effect::Discard { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::ScopedPlayer, + selection: crate::types::ability::CardSelectionMode::Chosen, + unless_filter: None, + filter: None, + }, + vec![], + ObjectId(100), + PlayerId(0), + ); + ability.player_scope = Some(PlayerFilter::All); + + let mut events = Vec::new(); + resolve_ability_chain(&mut state, &ability, &mut events, 0).unwrap(); + + assert!( + matches!( + state.waiting_for, + crate::types::game_state::WaitingFor::DiscardChoice { .. } + ), + "reach guard: the fan-out must actually be paused on seat 1's choice, \ + got {:?}", + state.waiting_for + ); + + let mut rows: Vec<(u8, i32)> = state + .last_effect_counts_by_player + .iter() + .map(|(p, n)| (p.0, *n)) + .collect(); + rows.sort(); + assert_eq!( + rows, + vec![(0, 1)], + "only the seat that COMPLETED before the pause belongs to the domain; \ + the paused seat has not had the chance to contribute and must not be \ + published as a zero" + ); + } + #[test] fn previous_effect_amount_for_damage_ignores_counter_side_effects() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index a3f6ff4ef4..e230ea6253 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -19073,9 +19073,16 @@ mod stage2_injector_tests { // located by CONTENT in this worktree — never by indexing a line of // `upstream/main`, which moves and once produced a phantom 79-line // discrepancy in this very lane. + // + // Final-review round: ONE producer moved and two did not — + // `:10404 ⇒ :10427` (+23) with `:7061`/`:7138` unchanged — because that + // round's edits (the paused-domain comment and its `i`-not-`i+1` note) + // land BETWEEN the second and third producers. This is the sharpest case + // in this log against a "+N to all three" assumption. Window `d7fd67fd…` + // unchanged, both neighbours differing as controls. "game/effects/mod.rs:7061".to_string(), "game/effects/mod.rs:7138".to_string(), - "game/effects/mod.rs:10404".to_string(), + "game/effects/mod.rs:10427".to_string(), // UNMOVED across the rebase, and that is itself evidence the SET did not // move: a census that had gained or lost a producer would not leave this // entry both byte-identical AND at the same coordinate. From 3b89667ba7d02c9eefe3c30750b94c2f1006933e Mon Sep 17 00:00:00 2001 From: lgray Date: Sun, 16 Aug 2026 13:44:21 -0500 Subject: [PATCH 07/26] docs(engine): correct a comment that cited the pre-fix numbers as measured Delta re-review of the previous commit found that its new comment quotes the per-player table as `{P0:1, P1:0}` at the pause -- which is the PRE-FIX value, byte-identical to the `left:` side of the probe that proves the `i`-not-`i+1` fix works, 85 lines below in that same commit. The comment therefore asserted, as measured fact about the tree it ships in, the exact behaviour that commit removes. A maintainer diagnosing the fill site would have read it, concluded the domain narrowing never took effect, and reverted or re-patched it. Re-measured on the tree the comment actually ships in: `[(0, 1)]` at the pause and `[(3, 1)]` once the continuation runs, with `last_effect_amount` reading `Some(1)` where an accumulating table would give 4. The corrected figures make the point stronger rather than weaker -- the remaining seats chain into ONE continuation leg, so seat 2's publication is replaced as well and the table is overwritten more than once, not merely truncated. The same two wrong figures were corrected in the PR body and the posterity issue. Two smaller corrections from the same review: The coverage guard was named for the four match arms while the pair space is two channels x three aggregates = six, and it asserted five of them, leaving `(Excess, Min)` unpinned. Renamed and completed to one assertion per pair, so the name's claim of completeness is literally true rather than true-of-the-arms. Recorded alongside it: rustc emits no `unreachable pattern` warning for the reorder this guard defends against, which is why the guard is needed at all. The `i`-not-`i+1` rationale said a seat that completed its producing clause already holds a real entry. `fill_zero_contributors` is `or_insert(0)`, so it is PRESENCE in the table, not completion, that makes the fill a no-op; a clause completing with a genuine zero emits no event and so holds no entry. Reworded to the property the code actually has. Census pins re-measured after the last edit: `:10347` to `:10352`, third producer only, window hash unchanged with both neighbours differing as controls. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/coverage.rs | 14 ++++++++++++-- crates/engine/src/game/effects/mod.rs | 21 +++++++++++++-------- crates/engine/src/game/engine.rs | 7 ++++++- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 3baba214b1..b02842d664 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -15992,9 +15992,11 @@ mod tests { /// (all of which are `Sum`) keeps rendering the pre-change string. Nothing /// enforced that ordering — reordering the arms would silently move the /// coverage signature of every Excess card, reddening CI's coverage check - /// with no indication of the cause. These four assertions are that guard. + /// with no indication of the cause. rustc emits NO `unreachable pattern` + /// warning for the reorder, so the compiler will not catch it either. These + /// six assertions -- one per channel/aggregate pair -- are that guard. #[test] - fn previous_effect_amount_renders_all_four_channel_aggregate_pairs() { + fn previous_effect_amount_renders_every_channel_aggregate_pair() { use crate::types::ability::{AggregateFunction, DamageChannel}; let render = |channel, aggregate| { fmt_quantity_ref(&QuantityRef::PreviousEffectAmount { channel, aggregate }) @@ -16024,5 +16026,13 @@ mod tests { render(DamageChannel::Excess, AggregateFunction::Max), "excess amount from preceding effect" ); + // The pair space is 2 channels x 3 aggregates = 6, which is more than the + // four match arms; `(Excess, Min)` routes through the same catch-all as + // `(Excess, Max)` and is asserted so the name's claim of completeness is + // literally true rather than true-of-the-arms. + assert_eq!( + render(DamageChannel::Excess, AggregateFunction::Min), + "excess amount from preceding effect" + ); } } diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 2ea9b38e8c..c097a31c5e 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -9865,17 +9865,22 @@ fn resolve_chain_body( // have not had the chance to make. Narrow the domain to the players who // COMPLETED before the pause — the pausing player is excluded too: they // are sitting on a choice they have not answered, so a `Min` read taken - // mid-pause must not see them as a zero contributor. Where the pause - // came after their own producing clause they already hold a real entry, - // and the fill is a no-op for them. + // mid-pause must not see them as a zero contributor. A seat that already + // holds an entry is unaffected either way — `fill_zero_contributors` is + // `or_insert(0)`, so it is PRESENCE in the table, not completion, that + // makes the fill a no-op for them. // - // WHAT THIS DOES NOT FIX, measured: each resumed continuation leg - // REPLACES the table rather than extending it — - // `install_previous_effect_counts_by_player`'s `Some` arm assigns + // WHAT THIS DOES NOT FIX, measured on the tree this comment ships in: + // each resumed continuation leg REPLACES the table rather than extending + // it — `install_previous_effect_counts_by_player`'s `Some` arm assigns // `last_effect_counts_by_player` outright, and `split_player_scope_chain` // clears `player_scope` on the resumed legs, so each leg publishes only - // its own entry. A paused four-seat fan-out measured `{P0:1, P1:0}` at - // the pause and `{P2:1}` after the next leg. That is PRE-EXISTING and + // its own entry. A four-seat fan-out pausing on seat 1 measures + // `[(0, 1)]` at the pause and `[(3, 1)]` once the continuation runs — the + // remaining seats chain into ONE leg, so even seat 2's publication is + // replaced before the fan-out ends, and `last_effect_amount` reads + // `Some(1)` where an accumulating table would give 4. That is + // PRE-EXISTING and // not specific to an aggregate: `last_effect_amount` is derived from the // same table (`.values().sum()`), so the `Sum` class loses the same // counts. It is reachable here because the forced whole-hand discard diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index e230ea6253..d63602394c 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -19080,9 +19080,14 @@ mod stage2_injector_tests { // land BETWEEN the second and third producers. This is the sharpest case // in this log against a "+N to all three" assumption. Window `d7fd67fd…` // unchanged, both neighbours differing as controls. + // + // Delta-re-review correction: `:10427 ⇒ :10432` (+5), third producer only + // again, because the corrected measurement in that comment is five lines + // longer than the wrong one it replaced. Window `d7fd67fd…` unchanged, + // both neighbours differing as controls. "game/effects/mod.rs:7061".to_string(), "game/effects/mod.rs:7138".to_string(), - "game/effects/mod.rs:10427".to_string(), + "game/effects/mod.rs:10432".to_string(), // UNMOVED across the rebase, and that is itself evidence the SET did not // move: a census that had gained or lost a producer would not leave this // entry both byte-identical AND at the same coordinate. From c77dbf6352db997ddb29f956f3fc9b812988ef50 Mon Sep 17 00:00:00 2001 From: lgray Date: Sun, 16 Aug 2026 23:07:47 -0500 Subject: [PATCH 08/26] feat(engine): carry a paused discard batch across a replacement choice A forced whole-hand discard that pauses for a CR 616.1 replacement choice had nowhere to record what it still owed, so it abandoned the rest of the hand. Add the carrier that lets it resume, modelled on the sacrifice family that already solves this for the sibling producer: `PendingDiscardBatch` holds the owed cursor, `DiscardBatchCursor` types the selection mode (whole-hand vs random pool) instead of flagging it, and `PendingDiscardFanOut` carries the remaining-seat roster in APNAP order (CR 101.4) so the seat list survives the pause. The roster doubles as the clause identity and the final-leg signal, which is why no marker field is added to `ResolvedAbility` and no accumulator field is added beside `last_effect_counts_by_player`. All six registration surfaces move in lockstep: declaration, `Default`, the exhaustive partition destructure with a written classification note, the hand-written `PartialEq` conjunct (a parked batch is interaction state and must compare), `LIVE_EVENT_CARRIER_FIELDS`, and serde. The CR733 authority matrix gains the corresponding row, derived from a real census run rather than hand-written. Hidden information: `filter_state_for_viewer` is an allowlist-of-clears, so a new carrier defaults to leaked. The batch holds hand-zone object ids (CR 400.2), so it is cleared for every viewer. Verified before clearing that every projection caller is display-only and none resumes from a filtered state, since clearing a field the drain reads would be a silent breakage rather than a redaction. Assisted-by: ClaudeCode:claude-opus-5 --- .../engine/src/game/engine_payment_choices.rs | 7 + crates/engine/src/game/visibility.rs | 73 +++++ crates/engine/src/types/game_state.rs | 297 ++++++++++++++++++ .../fixtures/cr733/authority_matrix.json.gz | Bin 42027 -> 42075 bytes 4 files changed, 377 insertions(+) diff --git a/crates/engine/src/game/engine_payment_choices.rs b/crates/engine/src/game/engine_payment_choices.rs index d0ad8a2df8..de97fd3ff9 100644 --- a/crates/engine/src/game/engine_payment_choices.rs +++ b/crates/engine/src/game/engine_payment_choices.rs @@ -937,6 +937,11 @@ pub(super) fn handle_unless_payment( crate::game::effects::discard::RandomDiscardOutcome::NeedsReplacementChoice { remaining_eligible, remaining_count, + // Effect-layer field: the parked EFFECT batch stamps + // the paused card's terminal `Discarded`. A cost + // payment publishes no such ledger, so this caller + // has nothing to do with it. + paused_card: _, } => { state.pending_cost_move_resume = Some(PendingCostMoveResume::RandomDiscardUnlessPayment(Box::new( @@ -2115,6 +2120,8 @@ pub(super) fn resume_random_discard_unless_payment( crate::game::effects::discard::RandomDiscardOutcome::NeedsReplacementChoice { remaining_eligible, remaining_count, + // Effect-layer field — see the sibling site above. + paused_card: _, } => { state.pending_cost_move_resume = Some(PendingCostMoveResume::RandomDiscardUnlessPayment(Box::new( diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index 6aa57d3ec3..12f0df6442 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -251,6 +251,15 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState // The replacement-resume cursor is server authority and can retain private // object IDs and last-known snapshots from a cost payment. filtered.pending_cost_move_resume = None; + // The EFFECT layer's twin of the cursor above, redacted for the same + // reason: `PendingDiscardBatch` retains the object IDs of cards still in a + // hand (a hidden zone, CR 400.2), the instruction's pre-pause event span, + // and full `ResolvedAbility` clones of the paused clause. The projected + // `WaitingFor::ReplacementChoice` is the complete viewer-facing interaction + // surface, so no viewer — including the choosing player — needs the carrier + // itself. Viewer projections are display-only clones; the authoritative + // state the drain resumes from is never filtered. + filtered.pending_discard_batch = None; // Deferred life-cost owners can embed a complete PendingCast, including // hidden card and target context. The projected WaitingFor is the only // viewer-facing interaction surface. @@ -6174,6 +6183,70 @@ mod tests { ); } + /// CR 400.2 + CR 616.1: `pending_discard_batch` is the EFFECT layer's twin + /// of the cost cursor above. It retains the object IDs of cards still in a + /// HAND — a hidden zone — plus the instruction's pre-pause event span, so it + /// must be absent from every viewer projection, including the projection of + /// the player who owns the live replacement prompt. `hide_card` is an + /// allowlist, so a new state carrier defaults to LEAKED and this has to be + /// measured rather than assumed. + /// + /// REVERT PROBE (RUN, not reasoned): delete + /// `filtered.pending_discard_batch = None;` from `filter_state_for_viewer`. + /// Observed first failure — "viewer PlayerId(0) must not receive the parked + /// discard batch". The later per-viewer and wire-string assertions never run + /// (the first panic ends the test), so it is that one which discriminates. + #[test] + fn parked_discard_batch_is_absent_from_every_viewer_projection() { + let mut state = GameState::new_two_player(42); + let hidden = create_object( + &mut state, + CardId(70_007), + PlayerId(0), + "Hand Secret".to_string(), + Zone::Hand, + ); + state.pending_discard_batch = + Some(Box::new(crate::types::game_state::PendingDiscardBatch { + player: PlayerId(0), + cursor: crate::types::game_state::DiscardBatchCursor::All { + remaining: vec![hidden], + }, + source_id: ObjectId(9_300), + effect_kind: crate::types::ability::EffectKind::Discard, + paused_card: hidden, + discard_frame: None, + fan_out: None, + preceding_events: Vec::new(), + })); + + let authoritative = serde_json::to_string(&state.pending_discard_batch) + .expect("the authoritative batch serializes"); + assert!( + authoritative.contains(&hidden.0.to_string()), + "reach guard: the authoritative carrier really does hold the hand card's ID" + ); + + for viewer in [PlayerId(0), PlayerId(1)] { + let view = filter_state_for_viewer(&state, viewer); + assert!( + view.pending_discard_batch.is_none(), + "viewer {viewer:?} must not receive the parked discard batch" + ); + let wire = serde_json::to_string(&view).expect("the filtered snapshot serializes"); + assert!( + !wire.contains("\"pendingDiscardBatch\":{") + && !wire.contains("\"pending_discard_batch\":{"), + "viewer {viewer:?}'s snapshot must not serialize the carrier at all" + ); + } + + assert!( + state.pending_discard_batch.is_some(), + "filtering must not alter the authoritative server carrier" + ); + } + /// CR 605.4a + CR 117.3c (plan Step 6): the triggered-mana continuation and /// the trigger-construction priority recipient are trusted persistence /// authority. They must survive an authoritative round trip exactly, and diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index b5b68f453b..50f83785fe 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -4051,6 +4051,151 @@ pub enum PendingPlayerScopeSacrificeFollowUp { Exploit { exploiter: ObjectId }, } +/// One discard instruction, parked mid-batch because a replacement-application +/// choice interrupted it (CR 616.1: "the affected object's controller … or the +/// affected player chooses one to apply"). +/// +/// This is [`PendingPlayerScopeSacrificeChoice`]'s sibling one layer down, and +/// it carries the same two things across the pause: the **cursor** is what the +/// instruction still owes, `preceding_events` is what it has already done, and +/// the two are reunited into one terminal window when the batch settles. Read +/// that type first — every mechanism here is its, with the two deliberate +/// divergences noted on `preceding_events` and in `drain_pending_discard_batch`. +/// +/// PERSISTENCE ASYMMETRY, stated because it will otherwise mis-triage a bug +/// report: this field IS serialized, while `GameState::clause_minimum_snapshot` +/// — the CR 608.2h freeze the resumed draw clause reads — is `#[serde(skip)]`. +/// A save taken mid-pause therefore restores the parked batch but not the +/// frozen count. That is pre-existing and out of this type's scope. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct PendingDiscardBatch { + /// The discarding seat whose batch paused. + pub player: PlayerId, + /// What this seat still owes. + pub cursor: DiscardBatchCursor, + /// The object that caused the discard. Together with `effect_kind` and + /// `player` this is the batch's identity: the driver hand-off below refuses + /// any batch whose identity does not match the clause it is running. + pub source_id: ObjectId, + /// The terminal marker this batch must emit once it settles. Held as + /// `EffectKind` rather than `Effect` because both `Effect::Discard` and + /// `Effect::DiscardCard` route here and the count authority + /// (`previous_effect_counts_by_player_from_events`) selects on kind alone. + pub effect_kind: EffectKind, + /// The card whose replacement is being chosen right now. + /// + /// CR 614.6: "If an event is replaced, it never happens. A modified event + /// occurs instead." A hand → graveyard `Moved` redirect therefore still + /// discarded the card (CR 701.9a), but the resumed zone-change arm emits no + /// `GameEvent::Discarded` for an unframed discard, so the drain stamps one + /// from this id. Without it the paused card is the one card that silently + /// leaves the ledger even though the batch resumed correctly. + pub paused_card: ObjectId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discard_frame: Option, + /// CR 608.2f: the clause and the seats it has not reached, installed by the + /// `player_scope` driver when this pause interrupted its fan-out. `None` + /// for a single-subject discard, which owns no fan-out. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fan_out: Option>, + /// The events this instruction emitted BEFORE the pause. + /// + /// COPIED, not drained — the one place this type diverges from + /// `PendingPlayerScopeSacrificeCompletion::deferred_events`. That sibling + /// drains because its terminal co-departure stamping (CR 603.10a look-back + /// zone-change triggers) has to rewrite the live event buffer. Discard does + /// no co-departure stamping, so draining would reorder client-visible + /// events across two actions for no rules benefit; copying yields the + /// identical terminal count window with no observable change. + /// + /// CR 608.2i: at completion the window is `preceding_events ++ events`, read + /// by the same authority the un-paused path uses. That rule's precondition + /// holds here — "if such an effect requires information from the game about + /// an object or group of objects, **and that effect is not taking any + /// actions on those objects**" — because a count ledger takes no actions on + /// the cards it counts. A Madness redirect (CR 702.35a: "that player + /// discards it, but exiles it instead") therefore counts here exactly as it + /// counts on the un-paused path, by construction rather than by a second + /// hand-written rule. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub preceding_events: Vec, +} + +/// What a paused discard batch still owes, by selection mode. +/// +/// CR 701.9b: "By default, effects that cause a player to discard a card allow +/// the affected player to choose which card to discard. Some effects, however, +/// require a random discard …" — the two modes differ only in how the next card +/// is chosen, which is the axis `CardSelectionMode` already names on +/// `Effect::Discard`. One parameterized cursor rather than two pending states. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(tag = "type")] +pub enum DiscardBatchCursor { + /// CR 701.9a: every remaining card of a forced whole-hand discard, in hand + /// order. Excludes the card whose replacement paused — that one is settled + /// by the replacement itself. + All { remaining: Vec }, + /// CR 701.9b: `remaining` further picks drawn at random from `pool`. + /// Mirrors `RandomDiscardOutcome::NeedsReplacementChoice`'s payload exactly. + Random { + pool: Vec, + remaining: usize, + }, +} + +/// The remainder of a `player_scope` discard clause whose fan-out was +/// interrupted. +/// +/// CR 608.2f: "Some spells and abilities include actions taken on multiple +/// players … If the action can't be processed simultaneously, it's instead +/// processed considering each affected player or object individually. APNAP +/// order is used to make the primary determination of the order of those +/// actions." A replacement choice is exactly what makes the discard action +/// non-simultaneous — but it is still ONE action, so the roster is held by the +/// batch rather than pushed onto `pending_continuation`, which is what lets the +/// whole clause publish ONE per-player table. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct PendingDiscardFanOut { + /// The scoped template — `player_scope` already removed by + /// `split_player_scope_chain`. + pub scoped_template: Box, + /// The outer ability, which the shared clause postlude consults for + /// tracked-set and linked-exile decisions. + pub outer: Box, + pub original_controller: PlayerId, + /// CR 101.4: seats not yet iterated, in APNAP order. + pub remaining_players: Vec, + /// CR 608.2f: the clause's full reduction domain, for the terminal + /// zero-fill. Latched at the pause and never re-derived: a player joining + /// or leaving the matching set afterwards cannot alter an action that has + /// already begun being processed per subject. + pub matching_players: Vec, + /// CR 607.2a: carried across the pause so the terminal publication makes the + /// same linked-exile decision the un-paused postlude would. + /// + /// ORDERING NOTE for the driver's hand-off (`resolve_chain_body`), recorded + /// here rather than at the call site so the CR 603.5 prompt-census pin in + /// `engine.rs` — which pins a line coordinate in `effects/mod.rs` BELOW that + /// call site — does not have to be re-derived for a comment. The hand-off is + /// placed AFTER `mark_exile_choice_tracks_by_source` so the batch pause route + /// keeps the exact side-effect the ordinary per-seat leg route already had; + /// placing it before would make the two pause routes for one clause diverge. + /// Only that side-effect differs between the placements — this flag itself + /// survives either way, which is what this field is for. + /// + /// UNREACHABLE in the corpus, measured rather than assumed. A python walk of + /// `data/card-data.json` (35798 cards) finds 0 `player_scope` clause nodes + /// that carry BOTH a discard effect and a linked-exile consumer tag + /// (`LINKED_EXILE_CONSUMER_TAGS`, `game/exile_links.rs`) anywhere in the + /// clause subtree. The subtree strictly CONTAINS the tail + /// `split_player_scope_chain` detaches, so the filter over-approximates the + /// real condition and the zero is sound. Three positive controls on the same + /// walk, all non-zero: 1530 `player_scope` clause nodes of any effect, 202 of + /// those carrying a discard effect, 290 cards carrying a linked-exile tag. + /// That is why the flag-true x batch-pause combination has no fixture. + pub after_scope_needs_linked_exile: bool, +} + /// CR 101.4 + CR 701.23i: APNAP state for a self-library search instruction /// whose selected cards are delivered only after every searching player has /// made their private choice. The original spell's controller remains on @@ -8968,6 +9113,10 @@ fn visit_persisted_live_zone_changed_records( "consumed_before_priority_trigger_events", "pending_attack_trigger_events", "pending_player_scope_sacrifice_choice", + // `PendingDiscardBatch::preceding_events` holds this turn's `ZoneChanged` + // records, whose `turn_zone_change_index` must be rebound on load — + // exactly the reason its sacrifice sibling is listed above. + "pending_discard_batch", "stack", "waiting_for", "resolution_stack", @@ -16592,6 +16741,15 @@ declare_game_state! { /// `EffectZoneChoice`. #[serde(default, skip_serializing_if = "Option::is_none")] pub pending_player_scope_sacrifice_choice: Option, + /// CR 616.1 + CR 701.9a: a discard instruction parked by a + /// replacement-application choice. See [`PendingDiscardBatch`]. + /// + /// Boxed: `GameState` is moved by value through the phase-server action and + /// AI paths under a hard stack budget (`types/game_state_size.rs`), and this + /// payload is populated only during a pause — the shape that file says to + /// box. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pending_discard_batch: Option>, /// CR 401.4: Remaining per-owner library-order batches for a mass /// `ChangeZoneAll` instruction paused on `EffectZoneChoice`. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -16827,6 +16985,16 @@ declare_game_state! { /// preceding count-producing effect in the current ability chain. Used by /// carried-subject continuations like "Each player discards ..., then draws /// that many ..." after all players have completed the discard pass. + /// + /// CR 608.2i is what makes such a continuation legal at all, and its + /// precondition is worth quoting rather than paraphrasing: an effect may + /// look back at a previous game state "if such an effect requires + /// information from the game about an object or group of objects, **and + /// that effect is not taking any actions on those objects**". This table is + /// a pure count ledger — it takes no actions on the cards it counts — so the + /// exception to CR 608.2h holds. A consumer that instead wanted to ACT on + /// the counted objects would not be covered by 608.2i and must not be built + /// on this field. #[serde(default, skip_serializing_if = "HashMap::is_empty")] #[serde(serialize_with = "crate::types::deterministic_serde::hash_map")] pub last_effect_counts_by_player: HashMap, @@ -21284,6 +21452,7 @@ impl GameState { merged_card_component_route: None, resolution_coin_flip: None, pending_player_scope_sacrifice_choice: None, + pending_discard_batch: None, pending_mass_library_order_choice: None, pending_scoped_library_search: None, pending_library_search_delivery: None, @@ -23239,7 +23408,14 @@ fn _gamestate_partition_is_total(s: &GameState) { // explicitly. It is `None` at every sample beat (cleared whenever `waiting_for == // Priority`, effects/mod.rs:759) or a constant direct-assigned count across a real // copy-token loop, so COMPARING never suppresses a legitimate loop's detection. + // - `pending_discard_batch`: COMPARED (hand-written `impl PartialEq` conjunct) — a + // paused discard-batch interaction state, the direct sibling of + // `pending_player_scope_sacrifice_choice` above and classified identically. Its cursor + // only SHRINKS as the batch drains and it is `None` outside a pause, so a differing + // value is correctly not a fixed-point repeat and COMPARING it can never suppress a + // legitimate loop's detection. pending_player_scope_sacrifice_choice: _, + pending_discard_batch: _, pending_mass_library_order_choice: _, pending_scoped_library_search: _, pending_library_search_delivery: _, @@ -23452,6 +23628,7 @@ impl PartialEq for GameState { && self.resolution_coin_flip == other.resolution_coin_flip && self.pending_player_scope_sacrifice_choice == other.pending_player_scope_sacrifice_choice + && self.pending_discard_batch == other.pending_discard_batch && self.pending_mass_library_order_choice == other.pending_mass_library_order_choice && self.pending_scoped_library_search == other.pending_scoped_library_search @@ -25492,6 +25669,126 @@ mod tests { } } + /// A mid-pause discard batch carrying one current-turn `ZoneChanged`. + /// The event is the point: `preceding_events` is a LIVE event carrier, so + /// its records must be rebound on load like every other one. + fn parked_discard_batch(record: ZoneChangeRecord) -> Box { + Box::new(PendingDiscardBatch { + player: PlayerId(0), + cursor: DiscardBatchCursor::All { + remaining: vec![ObjectId(9_301), ObjectId(9_302)], + }, + source_id: ObjectId(9_300), + effect_kind: crate::types::ability::EffectKind::Discard, + paused_card: ObjectId(9_303), + discard_frame: None, + fan_out: None, + preceding_events: vec![persisted_zone_change_event(record)], + }) + } + + /// Registration surfaces 4 and 5 for `pending_discard_batch`: the + /// hand-written `PartialEq` conjunct, and membership in + /// `LIVE_EVENT_CARRIER_FIELDS`. + /// + /// REVERT PROBES: + /// * delete `"pending_discard_batch"` from `LIVE_EVENT_CARRIER_FIELDS` → + /// the visitor reaches no record and `erased > 0` fails inside + /// `erase_persisted_event_occurrence_fields`. + /// * delete the `self.pending_discard_batch == other.pending_discard_batch` + /// conjunct → the `assert_ne!` below sees two states as equal. + #[test] + fn parked_discard_batch_is_a_live_event_carrier_and_a_compared_field() { + let mut state = GameState::new_two_player(42); + state.turn_number = 19; + let record = persisted_zone_change_record(ObjectId(9_101), 19, 0); + state.zone_changes_this_turn.push_back(record.clone()); + state.pending_discard_batch = Some(parked_discard_batch(record)); + + let bare = { + let mut bare = state.clone(); + bare.pending_discard_batch = None; + bare + }; + assert_ne!( + state, bare, + "a paused discard batch is interaction state and must be COMPARED" + ); + + let mut persisted = serde_json::to_value(PersistedGameState::Raw(Box::new(state.clone()))) + .expect("fixture serializes"); + // Asserts internally that the traversal reached at least one record — + // which it can only do if the field is a declared live-event carrier. + erase_persisted_event_occurrence_fields(persisted_state_payload_mut(&mut persisted)); + let restored = serde_json::from_value::(persisted) + .expect("the parked batch's records reconcile") + .into_game_state(); + let batch = restored + .pending_discard_batch + .as_ref() + .expect("the parked batch survives the round trip"); + let GameEvent::ZoneChanged { record, .. } = &batch.preceding_events[0] else { + panic!("the fixture stores one ZoneChanged"); + }; + assert_eq!( + (record.recorded_turn_number, record.turn_zone_change_index), + (19, 0), + "the carried record is rebound to this turn's ledger on load" + ); + assert_eq!( + batch.cursor, + DiscardBatchCursor::All { + remaining: vec![ObjectId(9_301), ObjectId(9_302)], + }, + "the cursor round-trips unchanged" + ); + } + + /// Registration surface 1's save-compat property: a save written before this + /// field existed — or by any writer that skipped it — must load as `None` + /// and leave the state machine intact, rather than failing deserialization. + /// + /// NOT a revert probe for `#[serde(default)]`. MEASURED, not assumed: + /// deleting `default` from the declaration leaves this test and all 195 + /// `types::game_state::` tests green, because serde's derive already maps a + /// missing `Option` field to `None`. The attribute is redundant TODAY and is + /// kept only for symmetry with the sibling pause carriers + /// (`pending_player_scope_sacrifice_choice`, + /// `pending_mass_library_order_choice`, `pending_scoped_library_search`), + /// so a later reader does not "restore" it without this measurement. + /// + /// This is therefore a characterization test of the save-compat contract, + /// not a discriminator for the attribute. What it does discriminate is the + /// contract itself: make the field non-`Option` and `from_value` errors on + /// the `expect` below — which is also why `default` must NOT be sold as + /// insurance for that change. On a non-`Option` field it would fabricate a + /// `Default` for a missing key instead of failing loudly. + #[test] + fn absent_pending_discard_batch_deserializes_as_none() { + let mut state = GameState::new_two_player(42); + state.turn_number = 19; + let record = persisted_zone_change_record(ObjectId(9_101), 19, 0); + state.zone_changes_this_turn.push_back(record.clone()); + state.pending_discard_batch = Some(parked_discard_batch(record)); + + let mut wire = serde_json::to_value(&state).expect("fixture serializes"); + assert!( + wire.as_object_mut() + .expect("state is an object") + .remove("pending_discard_batch") + .is_some(), + "reach guard: the field must actually have been serialized to remove" + ); + + let restored: GameState = + serde_json::from_value(wire).expect("an absent parked batch defaults to None"); + assert!(restored.pending_discard_batch.is_none()); + assert!( + matches!(restored.waiting_for, WaitingFor::Priority { .. }), + "the restored state machine is intact, not orphaned mid-pause" + ); + } + #[test] fn persisted_zone_change_collision_rebinds_only_unique_ledger_records() { let mut state = normal_trigger_firing_fixture(); diff --git a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz index bf1aae1c260a399471896bbe1be6a33bc0908de3..a1cd5f1beb4bea28d611d34cce8aafb46b6010c9 100644 GIT binary patch literal 42075 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 b${ifgGW&6F|MDOI{*V73R*oR>hm!^X2ZQEZ literal 42027 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 From 8fbaa5cb0fa0cc6138ca5013ddea0ddb98b18e83 Mon Sep 17 00:00:00 2001 From: lgray Date: Sun, 16 Aug 2026 23:08:11 -0500 Subject: [PATCH 09/26] fix(engine): finish a discard batch that paused, and count what it discarded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects on the forced whole-hand route, all on the path Windfall, Jace's Archivist and Whispering Madness actually take: 1. The loop's `return Ok(())` exited mid-hand with no cursor, so the seat's remaining cards were never discarded — they stayed in hand for the rest of the game. Silent data loss. 2. No terminal `EffectResolved` was emitted, so that seat's count could not be derived from events at all. 3. The paused card was never counted even after the choice was answered: the gate-2 resume path emits `Discarded` only when a discard frame is present, and it is absent for all three of these cards. Both bail-outs now park a batch instead of dropping it, and `drain_pending_discard_batch` resumes it from the replacement-choice epilogue — ordered after the sacrifice drain and before the generic continuation drain, so a parked `after_scope` cannot run before the instruction feeding it has settled. `stamp_resumed_discard_if_unrecorded` closes facet 3. `publish_player_scope_clause_results` is extracted from the driver so the driver and the resumed batch share ONE publication rather than two racing ones. That is what makes the CR 608.2i look-back read a complete per-player table across the pause, and it is why no accumulator state is needed: a clause that publishes once needs nothing to merge. The production write sites for `last_effect_counts_by_player` therefore remain exactly four. The driver hands its remaining-seat roster to the batch behind an identity triple — source, seat, and not-already-handed-off — checked before the hand-off rather than inferred from payload shape, so a foreign batch falls through to the unchanged leg path. Also resets `cost_payment_failed_flag` per seat in the drain's fan-out loop (CR 101.3 + CR 608.2c): impossibility is a property of the part, so an earlier seat's mandatory failure must not leak into a later seat. This is the driver's own documented resumption boundary, previously missing on the resumed path. The CR 603.5 prompt census is re-pinned, not relaxed: twelve hunks at or above the coordinate sum to exactly +366, the producer window is sha256-identical, and the partition assert stayed green. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/effects/discard.rs | 136 ++- crates/engine/src/game/effects/mod.rs | 943 +++++++++++++++++-- crates/engine/src/game/engine.rs | 28 +- crates/engine/src/game/engine_replacement.rs | 25 + 4 files changed, 1016 insertions(+), 116 deletions(-) diff --git a/crates/engine/src/game/effects/discard.rs b/crates/engine/src/game/effects/discard.rs index 5b51987b15..bf2cc92228 100644 --- a/crates/engine/src/game/effects/discard.rs +++ b/crates/engine/src/game/effects/discard.rs @@ -154,6 +154,46 @@ pub(crate) fn hand_off_recruit_discard_result( true } +/// Park what this seat's discard instruction still owes so the replacement +/// resume can finish it. +/// +/// CR 616.1 is the pause: "the affected object's controller … or the affected +/// player chooses one to apply". CR 701.9a is what is still owed: each remaining +/// card must still be moved from its owner's hand to their graveyard. This is +/// the SINGLE AUTHORITY for "this batch paused" — both selection modes park +/// through it, so the two cannot drift on what a parked batch means. +/// +/// Deliberately private and called ONLY from `resolve`, the effect layer. The +/// cost layer owns its own typed cursor (`PendingCostMoveResume:: +/// RandomDiscardUnlessPayment`), because it additionally owes an unless-payment +/// this carrier knows nothing about; sharing one carrier across the two would +/// launder a cost payment into an effect, which is exactly what [`DiscardCause`] +/// exists to make unrepresentable. +#[allow(clippy::too_many_arguments)] +fn park_discard_batch( + state: &mut GameState, + player: PlayerId, + cursor: crate::types::game_state::DiscardBatchCursor, + source_id: ObjectId, + effect_kind: EffectKind, + paused_card: ObjectId, + discard_frame: Option, + preceding_events: Vec, +) { + state.pending_discard_batch = Some(Box::new(crate::types::game_state::PendingDiscardBatch { + player, + cursor, + source_id, + effect_kind, + paused_card, + discard_frame, + // The `player_scope` driver installs the fan-out remainder, if any, + // as it unwinds — this layer only knows about one seat. + fan_out: None, + preceding_events, + })); +} + /// CR 701.9a: To discard a card, move it from owner's hand to their graveyard. /// If targets specify specific cards, discard those; otherwise discard from end of hand. pub fn resolve( @@ -174,6 +214,12 @@ pub fn resolve( ), _ => None, }); + // CR 608.2i: the terminal count window for this instruction starts here. + // Everything this node emits before a replacement-application pause is + // carried into the parked batch so the reunited window is exactly what the + // un-paused path would have published. The `player_scope` driver widens it + // to the whole clause's span when the pause interrupted a fan-out. + let events_before_self = events.len(); // CR 701.9b + CR 608.2d: Peel `UpTo` from the count expression to derive // the upper-bound expression and the may-pick-fewer flag. Plain // `QuantityExpr` means a mandatory count; wrapped in `UpTo` means the @@ -442,28 +488,42 @@ pub fn resolve( // CR 701.9a: this is a resolving effect, so Library-of-Leng-class // replacements DO apply — `DiscardCause::Effect`. // - // PRE-EXISTING GAP (unchanged by the extraction, called out so the - // asymmetry with the cost caller below is not mistaken for an - // oversight): a replacement choice mid-batch drops the remaining - // picks, because the effect layer has no batch cursor to resume - // through. The returned cursor is therefore ignored here. The cost - // caller DOES persist it, since it additionally owes a pending - // unless-payment that would otherwise never settle. - if matches!( - discard_at_random( + // CR 616.1: a replacement-application choice mid-batch parks the + // cursor `discard_at_random` returns rather than dropping it; + // `drain_pending_discard_batch` (effects/mod.rs) finishes the + // remaining picks and publishes the terminal marker. The COST caller + // persists the same cursor in its own carrier, because it + // additionally owes an unless-payment this layer has no business + // settling. + if let RandomDiscardOutcome::NeedsReplacementChoice { + remaining_eligible, + remaining_count, + paused_card, + } = discard_at_random( + state, + RandomDiscardRequest { + player: discard_player, + source_id: ability.source_id, + count, + eligible: hand_cards, + cause: DiscardCause::Effect, + discard_frame, + }, + events, + ) { + park_discard_batch( state, - RandomDiscardRequest { - player: discard_player, - source_id: ability.source_id, - count, - eligible: hand_cards, - cause: DiscardCause::Effect, - discard_frame, + discard_player, + crate::types::game_state::DiscardBatchCursor::Random { + pool: remaining_eligible, + remaining: remaining_count, }, - events, - ), - RandomDiscardOutcome::NeedsReplacementChoice { .. } - ) { + ability.source_id, + EffectKind::from(&ability.effect), + paused_card, + discard_frame, + events[events_before_self..].to_vec(), + ); return Ok(()); } } else if hand_cards.is_empty() { @@ -471,7 +531,7 @@ pub fn resolve( } else if !up_to && hand_cards.len() <= count { // Forced discard — no choice needed, discard all eligible cards. // When up_to=true, always present the choice (player may discard fewer). - for obj_id in &hand_cards { + for (i, obj_id) in hand_cards.iter().enumerate() { if let DiscardOutcome::NeedsReplacementChoice(player) = discard_caused_by_effect_with_source_and_frame( state, @@ -484,8 +544,24 @@ pub fn resolve( { state.waiting_for = crate::game::replacement::replacement_choice_waiting_for(player, state); - // Known limitation: EffectResolved is not emitted when replacement - // choice interrupts forced-discard (same systemic gap as sacrifice). + // CR 616.1 + CR 701.9a: park the un-iterated tail instead of + // abandoning it. `hand_cards[i + 1..]` and not `[i..]`: the + // paused card is settled by the replacement itself, exactly + // as `discard_at_random`'s cursor documents. The terminal + // `EffectResolved` below is unreachable from here, so the + // drain emits it — see `drain_pending_discard_batch`. + park_discard_batch( + state, + discard_player, + crate::types::game_state::DiscardBatchCursor::All { + remaining: hand_cards[i + 1..].to_vec(), + }, + ability.source_id, + EffectKind::from(&ability.effect), + *obj_id, + discard_frame, + events[events_before_self..].to_vec(), + ); return Ok(()); } } @@ -615,6 +691,12 @@ pub(crate) enum RandomDiscardOutcome { remaining_eligible: Vec, /// Picks still owed AFTER the paused one resolves. remaining_count: usize, + /// The card whose replacement raised the choice. CR 614.6: the replaced + /// event never happens and a modified event happens instead, so this + /// card was still discarded and the effect layer's drain needs its + /// identity to stamp the terminal `Discarded` the resumed zone-change + /// arm cannot emit. The cost layer does not consume it. + paused_card: ObjectId, }, } @@ -700,6 +782,7 @@ pub(crate) fn discard_at_random( // The paused pick is settled by the replacement itself, so the // resumed batch owes only the picks after it. remaining_count: count - pick - 1, + paused_card: obj_id, }; } } @@ -1043,6 +1126,7 @@ mod random_discard_authority_tests { let RandomDiscardOutcome::NeedsReplacementChoice { remaining_eligible, remaining_count, + paused_card, } = outcome else { panic!("expected a replacement pause, got {outcome:?}"); @@ -1056,6 +1140,12 @@ mod random_discard_authority_tests { 3, "the un-picked pool excludes only the paused card" ); + // The cursor's two halves must agree on WHICH card paused: the reported + // paused card is the one missing from the un-picked pool. + assert!( + hand.contains(&paused_card) && !remaining_eligible.contains(&paused_card), + "the paused card must be a hand card that left the un-picked pool" + ); } /// Caller contract (documented on the authority): a pool shorter than diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index c097a31c5e..18da0fd893 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -22,9 +22,9 @@ use crate::types::ability::{ use crate::types::ability::{AttackScope, AttackSubject}; use crate::types::events::{GameEvent, PlayerActionKind}; use crate::types::game_state::{ - AutoMayChoice, CastOfferKind, ClauseMinimumSnapshot, DayNight, GameState, LKISnapshot, - ManaAbilityResume, MayTriggerAutoChoiceKey, PendingContinuation, PendingCopyTokenBatch, - PendingCostMoveResume, PendingPlayerScopeSacrificeChoice, + AutoMayChoice, CastOfferKind, ClauseMinimumSnapshot, DayNight, DiscardBatchCursor, GameState, + LKISnapshot, ManaAbilityResume, MayTriggerAutoChoiceKey, PendingContinuation, + PendingCopyTokenBatch, PendingCostMoveResume, PendingPlayerScopeSacrificeChoice, PendingPlayerScopeSacrificeCompletion, PendingPlayerScopeSacrificeFollowUp, WaitingFor, ZoneChangeRecord, }; @@ -8307,17 +8307,24 @@ fn previous_effect_excess_amount_from_events( (excess > 0).then_some(excess) } +/// The per-player table a completed instruction leaves behind for a later +/// look-back (CR 608.2i), derived from the terminal event window. +/// +/// Keyed by `EffectKind` rather than `&Effect`: both selections this function +/// makes — which effects are count producers, and how each producer's counts are +/// derived — are kind-level facts, and a batch parked across a replacement +/// pause holds only the kind. Passing the kind is what lets the paused and +/// un-paused paths share ONE count authority instead of growing a second, +/// drifting counter. fn previous_effect_counts_by_player_from_events( - effect: &Effect, + kind: EffectKind, source_id: ObjectId, events: &[GameEvent], ) -> Option> { - let kind = match effect { - Effect::Discard { .. } | Effect::DiscardCard { .. } | Effect::ChangeZoneAll { .. } => { - EffectKind::from(effect) - } + match kind { + EffectKind::Discard | EffectKind::DiscardCard | EffectKind::ChangeZoneAll => {} _ => return None, - }; + } // CR 608.2c: An effect's terminal marker bounds exactly its own completed // instruction. The supplied slice is already scoped to the current parent // or player-scope pass; events later in that slice belong to later work and @@ -8334,11 +8341,11 @@ fn previous_effect_counts_by_player_from_events( })?; let mut counts = HashMap::new(); - match effect { + match kind { // CR 701.9a: `Discarded::source_id` is the causal source authority. // A same-window discard from another effect cannot be attributed to // this instruction merely because it happened before this marker. - Effect::Discard { .. } | Effect::DiscardCard { .. } => { + EffectKind::Discard | EffectKind::DiscardCard => { for event in &events[..=resolved_index] { if let GameEvent::Discarded { player_id, @@ -8356,7 +8363,7 @@ fn previous_effect_counts_by_player_from_events( // the move event. This collects all cards moved by the completed // ChangeZoneAll instruction, including zero-card players as an empty // map when the terminal marker is present. - Effect::ChangeZoneAll { .. } => { + EffectKind::ChangeZoneAll => { for event in &events[..=resolved_index] { if let GameEvent::ZoneChanged { record, .. } = event { *counts.entry(record.owner).or_insert(0) += 1; @@ -8426,6 +8433,97 @@ fn install_previous_effect_counts_by_player( } } +/// Publish one COMPLETED `player_scope` clause's terminal results: the +/// per-player table (zero-filled over `zero_fill_domain`), the scalar/excess +/// fallback, the tracked set, and `last_zone_changed_ids`. +/// +/// CR 608.2f: a clause is one action taken on multiple players; when a +/// replacement-application choice makes it non-simultaneous it is processed per +/// player, but it stays ONE action and therefore has exactly ONE terminal +/// result. This function is extracted so a clause that finished inside the +/// driver and a clause that finished inside a resumed +/// [`drain_pending_discard_batch`] publish through the same authority and +/// cannot drift. +/// +/// `scoped_events` is the clause's full event span. The driver passes its live +/// slice; a resumed batch passes its pre-pause span reunited with the resumed +/// action's buffer. +fn publish_player_scope_clause_results( + state: &mut GameState, + outer: &ResolvedAbility, + scoped_template: &ResolvedAbility, + zero_fill_domain: &[PlayerId], + after_scope_needs_linked_exile: bool, + scoped_events: &[GameEvent], +) { + let counts_by_player = previous_effect_counts_by_player_from_events( + EffectKind::from(&scoped_template.effect), + scoped_template.source_id, + scoped_events, + ); + let counts_by_player = + counts_by_player.map(|counts| fill_zero_contributors(counts, zero_fill_domain)); + if !install_previous_effect_counts_by_player(state, counts_by_player, false) { + if let Some(amount) = + previous_effect_amount_from_events(state, scoped_template, scoped_events) + { + state.last_effect_amount = Some(amount); + // CR 120.10: stamp the resolution-local excess channel alongside + // the running total so a follow-up "if excess damage was dealt + // this way" condition reads overkill-beyond-lethal. CR 120.6 was + // cited for that total and is struck: it governs damage MARKED on + // a creature until the cleanup step, not the amount one clause + // leaves for a later clause in the same resolution — that + // carry-forward is CR 608.2c. + let excess = + previous_effect_excess_amount_from_events(state, scoped_template, scoped_events); + state.last_effect_excess_amount = excess; + } + } + let affected_with_causes = + if next_sub_needs_tracked_set(outer) || after_scope_needs_linked_exile { + affected_objects_with_causes( + state, + scoped_template, + &scoped_template.effect, + scoped_events, + ) + } else { + Vec::new() + }; + let affected_ids: Vec = affected_with_causes.iter().map(|(id, _)| *id).collect(); + if after_scope_needs_linked_exile { + for id in &affected_ids { + if state + .objects + .get(id) + .is_some_and(|obj| obj.zone == crate::types::zones::Zone::Exile) + { + crate::game::exile_links::push_tracked_by_source(state, *id, outer.source_id); + } + } + } + // CR 608.2c: After a `player_scope: All` sacrifice clause completes, + // publish the full scoped event slice so downstream "if you sacrificed + // a permanent this way" / ZoneChangedThisWay gates see every player's + // sacrifice — not only the last iteration's overwrite of + // `last_zone_changed_ids`. + let mut ids: Vec = scoped_events + .iter() + .filter_map(|event| match event { + GameEvent::ZoneChanged { object_id, .. } + | GameEvent::PermanentSacrificed { object_id, .. } => Some(*object_id), + _ => None, + }) + .collect(); + ids.sort_unstable_by_key(|id| id.0); + ids.dedup(); + state.last_zone_changed_ids = ids; + if next_sub_needs_tracked_set(outer) { + publish_tracked_set_with_causes(state, affected_with_causes); + } +} + fn effect_consumes_event_context_amount(effect: &Effect) -> bool { let mut consumes = false; effect.for_each_quantity_expr(&mut |quantity| { @@ -9075,6 +9173,288 @@ pub(crate) fn drain_pending_player_scope_sacrifice_after_replacement( } } +pub(crate) enum PendingDiscardBatchOutcome { + /// No batch was parked; nothing was done. + Idle, + /// The batch (or the fan-out behind it) paused again. `state.waiting_for` + /// carries the new prompt. + PausedForReplacement, + /// The whole instruction settled and published its terminal results. + Completed, +} + +/// Finish a discard instruction that a replacement-application choice parked +/// mid-batch, and publish its terminal result ONCE. +/// +/// CR 616.1 is what parked it. CR 608.2f is why the remainder belongs here and +/// not on the generic continuation queue: the clause is one action taken on +/// several players, processed per player only because it could not be processed +/// simultaneously — so it still has exactly one terminal result. +/// +/// Composability: an arbitrary number of sequential re-pauses compose, because +/// each resume re-enters this same function through the same hook. +pub(crate) fn drain_pending_discard_batch( + state: &mut GameState, + events: &mut Vec, +) -> Result { + let Some(mut batch) = state.pending_discard_batch.take() else { + return Ok(PendingDiscardBatchOutcome::Idle); + }; + + stamp_resumed_discard_if_unrecorded(state, &batch, events); + + // Finish what this seat still owes. The cursor is replaced with an empty + // one so a re-park below installs a fresh remainder rather than mutating a + // borrowed value. + let cursor = std::mem::replace( + &mut batch.cursor, + DiscardBatchCursor::All { + remaining: Vec::new(), + }, + ); + match cursor { + DiscardBatchCursor::All { remaining } => { + for (i, obj_id) in remaining.iter().enumerate() { + if let discard::DiscardOutcome::NeedsReplacementChoice(chooser) = + discard::discard_caused_by_effect_with_source_and_frame( + state, + *obj_id, + batch.player, + Some(batch.source_id), + batch.discard_frame, + events, + ) + { + batch.cursor = DiscardBatchCursor::All { + remaining: remaining[i + 1..].to_vec(), + }; + batch.paused_card = *obj_id; + repark_discard_batch(state, batch, events, chooser); + return Ok(PendingDiscardBatchOutcome::PausedForReplacement); + } + } + } + DiscardBatchCursor::Random { pool, remaining } => { + if let discard::RandomDiscardOutcome::NeedsReplacementChoice { + remaining_eligible, + remaining_count, + paused_card, + } = discard::discard_at_random( + state, + discard::RandomDiscardRequest { + player: batch.player, + source_id: batch.source_id, + count: remaining, + eligible: pool, + cause: discard::DiscardCause::Effect, + discard_frame: batch.discard_frame, + }, + events, + ) { + batch.cursor = DiscardBatchCursor::Random { + pool: remaining_eligible, + remaining: remaining_count, + }; + batch.paused_card = paused_card; + // `discard_at_random` already set `state.waiting_for`; pass the + // seat that is choosing so the re-park helper reads one contract. + let chooser = batch.player; + repark_discard_batch(state, batch, events, chooser); + return Ok(PendingDiscardBatchOutcome::PausedForReplacement); + } + } + } + + // CR 608.2c: the terminal marker the pre-pause action could not emit, + // because it returned from inside the batch loop. Without it this seat's + // count is underivable — `previous_effect_counts_by_player_from_events` + // early-returns at its `rposition`. + events.push(GameEvent::EffectResolved { + kind: batch.effect_kind, + source_id: batch.source_id, + subject: None, + }); + + // CR 608.2f + CR 101.4: run the clause's remaining seats, in the APNAP + // order latched at the pause. + if let Some(fan_out) = batch.fan_out.take() { + let fan_out = *fan_out; + let initial_waiting_for = state.waiting_for.clone(); + for (i, pid) in fan_out.remaining_players.iter().enumerate() { + let mut scoped = (*fan_out.scoped_template).clone(); + // CR 608.2c + CR 101.3: each scoped iteration is a fresh + // sub-resolution of the scoped template, so the cost-payment-failed + // signal is per-iteration. This is the same resumption boundary the + // driver's own loop resets; without it an earlier seat's mandatory + // failure (an empty-handed seat's `count == 0 && !up_to` arm) leaks + // into a later seat's `IfCurrentScopeSucceeded` read, for cards like + // Refurbished Familiar and Aclazotz, Deepest Betrayal. + state.cost_payment_failed_flag = false; + scoped.set_original_controller_recursive(fan_out.original_controller); + scoped.set_controller_recursive(*pid); + scoped.set_scoped_player_recursive(*pid); + resolve_ability_chain(state, &scoped, events, 1)?; + if state.waiting_for == initial_waiting_for { + continue; + } + // This seat paused. If it parked its OWN discard batch, move the + // clause remainder onto that batch so the instruction still ends in + // one publication. + if let Some(next) = state.pending_discard_batch.as_mut() { + if next.fan_out.is_none() + && next.source_id == fan_out.scoped_template.source_id + && next.player == *pid + { + let mut window = std::mem::take(&mut batch.preceding_events); + window.extend_from_slice(events); + next.preceding_events = window; + next.fan_out = Some(Box::new(crate::types::game_state::PendingDiscardFanOut { + remaining_players: fan_out.remaining_players[i + 1..].to_vec(), + ..fan_out.clone() + })); + return Ok(PendingDiscardBatchOutcome::PausedForReplacement); + } + } + // BOUNDARY (measured, and deliberately not repaired here): the seat + // paused on something that is not a batch pause — an interactive + // `WaitingFor::DiscardChoice`, or any other resolution choice. Hand + // the remaining seats back to the generic continuation queue exactly + // as the driver does, and publish NOTHING: those legs each publish + // node-locally, which is the pre-existing behaviour this change does + // not extend to the interactive path. + let mut tail: Option> = None; + for &remaining_pid in fan_out.remaining_players[i + 1..].iter().rev() { + let mut remaining_scoped = (*fan_out.scoped_template).clone(); + remaining_scoped.set_original_controller_recursive(fan_out.original_controller); + remaining_scoped.set_controller_recursive(remaining_pid); + remaining_scoped.set_scoped_player_recursive(remaining_pid); + remaining_scoped.sub_link = SubAbilityLink::SequentialSibling; + if let Some(prev) = tail { + super::ability_utils::append_to_sub_chain(&mut remaining_scoped, *prev); + } + tail = Some(Box::new(remaining_scoped)); + } + if tail.is_some() { + append_to_pending_continuation(state, tail); + } + return Ok(PendingDiscardBatchOutcome::PausedForReplacement); + } + + // CR 608.2i: the look-back window is everything this instruction did, + // on both sides of the pause. `preceding_events` was copied rather than + // drained, so `events` still holds only the resumed action's own span. + let mut window = std::mem::take(&mut batch.preceding_events); + window.extend_from_slice(events); + publish_player_scope_clause_results( + state, + &fan_out.outer, + &fan_out.scoped_template, + &fan_out.matching_players, + fan_out.after_scope_needs_linked_exile, + &window, + ); + // CR 608.2h: the clause has completed, so clear its frozen values before + // the parked tail runs — a following `player_scope` clause captures its + // own snapshot against the post-this-clause board. + state.clause_minimum_snapshot = None; + return Ok(PendingDiscardBatchOutcome::Completed); + } + + // Single-subject discard: no fan-out, so no reduction domain to zero-fill. + // Mirrors the non-`player_scope` publication site, whose `preserve` argument + // is provably irrelevant here — it is read only on the `None` arm, and the + // marker pushed above guarantees `Some`. + let mut window = std::mem::take(&mut batch.preceding_events); + window.extend_from_slice(events); + install_previous_effect_counts_by_player( + state, + previous_effect_counts_by_player_from_events(batch.effect_kind, batch.source_id, &window), + false, + ); + state.last_zone_changed_ids = window + .iter() + .filter_map(|e| match e { + GameEvent::ZoneChanged { object_id, .. } => Some(*object_id), + _ => None, + }) + .collect(); + Ok(PendingDiscardBatchOutcome::Completed) +} + +/// Re-park a batch that paused again, carrying the resumed action's span into +/// the pre-pause window so the terminal count still covers the whole +/// instruction. +fn repark_discard_batch( + state: &mut GameState, + mut batch: Box, + events: &[GameEvent], + chooser: PlayerId, +) { + let mut window = std::mem::take(&mut batch.preceding_events); + window.extend_from_slice(events); + batch.preceding_events = window; + state.pending_discard_batch = Some(batch); + state.waiting_for = crate::game::replacement::replacement_choice_waiting_for(chooser, state); +} + +/// Stamp the terminal `Discarded` for the card whose replacement just resolved, +/// when the resume path could not emit one. +/// +/// CR 614.6: "If an event is replaced, it never happens. A modified event occurs +/// instead." A hand → graveyard `Moved` redirect (Rest in Peace class) therefore +/// still discarded the card per CR 701.9a, and a Madness redirect explicitly +/// does (CR 702.35a: "that player discards it, but exiles it instead of putting +/// it into their graveyard"). But that resume returns through terminal zone +/// delivery, which emits `Discarded` only for a provenance-framed discard — so +/// for every unframed discard the card leaves the hand and is never counted. +/// +/// The already-emitted guard is what makes the OTHER gate idempotent: a +/// `ReplacementEvent::Discard` pause (Library of Leng class) resumes through +/// `complete_discard_to_graveyard`, which does emit the event. This is the +/// direct analogue of the sacrifice batch's `!completion.sacrificed.contains(id)` +/// guard. +fn stamp_resumed_discard_if_unrecorded( + state: &mut GameState, + batch: &crate::types::game_state::PendingDiscardBatch, + events: &mut Vec, +) { + let card = batch.paused_card; + let already_recorded = events.iter().any(|event| { + matches!( + event, + GameEvent::Discarded { object_id, .. } if *object_id == card + ) + }); + if already_recorded { + return; + } + let left_hand = events.iter().any(|event| { + matches!( + event, + GameEvent::ZoneChanged { + object_id, + from: Some(crate::types::zones::Zone::Hand), + .. + } if *object_id == card + ) + }); + if !left_hand { + return; + } + crate::game::restrictions::record_discard(state, batch.player); + // CR 702.187b: the Mayhem marker is stamped only when the card actually + // landed in the graveyard — a redirect leaves it elsewhere, matching the + // un-paused path's own condition. + if state.objects.get(&card).map(|o| o.zone) == Some(crate::types::zones::Zone::Graveyard) { + crate::game::restrictions::record_card_discarded(state, card); + } + events.push(GameEvent::Discarded { + player_id: batch.player, + object_id: card, + source_id: Some(batch.source_id), + }); +} + /// Resolve an ability and follow its sub_ability chain using typed nested structs. /// No SVar lookup, no parse_ability(). The depth is bounded by the data structure. /// CR 608.2c: True when `condition` is a quantity comparison awaiting a @@ -9931,6 +10311,54 @@ fn resolve_chain_body( if after_scope_needs_linked_exile { mark_exile_choice_tracks_by_source(state, ability.source_id); } + // CR 608.2f: this fan-out paused because THIS seat's discard + // batch is parked. The clause's remaining seats belong to that + // batch, not to the generic continuation queue, so the whole + // instruction publishes ONE per-player table instead of one + // table per resumed leg. Mirrors + // `start_player_scope_sacrifice_choices`, which likewise keeps + // `remaining_players` on its pending state and parks only the + // unscoped tail. + // + // The identity triple is checked BEFORE the hand-off and never + // inferred from payload shape: a batch parked by a different + // source, by a different seat, or one that a nested clause has + // already handed off, fails a conjunct and the driver falls + // through to the ordinary per-seat leg path below, unchanged. + let handed_to_discard_batch = + state.pending_discard_batch.as_ref().is_some_and(|batch| { + batch.source_id == scoped_template.source_id + && batch.player == *pid + && batch.fan_out.is_none() + }); + if handed_to_discard_batch { + if let Some(batch) = state.pending_discard_batch.as_mut() { + // Widen the batch's pre-pause window from this seat's + // own emissions to the whole clause's span: the earlier + // seats' discards are part of the same instruction. + // Copied, not drained — the pre-pause action still + // returns them to its caller. + batch.preceding_events = events[scoped_events_before..].to_vec(); + batch.fan_out = + Some(Box::new(crate::types::game_state::PendingDiscardFanOut { + scoped_template: Box::new(scoped_template.clone()), + outer: Box::new(ability.clone()), + original_controller: controller, + remaining_players: matching_players[i + 1..].to_vec(), + matching_players: matching_players.clone(), + after_scope_needs_linked_exile, + })); + } + // Only the unscoped tail goes to the generic continuation; + // the per-seat legs do not exist on this path. + if after_scope.is_some() { + append_to_pending_continuation(state, after_scope.clone()); + } + // Deliberately skips the clause postlude below: the batch + // owns that publication now, and running it here as well + // would publish a truncated table first. + return Ok(()); + } let remaining = &matching_players[i + 1..]; let mut tail = after_scope.clone(); // Build continuation chain for remaining players in APNAP order. @@ -9977,76 +10405,14 @@ fn resolve_chain_body( break; } } - let scoped_events = &events[scoped_events_before..]; - let counts_by_player = previous_effect_counts_by_player_from_events( - &scoped_template.effect, - scoped_template.source_id, - scoped_events, - ); - let counts_by_player = counts_by_player - .map(|counts| fill_zero_contributors(counts, &matching_players[..applied_domain_end])); - if !install_previous_effect_counts_by_player(state, counts_by_player, false) { - if let Some(amount) = - previous_effect_amount_from_events(state, &scoped_template, scoped_events) - { - state.last_effect_amount = Some(amount); - // CR 120.10: stamp the resolution-local excess channel alongside - // the running total so a follow-up "if excess damage was dealt - // this way" condition reads overkill-beyond-lethal. CR 120.6 was - // cited for that total and is struck: it governs damage MARKED on - // a creature until the cleanup step, not the amount one clause - // leaves for a later clause in the same resolution — that - // carry-forward is CR 608.2c. - let excess = previous_effect_excess_amount_from_events( - state, - &scoped_template, - scoped_events, - ); - state.last_effect_excess_amount = excess; - } - } - let affected_with_causes = - if next_sub_needs_tracked_set(ability) || after_scope_needs_linked_exile { - affected_objects_with_causes( - state, - &scoped_template, - &scoped_template.effect, - scoped_events, - ) - } else { - Vec::new() - }; - let affected_ids: Vec = affected_with_causes.iter().map(|(id, _)| *id).collect(); - if after_scope_needs_linked_exile { - for id in &affected_ids { - if state - .objects - .get(id) - .is_some_and(|obj| obj.zone == crate::types::zones::Zone::Exile) - { - crate::game::exile_links::push_tracked_by_source(state, *id, ability.source_id); - } - } - } - // CR 608.2c: After a `player_scope: All` sacrifice clause completes, - // publish the full scoped event slice so downstream "if you sacrificed - // a permanent this way" / ZoneChangedThisWay gates see every player's - // sacrifice — not only the last iteration's overwrite of - // `last_zone_changed_ids`. - let mut ids: Vec = scoped_events - .iter() - .filter_map(|event| match event { - GameEvent::ZoneChanged { object_id, .. } - | GameEvent::PermanentSacrificed { object_id, .. } => Some(*object_id), - _ => None, - }) - .collect(); - ids.sort_unstable_by_key(|id| id.0); - ids.dedup(); - state.last_zone_changed_ids = ids; - if next_sub_needs_tracked_set(ability) { - publish_tracked_set_with_causes(state, affected_with_causes); - } + publish_player_scope_clause_results( + state, + ability, + &scoped_template, + &matching_players[..applied_domain_end], + after_scope_needs_linked_exile, + &events[scoped_events_before..], + ); if !paused { // CR 608.2e: this `player_scope` clause has completed. Clear its // frozen values before running any following instruction; if the @@ -11126,7 +11492,7 @@ fn resolve_chain_body( // many" chains (Tolarian Winds) stamp `last_effect_count`. let parent_events = &events[events_before..]; let counts_by_player = previous_effect_counts_by_player_from_events( - &ability.effect, + EffectKind::from(&ability.effect), ability.source_id, parent_events, ); @@ -18716,6 +19082,388 @@ mod tests { ); } + // --------------------------------------------------------------------- + // The CR 616.1 discard-batch carrier. + // --------------------------------------------------------------------- + + /// A `player_scope: All` "each player discards a card" clause template. + fn scoped_discard_one(source_id: ObjectId) -> ResolvedAbility { + let mut ability = ResolvedAbility::new( + Effect::Discard { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::ScopedPlayer, + selection: crate::types::ability::CardSelectionMode::Chosen, + unless_filter: None, + filter: None, + }, + vec![], + source_id, + PlayerId(0), + ); + ability.player_scope = Some(PlayerFilter::All); + ability + } + + fn deal_hand(state: &mut GameState, seat: u8, cards: u32) -> Vec { + (0..cards) + .map(|n| { + create_object( + state, + CardId(500 + u64::from(seat) * 10 + u64::from(n)), + PlayerId(seat), + format!("P{seat} Card {n}"), + Zone::Hand, + ) + }) + .collect() + } + + fn park_batch( + state: &mut GameState, + source_id: ObjectId, + player: PlayerId, + remaining: Vec, + fan_out: Option>, + ) { + state.pending_discard_batch = + Some(Box::new(crate::types::game_state::PendingDiscardBatch { + player, + cursor: DiscardBatchCursor::All { remaining }, + source_id, + effect_kind: EffectKind::Discard, + paused_card: ObjectId(9_999_999), + discard_frame: None, + fan_out, + preceding_events: Vec::new(), + })); + } + + fn fan_out_of( + source_id: ObjectId, + remaining_players: Vec, + matching_players: Vec, + ) -> Box { + let template = scoped_discard_one(source_id); + let mut scoped = template.clone(); + scoped.player_scope = None; + Box::new(crate::types::game_state::PendingDiscardFanOut { + scoped_template: Box::new(scoped), + outer: Box::new(template), + original_controller: PlayerId(0), + remaining_players, + matching_players, + after_scope_needs_linked_exile: false, + }) + } + + /// CR 608.2c: the terminal marker the pre-pause action could not emit, + /// because it returned from inside the batch loop. + /// + /// Without it the seat's count is underivable — + /// `previous_effect_counts_by_player_from_events` early-returns at its + /// `rposition`, so `install_previous_effect_counts_by_player` takes the arm + /// that CLEARS the table. The window spans the pause: one pre-pause discard + /// carried in `preceding_events` plus the two the drain still owes. + /// + /// REVERT PROBE (RUN, not reasoned): delete the + /// `events.push(GameEvent::EffectResolved { .. })` in + /// `drain_pending_discard_batch`. Observed first failure — "exactly one + /// terminal marker, matching the un-paused path's one per seat / left: 0 / + /// right: 1". The `last_effect_count` assertion below is downstream of that + /// one and never gets to run, so it is the marker count that discriminates. + #[test] + fn drained_discard_batch_emits_its_terminal_marker_and_counts_across_the_pause() { + let mut state = GameState::new_two_player(42); + let source = ObjectId(100); + let hand = deal_hand(&mut state, 0, 2); + let already_discarded = ObjectId(9_001); + + park_batch(&mut state, source, PlayerId(0), hand.clone(), None); + state + .pending_discard_batch + .as_mut() + .unwrap() + .preceding_events = vec![GameEvent::Discarded { + player_id: PlayerId(0), + object_id: already_discarded, + source_id: Some(source), + }]; + + let mut events = Vec::new(); + let outcome = drain_pending_discard_batch(&mut state, &mut events).unwrap(); + + assert!( + matches!(outcome, PendingDiscardBatchOutcome::Completed), + "the batch owed two cards and no replacement intervened" + ); + // Reach guard: the two owed cards really were discarded, so the count + // below cannot be a stale read from a run that did nothing. + assert_eq!( + hand.iter() + .filter(|id| state.objects[id].zone == Zone::Graveyard) + .count(), + 2, + "reach guard: the drain must finish the parked cursor" + ); + assert_eq!( + events + .iter() + .filter(|e| matches!( + e, + GameEvent::EffectResolved { kind: EffectKind::Discard, source_id: s, .. } + if *s == source + )) + .count(), + 1, + "exactly one terminal marker, matching the un-paused path's one per seat" + ); + assert_eq!( + state.last_effect_count, + Some(3), + "the published count spans the pause: 1 pre-pause discard + 2 owed" + ); + } + + /// CR 608.2c + CR 101.3: the drain's per-seat resumption boundary. + /// + /// `cost_payment_failed_flag` is per-iteration. Seat 1 is empty-handed, so + /// its mandatory discard fails (`discard.rs`'s `count == 0 && !up_to` arm) + /// and raises the flag; seat 2 then succeeds. Without the reset, seat 1's + /// failure leaks into seat 2's `IfCurrentScopeSucceeded` read — the same + /// leak the driver's own loop resets against for Refurbished Familiar and + /// Aclazotz, Deepest Betrayal. + /// + /// REVERT PROBE (RUN, not reasoned): delete + /// `state.cost_payment_failed_flag = false;` from the drain's fan-out loop. + /// Observed failure — "an earlier seat's mandatory failure must not leak + /// into a later seat". + #[test] + fn drained_fan_out_resets_the_cost_payment_failure_between_seats() { + let mut state = GameState::new(FormatConfig::standard(), 4, 42); + let source = ObjectId(100); + // Seat 1 empty (raises the flag); seat 2 holds one card, so its forced + // discard succeeds. The roster deliberately ends on the succeeding seat. + let seat2 = deal_hand(&mut state, 2, 1); + + park_batch(&mut state, source, PlayerId(0), Vec::new(), None); + state.pending_discard_batch.as_mut().unwrap().fan_out = Some(fan_out_of( + source, + vec![PlayerId(1), PlayerId(2)], + vec![PlayerId(0), PlayerId(1), PlayerId(2), PlayerId(3)], + )); + + let mut events = Vec::new(); + drain_pending_discard_batch(&mut state, &mut events).unwrap(); + + // Reach guards: both seats really ran. Without them the `false` below + // could hold vacuously on a roster that was never iterated. + assert_eq!( + state.objects[&seat2[0]].zone, + Zone::Graveyard, + "reach guard: the later seat's forced discard must have run" + ); + assert!( + state.players[1].hand.is_empty(), + "reach guard: the earlier seat must be the empty-handed one" + ); + assert!( + !state.cost_payment_failed_flag, + "an earlier seat's mandatory failure must not leak into a later seat" + ); + } + + /// CR 608.2f: BOUNDARY. A later seat that pauses on something which is NOT + /// a batch pause — here an interactive `WaitingFor::DiscardChoice` — hands + /// the remaining seats back to the generic continuation queue exactly as the + /// driver does, and leaves no stale batch live. + /// + /// That interactive route is the one this change deliberately does NOT + /// repair; this test pins that it is handed back cleanly rather than + /// corrupted. + /// + /// REVERT PROBE: delete the leg-rebuild loop in the drain's non-batch pause + /// fallback. The remaining seats are silently dropped and + /// `active_ability_continuation().is_some()` fails. + #[test] + fn drained_fan_out_returns_an_interactive_seat_to_the_continuation_path() { + let mut state = GameState::new(FormatConfig::standard(), 4, 42); + let source = ObjectId(100); + // Seat 1 holds two cards facing "discard a card": it must choose. + deal_hand(&mut state, 1, 2); + deal_hand(&mut state, 2, 1); + deal_hand(&mut state, 3, 1); + + park_batch(&mut state, source, PlayerId(0), Vec::new(), None); + state.pending_discard_batch.as_mut().unwrap().fan_out = Some(fan_out_of( + source, + vec![PlayerId(1), PlayerId(2), PlayerId(3)], + vec![PlayerId(0), PlayerId(1), PlayerId(2), PlayerId(3)], + )); + + let mut events = Vec::new(); + let outcome = drain_pending_discard_batch(&mut state, &mut events).unwrap(); + + assert!( + matches!(outcome, PendingDiscardBatchOutcome::PausedForReplacement), + "an unfinished clause must report a pause, not completion" + ); + assert!( + matches!(state.waiting_for, WaitingFor::DiscardChoice { .. }), + "reach guard: seat 1 must actually be sitting on its choice, got {:?}", + state.waiting_for + ); + assert!( + state.pending_discard_batch.is_none(), + "no stale batch may be left live once the clause left this path" + ); + assert!( + state.active_ability_continuation().is_some(), + "seats 2 and 3 must be returned to the generic continuation queue" + ); + } + + /// MULTI-AUTHORITY. The driver hand-off's identity triple must reject a + /// batch that is not the one this clause's seat just parked. + /// + /// The reachable hostile shape: the running clause's seat pauses on an + /// INTERACTIVE `DiscardChoice` (which parks no batch) while an unrelated + /// batch already sits in the single-slot carrier. Without the triple the + /// driver would hand this clause's roster to that stranger. + /// + /// REVERT PROBES (all three RUN, not reasoned), one per conjunct in the + /// driver's `handed_to_discard_batch` predicate. Each independently reddens + /// exactly one arm, and all three land on the SAME assertion — the + /// sentinel-roster one — with only the arm label differing: + /// (a) delete `batch.source_id == scoped_template.source_id` → observed + /// "foreign_source: a parked batch's roster must not be overwritten by + /// this clause / left: [PlayerId(1), PlayerId(2), PlayerId(3)] / + /// right: [PlayerId(3)]". + /// (b) delete `batch.player == *pid` → same assertion, "foreign_seat:". + /// (c) delete `batch.fan_out.is_none()` → same assertion, + /// "already_handed_off:". + #[test] + fn hand_off_identity_triple_rejects_a_foreign_batch() { + // Sentinel roster, distinguishable from the clause's real remainder + // [P1, P2, P3], so an unwanted hand-off is visible. + const SENTINEL: [PlayerId; 1] = [PlayerId(3)]; + + for arm in ["foreign_source", "foreign_seat", "already_handed_off"] { + let mut state = GameState::new(FormatConfig::standard(), 4, 42); + let clause_source = ObjectId(100); + // Seat 0 holds two cards facing "discard a card": it must choose, + // so the fan-out pauses WITHOUT parking a batch of its own. + deal_hand(&mut state, 0, 2); + deal_hand(&mut state, 1, 1); + deal_hand(&mut state, 2, 1); + deal_hand(&mut state, 3, 1); + + let (stale_source, stale_player, stale_fan_out) = match arm { + "foreign_source" => (ObjectId(999), PlayerId(0), None), + "foreign_seat" => (clause_source, PlayerId(1), None), + _ => ( + clause_source, + PlayerId(0), + Some(fan_out_of( + clause_source, + SENTINEL.to_vec(), + SENTINEL.to_vec(), + )), + ), + }; + park_batch( + &mut state, + stale_source, + stale_player, + Vec::new(), + stale_fan_out, + ); + + let mut events = Vec::new(); + resolve_ability_chain( + &mut state, + &scoped_discard_one(clause_source), + &mut events, + 0, + ) + .unwrap(); + + assert!( + matches!(state.waiting_for, WaitingFor::DiscardChoice { .. }), + "{arm}: reach guard — the clause must actually pause on seat 0's choice, \ + got {:?}", + state.waiting_for + ); + let batch = state + .pending_discard_batch + .as_ref() + .unwrap_or_else(|| panic!("{arm}: the stale batch must still be parked")); + assert_eq!( + batch.source_id, stale_source, + "{arm}: the stale batch's identity must be untouched" + ); + match &batch.fan_out { + None => assert_ne!( + arm, "already_handed_off", + "the already-handed-off arm must keep its own fan-out" + ), + Some(fan_out) => assert_eq!( + fan_out.remaining_players, SENTINEL, + "{arm}: a parked batch's roster must not be overwritten by this clause" + ), + } + assert!( + state.active_ability_continuation().is_some(), + "{arm}: the driver must fall through to the ordinary per-seat leg path" + ); + } + } + + /// Kind-keying `previous_effect_counts_by_player_from_events` is + /// behaviour-preserving: the producer set is exactly Discard / DiscardCard / + /// ChangeZoneAll, and nothing else opens a count window. + /// + /// REVERT PROBE (RUN, not reasoned): add `EffectKind::Draw` to the producer + /// arm of the opening `match kind`. Observed failure is NOT a test assertion + /// — it is the production `unreachable!("producer kind was selected above")` + /// in the inner match, because opening the outer gate without adding the + /// matching inner arm makes the two disagree. Still discriminating (the run + /// goes red on Draw's row and cannot go green), but a reader should expect a + /// panic from production rather than an `assert!` message. + #[test] + fn count_authority_producer_set_is_closed_over_effect_kind() { + let source = ObjectId(10); + for kind in [ + EffectKind::Discard, + EffectKind::DiscardCard, + EffectKind::ChangeZoneAll, + ] { + assert!( + previous_effect_counts_by_player_from_events( + kind, + source, + &[resolved_event(kind, source)], + ) + .is_some(), + "{kind:?} is a count producer" + ); + } + for kind in [ + EffectKind::Draw, + EffectKind::LoseLife, + EffectKind::DealDamage, + ] { + assert!( + previous_effect_counts_by_player_from_events( + kind, + source, + &[resolved_event(kind, source)], + ) + .is_none(), + "{kind:?} publishes no per-player table" + ); + } + } + #[test] fn previous_effect_amount_for_damage_ignores_counter_side_effects() { let mut state = GameState::new_two_player(42); @@ -30073,14 +30821,17 @@ mod tests { }, ]; - let counts = - previous_effect_counts_by_player_from_events(&discard_count_effect(), source, &events) - .expect("the exact discard terminal marker is present"); + let counts = previous_effect_counts_by_player_from_events( + EffectKind::from(&discard_count_effect()), + source, + &events, + ) + .expect("the exact discard terminal marker is present"); assert_eq!(counts, HashMap::from([(PlayerId(0), 1)])); assert!( previous_effect_counts_by_player_from_events( - &discard_count_effect(), + EffectKind::from(&discard_count_effect()), source, &[resolved_event(EffectKind::ChangeZoneAll, source)], ) @@ -30089,7 +30840,7 @@ mod tests { ); assert!( previous_effect_counts_by_player_from_events( - &discard_count_effect(), + EffectKind::from(&discard_count_effect()), source, &[resolved_event(EffectKind::Discard, other_source)], ) @@ -30098,7 +30849,7 @@ mod tests { ); assert!( previous_effect_counts_by_player_from_events( - &discard_count_effect(), + EffectKind::from(&discard_count_effect()), source, &events[..2], ) @@ -30120,7 +30871,11 @@ mod tests { zone_changed_event(ObjectId(2), PlayerId(1)), ]; assert_eq!( - previous_effect_counts_by_player_from_events(&effect, source, &before_then_after), + previous_effect_counts_by_player_from_events( + EffectKind::from(&effect), + source, + &before_then_after + ), Some(HashMap::from([(PlayerId(0), 1)])), "zone changes after the final marker belong to later work" ); @@ -30132,14 +30887,18 @@ mod tests { resolved_event(EffectKind::ChangeZoneAll, source), ]; assert_eq!( - previous_effect_counts_by_player_from_events(&effect, source, &two_same_source_moves), + previous_effect_counts_by_player_from_events( + EffectKind::from(&effect), + source, + &two_same_source_moves + ), Some(HashMap::from([(PlayerId(0), 1), (PlayerId(1), 1)])), "the final same-source marker aggregates the completed scoped moves" ); assert_eq!( previous_effect_counts_by_player_from_events( - &effect, + EffectKind::from(&effect), source, &[resolved_event(EffectKind::ChangeZoneAll, source)], ), diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index d63602394c..d98e685879 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -19085,9 +19085,35 @@ mod stage2_injector_tests { // again, because the corrected measurement in that comment is five lines // longer than the wrong one it replaced. Window `d7fd67fd…` unchanged, // both neighbours differing as controls. + // + // Paused-discard-batch unit (base 3b89667ba): `:10432 ⇒ :10798`, +366, + // THIRD PRODUCER ONLY. LOCAL, not upstream, so the CI-vs-local diagnosis in + // the header does not apply. `git diff -U0 3b89667ba` on effects/mod.rs has + // twelve hunks at or above the old coordinate, and they sum to exactly the + // shift: `+9` (the count authority's new doc comment), `-2` (its `&Effect` + // producer gate replaced by a two-line `match kind`), `+91` + // (`publish_player_scope_clause_results`, the extraction the driver and the + // resumed batch now share), `+282` (`drain_pending_discard_batch` and its two + // helpers), `+48` (the driver hand-off's identity triple, INSIDE + // `resolve_chain_body` and above its gate), and `-62` (the publication block + // this producer's function used to inline, now a call to the extraction). + // 9 - 2 + 91 + 282 + 48 - 62 = 366, and `10432 + 366` equals the observed + // coordinate exactly. None of the six adds or removes a prompt mint: the + // drain RESUMES an instruction that already minted its `ReplacementChoice` + // before the pause, and the hand-off only re-routes a roster. + // Identity re-established, not assumed, and re-measured after the rebase + // rather than carried forward: the producer window at `:10798` is + // sha256-identical to `3b89667ba:effects/mod.rs` at `:10432` + // (`d7fd67fd769a2e2e`) and is still inside `resolve_chain_body`. + // The diff instrument discriminates: the OLD coordinate `:10432` now holds a + // prose line from the resolution-time target-binding comment, which mints + // nothing. Set preservation: the two asserts above this one ran FIRST and + // both fired GREEN on the run that caught this (partition still 5/8/28), the + // other two effects/mod.rs entries sit ABOVE every hunk and did not move, and + // neither `scoped_library_search.rs` nor this file's own producer was touched. "game/effects/mod.rs:7061".to_string(), "game/effects/mod.rs:7138".to_string(), - "game/effects/mod.rs:10432".to_string(), + "game/effects/mod.rs:10798".to_string(), // UNMOVED across the rebase, and that is itself evidence the SET did not // move: a census that had gained or lost a producer would not leave this // entry both byte-identical AND at the same coordinate. diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index 18a1de93e2..26e0123ea4 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -1019,6 +1019,31 @@ pub(super) fn handle_replacement_choice( } } + // CR 616.1 + CR 608.2f: a discard instruction parked mid-batch by a + // replacement-application choice finishes what it still owes BEFORE + // any parked continuation runs — the same ordering the simultaneous + // sacrifice block above states, for the same reason: the clause is + // ONE action, so the instructions after it must not resume until it + // has settled and published its terminal result. + if matches!(waiting_for, WaitingFor::Priority { .. }) + && state.pending_discard_batch.is_some() + { + match effects::drain_pending_discard_batch(state, events) + .map_err(|error| EngineError::InvalidAction(error.to_string()))? + { + effects::PendingDiscardBatchOutcome::Idle => {} + effects::PendingDiscardBatchOutcome::PausedForReplacement => { + waiting_for = state.waiting_for.clone(); + } + effects::PendingDiscardBatchOutcome::Completed => { + effects::drain_pending_continuation(state, events); + if !matches!(state.waiting_for, WaitingFor::Priority { .. }) { + waiting_for = state.waiting_for.clone(); + } + } + } + } + if matches!(waiting_for, WaitingFor::Priority { .. }) && (state.active_ability_continuation().is_some() || state.active_change_zone_frame().is_some()) From 07f55e9f8eb790cd669bb9aa25bef0657f20b38f Mon Sep 17 00:00:00 2001 From: lgray Date: Sun, 16 Aug 2026 23:08:29 -0500 Subject: [PATCH 10/26] test(engine): drive the paused discard through the real cast pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two arms on the existing aggregate file, both driving the production pipeline rather than a helper: cast Windfall into a four-seat board with hands 7/3/5/2, arranged so the seat that PAUSES holds the maximum. That placement is the whole point — a fixture where the paused seat is not the max cannot tell a complete table from a truncated one. The reference values are mutually distinct by construction, so no partial-table failure mode can coincide with the right answer: 7 is reachable only if the paused seat is in the table, 5 is the max without it, 2 is the last publication alone, and 6 is the value if the uncounted-redirected-card facet were left unrepaired. Arm B lands the paused card in exile, exercising the false arm of the graveyard guard. Both were run at the pre-fix tip first and were RED with the signature predicted in advance — 1 prompt, `drawn == [2,2,2,2]` — before any production line was written; they now read 7 prompts and `[7,7,7,7]`. One correction came out of running rather than deriving them: the predicted graveyard row had omitted the spell's own card, which lands in its controller's graveyard as the final part of resolution (CR 608.2n). The random-branch file covers the other cursor shape, with a pool of four and two sequential pauses, because a single-pick fixture could never exercise a cursor. Its redirect is narrowed to exclude the spell itself: with an unfiltered redirect the spell's own graveyard move overwrites the parked choice, which is a separate pre-existing defect recorded in the test's doc comment so a future reader sees why the narrowing exists. Assisted-by: ClaudeCode:claude-opus-5 --- .../random_discard_cost_replacement_resume.rs | 87 +++++ .../windfall_greatest_discard_aggregate.rs | 299 +++++++++++++++++- 2 files changed, 385 insertions(+), 1 deletion(-) diff --git a/crates/engine/tests/integration/random_discard_cost_replacement_resume.rs b/crates/engine/tests/integration/random_discard_cost_replacement_resume.rs index fbdb1031c8..47480e35af 100644 --- a/crates/engine/tests/integration/random_discard_cost_replacement_resume.rs +++ b/crates/engine/tests/integration/random_discard_cost_replacement_resume.rs @@ -164,6 +164,14 @@ fn random_discard_cost_resumes_its_payment_after_an_accepted_replacement() { runner.state().pending_cost_move_resume.is_some(), "the unless-payment continuation must be persisted while the choice is open" ); + // NEGATIVE, paired with the positive reach guard directly above: the EFFECT + // layer's batch carrier must stay empty for a COST payment. The assertion is + // non-vacuous precisely because the line above proves a random discard batch + // really did pause here — only the layer differs. + assert!( + runner.state().pending_discard_batch.is_none(), + "a cost-layer random discard must not park an EFFECT batch (DiscardCause::Cost)" + ); let accept_idx = candidates .iter() .position(|c| c.description == "Accept") @@ -316,3 +324,82 @@ fn random_discard_cost_with_no_cards_still_sacrifices() { "nothing may be left parked" ); } + +/// Hymn to Tourach's printed Oracle text (Scryfall, verified verbatim +/// 2026-08-16) — the EFFECT layer's multi-pick random discard, and the only +/// selection mode besides the forced whole-hand branch that can lose cards to a +/// mid-batch pause. +const HYMN_TO_TOURACH: &str = "Target player discards two cards at random."; + +/// CR 701.9b + CR 616.1: an EFFECT-caused random discard that pauses on its +/// first pick must still make its second one. +/// +/// This is the random sibling of the forced whole-hand arm in +/// `windfall_greatest_discard_aggregate.rs`. The gate-2 `Moved` redirect fires +/// on every VICTIM card, so the batch pauses on pick 1, resumes, pauses again on +/// pick 2, and resumes — two prompts, two cards gone. +/// +/// FIXTURE NOTE (measured, not assumed). The redirect is narrowed with +/// `Not { SpecificObject { id: hymn } }` rather than left at `valid_card: None`. +/// An unnarrowed redirect also watches the SPELL's own CR 608.2n stack → +/// graveyard move, and that move happens while the first pick's choice is still +/// parked: `pending_replacement` is a single slot, so the spell's move overwrote +/// the victim's parked choice and the victim never left the hand. Measured on +/// this fixture at this tip — prompt #0's `pending_replacement` was +/// `ZoneChange { object_id: , from: Stack, to: Graveyard }`, and the +/// first-picked victim stayed in `Zone::Hand` for the rest of the game. The +/// overwrite was already in place at prompt #0, i.e. before any resume code +/// runs, so it is a single-slot `pending_replacement` defect independent of the +/// batch cursor this test covers. It is NOT repaired here; the fixture excludes +/// the spell instead of silently absorbing it. +/// +/// Discriminating: at the pre-fix tip the effect layer threw the returned cursor +/// away, so exactly ONE card left the hand and exactly ONE prompt was raised. +/// The prompt count is the reach guard — a run that raised no prompt never +/// exercised the pause path and could satisfy a bare zone check for the wrong +/// reason. +#[test] +fn effect_random_discard_finishes_its_batch_after_a_replacement_pause() { + let mut scenario = GameScenario::new(); + scenario.at_phase(engine::types::phase::Phase::PreCombatMain); + let hymn = scenario + .add_spell_to_hand_from_oracle(P0, "Hymn to Tourach", false, HYMN_TO_TOURACH) + .with_mana_cost(engine::types::mana::ManaCost::zero()) + .id(); + scenario + .add_creature(P1, "Graveyard Warden", 1, 1) + .with_replacement_definition(optional_graveyard_exile_replacement().valid_card( + TargetFilter::Not { + filter: Box::new(TargetFilter::SpecificObject { id: hymn }), + }, + )); + let hand: Vec = (0..4) + .map(|i| scenario.add_card_to_hand(P1, &format!("Victim Card {i}"))) + .collect(); + let mut runner = scenario.build(); + + runner.cast(hymn).target_player(P1).resolve(); + + let mut prompts = 0; + for _ in 0..16 { + let WaitingFor::ReplacementChoice { candidates, .. } = runner.state().waiting_for.clone() + else { + break; + }; + let decline = candidates + .iter() + .position(|c| c.description == "Decline") + .expect("a Decline option"); + prompts += 1; + runner + .act(GameAction::ChooseReplacement { index: decline }) + .expect("declining the redirect must be accepted"); + } + runner.advance_until_stack_empty(); + + assert_eq!( + (prompts, moved_out_of_hand(&runner, &hand)), + (2, 2), + "both random picks must be made across the pause (prompts, cards gone)" + ); +} diff --git a/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs b/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs index 4759520505..1368ee0b1a 100644 --- a/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs +++ b/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs @@ -21,10 +21,17 @@ //! set to a cross-player SUM, Windfall drew 8+7+3+3 = 21 for every player //! instead of the greatest single player's 8. -use engine::game::scenario::{GameScenario, Outcome, P0, P1}; +use engine::game::scenario::{GameRunner, GameScenario, Outcome, P0, P1}; +use engine::types::ability::{ + AbilityDefinition, AbilityKind, Effect, ReplacementDefinition, ReplacementMode, TargetFilter, +}; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::ObjectId; use engine::types::mana::ManaCost; use engine::types::phase::Phase; use engine::types::player::PlayerId; +use engine::types::replacements::ReplacementEvent; use engine::types::zones::Zone; const WINDFALL: &str = "Each player discards their hand, then draws cards equal to the greatest number of cards a player discarded this way."; @@ -405,3 +412,293 @@ fn windfall_short_library_does_not_shrink_later_players_draws() { "P0's short library caps only P0; every later player still draws the greatest discard (8)" ); } + +// --------------------------------------------------------------------------- +// The CR 616.1 pause arms. A replacement choice interrupts the discard fan-out +// mid-batch; the clause must still publish ONE complete per-player table. +// --------------------------------------------------------------------------- + +/// Library of Leng, verbatim Scryfall (re-fetched 2026-08-16 via +/// `curl -s 'https://api.scryfall.com/cards/named?exact=Library%20of%20Leng' | jq -r .oracle_text`). +/// +/// Line 2 parses to a `ReplacementEvent::Discard` definition in +/// `ReplacementMode::Optional` with `valid_card: Typed { controller: You }` +/// (`parser/oracle_replacement.rs`'s `parse_discard_to_library_top_replacement`), +/// so the engine raises an Accept/Decline prompt for its controller's discards +/// and for nobody else's. That is what makes this fixture's pause count +/// predictable: exactly one prompt per card P0 discards. +const LIBRARY_OF_LENG: &str = "You have no maximum hand size.\nIf an effect causes you to discard a card, discard it, but you may put it on top of your library instead of into your graveyard."; + +/// Hands beside Windfall. The MAXIMUM sits on P0 — the seat whose batch pauses — +/// so the aggregate is only correct if that seat is present AND complete in the +/// published table. Reference values over `{P0:7, P1:3, P2:5, P3:2}`: +/// MAX 7, MAX-without-P0 5, MAX-with-P0's-paused-card-uncounted 6, last-seat 2. +/// Four mutually distinct numbers, one per failure mode. +const PAUSED_HANDS: [usize; 4] = [7, 3, 5, 2]; + +fn seed_hand_ids(scenario: &mut GameScenario, player: PlayerId, n: usize) -> Vec { + (0..n) + .map(|i| scenario.add_card_to_hand(player, &format!("Hand Filler {player:?} {i}"))) + .collect() +} + +fn state_zone_len(runner: &GameRunner, player: PlayerId, zone: Zone) -> usize { + let p = runner + .state() + .players + .iter() + .find(|p| p.id == player) + .expect("player exists"); + match zone { + Zone::Hand => p.hand.len(), + Zone::Library => p.library.len(), + Zone::Graveyard => p.graveyard.len(), + other => panic!("state_zone_len does not cover {other:?}"), + } +} + +/// Answer every `ReplacementChoice` the board raises with the named option, +/// returning how many were answered. The count is the reach guard for every +/// assertion below: a run that raised no prompt never exercised the pause path +/// at all, and would pass a bare zone-count check for the wrong reason. +fn answer_every_replacement_choice(runner: &mut GameRunner, description: &str) -> usize { + for (prompts, _) in (0..64).enumerate() { + let WaitingFor::ReplacementChoice { candidates, .. } = runner.state().waiting_for.clone() + else { + return prompts; + }; + let index = candidates + .iter() + .position(|c| c.description == description) + .unwrap_or_else(|| { + panic!( + "no {description:?} option among {:?}", + candidates + .iter() + .map(|c| c.description.clone()) + .collect::>() + ) + }); + runner + .act(GameAction::ChooseReplacement { index }) + .expect("ChooseReplacement must be accepted"); + } + panic!("the replacement-choice loop never terminated"); +} + +/// Every observable of the paused clause, asserted as ONE value so a failure +/// prints the whole signature rather than the first divergent field. +#[derive(Debug, PartialEq, Eq)] +struct PausedFanOutSignature { + prompts: usize, + graveyards: Vec, + drawn: Vec, + hands: Vec, +} + +/// Rest in Peace class, made OPTIONAL so it surfaces an Accept/Decline choice. +/// Copied in shape from `random_discard_cost_replacement_resume.rs`'s +/// `optional_graveyard_exile_replacement`; narrowed per call site with +/// `valid_card`. +fn optional_graveyard_exile_replacement() -> ReplacementDefinition { + ReplacementDefinition::new(ReplacementEvent::Moved) + .destination_zone(Zone::Graveyard) + .mode(ReplacementMode::Optional { decline: None }) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChangeZone { + origin: None, + destination: Zone::Exile, + target: TargetFilter::SelfRef, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: engine::types::zones::EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + enters_modified_if: None, + face_down_profile: None, + }, + )) +} + +/// ARM A — gate 1 (`ReplacementEvent::Discard`, Library of Leng), the fan-out +/// discriminator. +/// +/// CR 616.1: P0 controls an optional discard replacement, so every one of P0's +/// seven discards raises a choice. CR 608.2f: the discard action is taken on +/// four players and cannot be processed simultaneously once it pauses, so it is +/// processed per player — but it is still ONE action, and the look-back +/// (CR 608.2i) that feeds the draw clause must see every seat's contribution. +/// +/// Discriminating: with `{P0:7, P1:3, P2:5, P3:2}` the correct MAX is 7, and 7 +/// is unreachable under every partial-table failure mode — a table missing P0 +/// yields 5, a table holding only the last resumed leg yields 2, and a table +/// where P0's paused card went uncounted yields 6. +/// +/// Reach guards, both inside the asserted signature: `prompts == 7` proves the +/// pause path really ran seven times (a zero-prompt run would trivially satisfy +/// a graveyard check), and `graveyards == [8, 3, 5, 2]` proves all four seats +/// discarded their whole hands. P0's 8 is seven discards PLUS Windfall itself: +/// CR 608.2n — "As the final part of an instant or sorcery spell's resolution, +/// the spell is put into its owner's graveyard." That is the same reason the +/// first test in this file asserts `>= 8` rather than `== 8`. +#[test] +fn windfall_paused_mid_fan_out_still_draws_the_greatest() { + let mut scenario = GameScenario::new_n_player(4, 42); + scenario.at_phase(Phase::PreCombatMain); + for (seat, hand) in SEATS.iter().zip(PAUSED_HANDS) { + seed_hand_ids(&mut scenario, *seat, hand); + seed_library(&mut scenario, *seat, LIBRARY_DEPTH); + } + let leng = scenario + .add_creature_from_oracle(P0, "Library of Leng", 1, 1, LIBRARY_OF_LENG) + .as_artifact() + .id(); + let windfall = scenario + .add_spell_to_hand_from_oracle(P0, "Windfall", false, WINDFALL) + .with_mana_cost(ManaCost::zero()) + .id(); + let mut runner = scenario.build(); + + // Fixture self-checks — the prop must be what the derivation assumes. + assert_eq!( + format!("{:?}", runner.state().objects[&leng].card_types.core_types), + "[Artifact]", + "the Leng prop must be an artifact, not a creature" + ); + assert_eq!( + runner.state().objects[&leng].replacement_definitions.len(), + 1, + "Library of Leng's second line must parse to exactly one replacement" + ); + + // The cast driver stops at the first ReplacementChoice it is not told how + // to answer; from there this test drives the prompts itself so it can count + // them. + runner.cast(windfall).resolve(); + let prompts = answer_every_replacement_choice(&mut runner, "Decline"); + runner.advance_until_stack_empty(); + + let observed = PausedFanOutSignature { + prompts, + graveyards: SEATS + .iter() + .map(|p| state_zone_len(&runner, *p, Zone::Graveyard)) + .collect(), + drawn: SEATS + .iter() + .map(|p| LIBRARY_DEPTH - state_zone_len(&runner, *p, Zone::Library)) + .collect(), + hands: SEATS + .iter() + .map(|p| state_zone_len(&runner, *p, Zone::Hand)) + .collect(), + }; + + assert_eq!( + observed, + PausedFanOutSignature { + prompts: 7, + graveyards: vec![8, 3, 5, 2], + drawn: vec![7, 7, 7, 7], + hands: vec![7, 7, 7, 7], + }, + "a CR 616.1 pause must not truncate the batch (prompts/graveyards) nor split \ + the clause's per-player table (drawn/hands)" + ); +} + +/// Every observable of the gate-2 arm, asserted as ONE value. +#[derive(Debug, PartialEq, Eq)] +struct GateTwoSignature { + prompts: usize, + p0_graveyard: usize, + redirected_card_zone: Zone, + exiled_total: usize, + drawn: Vec, +} + +/// ARM B — gate 2 (`ReplacementEvent::Moved` on the inner hand → graveyard +/// move), the discriminator for the paused card's OWN count. +/// +/// CR 614.6: a replaced event never happens; the modified event happens +/// instead — the card is still discarded (CR 701.9a) and must still be counted. +/// The gate-2 resume returns through terminal zone delivery, which emits no +/// `GameEvent::Discarded` for an unframed discard, so the paused card is the one +/// card that can silently vanish from the table even after the batch resumes. +/// +/// Discriminating: exactly one card in the game can prompt (`valid_card` is a +/// `SpecificObject`), the redirect is ACCEPTED, and P0's counted discards are 7 +/// while P0's graveyard tops out at 7 (six discards + Windfall) because the +/// seventh went to exile. If the paused card is uncounted the aggregate is 6, +/// not 7 — the only arm in this file that separates that facet from the batch +/// truncation arm above. +/// +/// Reach guards, inside the asserted signature: `prompts == 1` proves the pause +/// happened, and `redirected_card_zone == Exile` / `exiled_total == 1` prove the +/// redirect was actually applied (on a board where it silently did not apply, +/// the card would be in the graveyard and the count would be right for the +/// wrong reason). +#[test] +fn windfall_counts_a_card_redirected_out_of_the_graveyard_mid_batch() { + let mut scenario = GameScenario::new_n_player(4, 42); + scenario.at_phase(Phase::PreCombatMain); + let mut p0_hand = Vec::new(); + for (seat, hand) in SEATS.iter().zip(PAUSED_HANDS) { + let ids = seed_hand_ids(&mut scenario, *seat, hand); + if *seat == P0 { + p0_hand = ids; + } + seed_library(&mut scenario, *seat, LIBRARY_DEPTH); + } + let redirected = p0_hand[3]; + // Hosted on P1 so it cannot be confused with the discarding seat's own + // permanents; narrowed to a single card so the prompt count is exactly 1. + scenario + .add_creature(P1, "Graveyard Warden", 1, 1) + .with_replacement_definition( + optional_graveyard_exile_replacement() + .valid_card(TargetFilter::SpecificObject { id: redirected }), + ); + let windfall = scenario + .add_spell_to_hand_from_oracle(P0, "Windfall", false, WINDFALL) + .with_mana_cost(ManaCost::zero()) + .id(); + let mut runner = scenario.build(); + + runner.cast(windfall).resolve(); + let prompts = answer_every_replacement_choice(&mut runner, "Accept"); + runner.advance_until_stack_empty(); + + let observed = GateTwoSignature { + prompts, + p0_graveyard: state_zone_len(&runner, P0, Zone::Graveyard), + redirected_card_zone: runner.state().objects[&redirected].zone, + exiled_total: runner + .state() + .objects + .values() + .filter(|o| o.zone == Zone::Exile) + .count(), + drawn: SEATS + .iter() + .map(|p| LIBRARY_DEPTH - state_zone_len(&runner, *p, Zone::Library)) + .collect(), + }; + + assert_eq!( + observed, + GateTwoSignature { + prompts: 1, + p0_graveyard: 7, + redirected_card_zone: Zone::Exile, + exiled_total: 1, + drawn: vec![7, 7, 7, 7], + }, + "a card redirected out of the graveyard mid-batch was still discarded \ + (CR 614.6 + CR 701.9a) and must still be counted" + ); +} From a354c1ebbfccc68f43310344603923d83f31be04 Mon Sep 17 00:00:00 2001 From: lgray Date: Sun, 16 Aug 2026 23:08:43 -0500 Subject: [PATCH 11/26] docs(ai): strike a CR tag from a detection heuristic that implements no rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_previous_amount` carried `CR 120.10`. That rule governs excess damage dealt to a permanent and how triggered abilities checking for it are evaluated; it says nothing about amounts left by a preceding effect, the total channel, or aggregate-agnostic detection — which is what the comment actually asserts. Same class as the `CR 120.6` miscitation this branch already struck one crate over. The rationale is correct and is kept verbatim as an engine invariant. It is the annotation that does not belong: an AI scoring heuristic implements no game rule, so per the workspace convention it carries no CR tag at all. Pinned by an `include_str!` guard asserting the rationale survives and the annotation form does not, so a future edit cannot quietly restore the tag or drop the reasoning. Assisted-by: ClaudeCode:claude-opus-5 --- crates/phase-ai/src/policies/x_reference.rs | 51 +++++++++++++++++++-- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/crates/phase-ai/src/policies/x_reference.rs b/crates/phase-ai/src/policies/x_reference.rs index e221e73f74..50f4aa7a9c 100644 --- a/crates/phase-ai/src/policies/x_reference.rs +++ b/crates/phase-ai/src/policies/x_reference.rs @@ -354,11 +354,18 @@ fn is_cost_x_paid(qty: &QuantityRef) -> bool { } fn is_previous_amount(qty: &QuantityRef) -> bool { - // CR 120.10: both channels (total and excess) are amounts left by the - // preceding effect, so the AI's X-reference detection treats them alike — - // it cares that the value is chain-derived, not which tally it came from, - // and every aggregate reduces the same table, so the detection is - // aggregate-agnostic too. + // Both channels (total and excess) are amounts left by the preceding + // effect, so the AI's X-reference detection treats them alike — it cares + // that the value is chain-derived, not which tally it came from, and every + // aggregate reduces the same table, so the detection is aggregate-agnostic + // too. + // + // The former CR 120.10 tag is STRUCK, not relocated. Read in full, that + // rule scopes triggered abilities that check whether a permanent has been + // dealt EXCESS DAMAGE; it says nothing about amounts one effect leaves for + // the next, and nothing about aggregate-agnostic detection. An AI scoring + // heuristic implements no game rule and needs no CR annotation. The + // rationale above is kept verbatim. matches!(qty, QuantityRef::PreviousEffectAmount { .. }) } @@ -408,3 +415,37 @@ fn filter_prop_references_x(prop: &FilterProp) -> bool { _ => false, } } + +#[cfg(test)] +mod tests { + /// The `CR 120.10` strike on `is_previous_amount` is load-bearing, so it is + /// asserted rather than left to review. Read in full, CR 120.10 governs + /// triggered abilities that check whether a permanent has been dealt excess + /// damage — it does not govern "amounts left by the preceding effect", and + /// an AI scoring heuristic implements no game rule at all. + /// + /// Reads this file's own source so the assertion is about the annotation as + /// shipped, not about a value re-derived from it. + /// + /// REVERT PROBE (RUN, not reasoned): restore the tag, i.e. change the + /// comment's first line back to `// CR 120.10: both channels (total and + /// excess) are amounts left by`. Observed failure — "an AI scoring heuristic + /// implements no game rule, so it carries no CR annotation". + #[test] + fn previous_amount_detection_carries_its_rationale_without_a_cr_tag() { + let source = include_str!("x_reference.rs"); + let start = source + .find("fn is_previous_amount(") + .expect("the detection helper exists"); + let body = &source[start..start + 900]; + + assert!( + body.contains("chain-derived"), + "the rationale for treating both channels alike must survive the strike" + ); + assert!( + !body.contains("CR 120.10:"), + "an AI scoring heuristic implements no game rule, so it carries no CR annotation" + ); + } +} From 137a405d6c381e8f7c6c8a4711b27eecb5d6504f Mon Sep 17 00:00:00 2001 From: lgray Date: Sun, 16 Aug 2026 23:43:00 -0500 Subject: [PATCH 12/26] docs(engine): say what CR 608.2i's exception actually exempts CR 608.2i ends "This is an exception to 608.2h", and review read that as exempting a look-back from the snapshot rule outright -- which would make freezing `PreviousEffectAmount` contradictory rather than correct. Read in full, the exception is scoped to two things, both about objects: they "don't need to be currently in the zone" they were in, "nor do they need to currently meet the criteria described in the action". It relaxes where the objects must be standing, not when the number is determined, so CR 608.2h's "determined only once, when the effect is applied" still governs the value. The clause-snapshot doc already named all three rules but never said this, so the objection had nothing in-tree to answer it. Doc-only; no code change. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/types/game_state.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 50f83785fe..681dee9b13 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -14640,6 +14640,20 @@ impl StackEntryKind { /// instruction's result); the per-player `left` operand still re-resolves per /// iteration, which is correct. /// +/// CR 608.2i ends "This is an exception to 608.2h", which invites the reading +/// that a look-back is exempt from the snapshot rule outright. It is not, and +/// the distinction is what makes freezing `PreviousEffectAmount` correct rather +/// than contradictory. Read in full, the exception is scoped to two things, +/// both about **objects**: they "don't need to be currently in the zone" they +/// were in, "nor do they need to currently meet the criteria described in the +/// action". It relaxes WHERE the objects must be standing; it says nothing +/// about WHEN the number is determined. So 608.2h's "determined only once, when +/// the effect is applied" still governs the value — which is precisely the rule +/// a per-seat re-read violates. Were it otherwise, the pre-fix behaviour (every +/// seat re-stamping the shared scalar as its own draw completed) would have +/// been correct, and Windfall would rightly pay out the last discard rather +/// than the greatest. +/// /// Transient — never serialized. Captured before a `player_scope` link's /// fan-out and cleared when the link completes, so the next clause re-enters /// the driver with `None` and re-captures against the post-clause board. From 8cf86193d4440b90a4f64c9a407c5b615a7943e4 Mon Sep 17 00:00:00 2001 From: lgray Date: Mon, 17 Aug 2026 00:39:19 -0500 Subject: [PATCH 13/26] fix(engine): let the authority publish the replacement chooser, and refuse to discard a card that is not in hand Three defects surfaced by independent implementation review of the paused discard batch. All are in this PR's own new code. The `Random` cursor's re-park re-derived the CR 616.1 chooser as `batch.player` instead of threading the seat `discard_at_random` had just computed, while the `All` arm 30 lines above threads its own correctly. Benign today, because a hand card's affected player is its controller -- but `replacement_choice_player`'s commander carve-out proves the engine already has chooser != affected-seat cases, and the moment one reaches a random discard the wrong seat is prompted. `RandomDiscardOutcome::NeedsReplacementChoice` now carries `chooser`, so both cursor arms read one contract and no call site re-derives it. The two cost-layer destructures take it as `_`: that layer never re-parks, so it has no prompt to keep in step. `route_discard` -- the single chokepoint every discard routes through, effect and cost, whole-hand and random -- now returns early when the card is not in a hand. CR 701.9a defines discarding as a move from hand to graveyard, so there is no event to propose. This became load-bearing with the parked batch: a cursor is a hand snapshot latched before an action boundary and drained after one, and `complete_discard_to_graveyard` lowers to a hard-coded `from: Hand`, so a card that moved in between would have been "discarded" out of whatever zone it now occupies. Un-paused callers build and consume their snapshot inside one action and cannot observe a difference. CR 800.4a: a seat that has left the game is dropped from the discard fan-out's not-yet-prompted roster, the same treatment `pending_scoped_library_search` already gets. `matching_players` is deliberately left whole -- CR 608.2f latches the reduction domain when the action begins being processed per subject, so a departed seat still contributes its truthful zero and pruning it would silently change a `Min` answer. The CR 603.5 prompt census pin moves `:10798 => :10803`, third producer only, re-derived by content after the last edit; the CR733 row's three reroute coordinates are re-derived from a fresh `cr733_mutation_census.py` run. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/effects/discard.rs | 70 ++++++++++++++++++ crates/engine/src/game/effects/mod.rs | 11 ++- crates/engine/src/game/elimination.rs | 16 ++++ crates/engine/src/game/engine.rs | 10 ++- .../engine/src/game/engine_payment_choices.rs | 7 +- .../fixtures/cr733/authority_matrix.json.gz | Bin 42075 -> 42074 bytes 6 files changed, 109 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/game/effects/discard.rs b/crates/engine/src/game/effects/discard.rs index bf2cc92228..c5be233681 100644 --- a/crates/engine/src/game/effects/discard.rs +++ b/crates/engine/src/game/effects/discard.rs @@ -404,6 +404,24 @@ pub fn resolve( events, ) { + // SAME SHAPE, NOT YET REPAIRED. Like the + // whole-hand loop before `park_discard_batch`, + // this exits mid-list with no cursor, so the + // untouched targets are never discarded and no + // terminal `EffectResolved` is emitted. It is + // NOT parked here because a specific-target + // list needs a different provenance contract: + // the whole-hand cursor's remainder is any + // subset of one seat's hand and is order-free, + // while this one must preserve the ANNOUNCED + // target list and its order. Parking it under + // the hand-shaped cursor would silently discard + // the wrong cards. (Deliberately uncited: the + // target-legality rule CR 608.2b runs once, as + // the spell begins to resolve, so it does not + // govern a mid-resolution resume. Whatever + // contract this needs must be derived, not + // borrowed.) Tracked with the rest of the class. state.waiting_for = crate::game::replacement::replacement_choice_waiting_for( player, state, @@ -447,6 +465,9 @@ pub fn resolve( } } ReplacementResult::NeedsChoice(player) => { + // Same un-parked mid-list bail-out as the arm above; see the + // provenance-contract note there for why the specific-target + // list is not carried by the hand-shaped cursor. state.waiting_for = crate::game::replacement::replacement_choice_waiting_for(player, state); return Ok(()); @@ -499,6 +520,11 @@ pub fn resolve( remaining_eligible, remaining_count, paused_card, + // `discard_at_random` already set `waiting_for` from this value + // and this path parks without re-setting it, so there is + // nothing here to keep in step. The drain that RE-parks does + // consume it. + chooser: _, } = discard_at_random( state, RandomDiscardRequest { @@ -697,6 +723,16 @@ pub(crate) enum RandomDiscardOutcome { /// identity to stamp the terminal `Discarded` the resumed zone-change /// arm cannot emit. The cost layer does not consume it. paused_card: ObjectId, + /// CR 616.1: the player who chooses among the applicable replacement + /// effects. Published by this authority rather than re-derived at the + /// call site, because it is NOT always the discarding player — see the + /// commander carve-out in `replacement_choice_player`, where the choice + /// belongs to a seat other than the affected one. A re-parking caller + /// that assumed `request.player` would prompt the wrong seat the moment + /// such a case reaches a random discard. Mirrors the `chooser` the + /// single-card `DiscardOutcome::NeedsReplacementChoice` already carries, + /// so both cursor arms read one contract. + chooser: PlayerId, }, } @@ -783,6 +819,9 @@ pub(crate) fn discard_at_random( // resumed batch owes only the picks after it. remaining_count: count - pick - 1, paused_card: obj_id, + // Same value this function just set `waiting_for` from, so a + // re-parking caller cannot drift from the prompt actually shown. + chooser, }; } } @@ -817,6 +856,22 @@ fn route_discard( discard_frame: Option, events: &mut Vec, ) -> DiscardOutcome { + // CR 701.9a: "To discard a card, move it from its owner's hand to that + // player's graveyard." A card that is not in a hand cannot be discarded, so + // there is no event to propose. + // + // This is the single chokepoint every discard routes through — effect and + // cost layers, whole-hand and random cursors — so the guard belongs here + // rather than at each caller. It became load-bearing with the parked batch: + // a cursor is a hand snapshot latched BEFORE an action boundary, and it is + // drained after one, so anything that moved a listed card in between would + // otherwise be "discarded" out of whatever zone it now occupies — + // `complete_discard_to_graveyard` lowers to a hard-coded `from: Hand`. + // Un-paused callers build and consume their snapshot inside one action and + // cannot observe a difference, so this narrows nothing that works today. + if state.objects.get(&object_id).map(|obj| obj.zone) != Some(Zone::Hand) { + return DiscardOutcome::Complete; + } let proposed = ProposedEvent::Discard { player_id: player, object_id, @@ -1127,6 +1182,7 @@ mod random_discard_authority_tests { remaining_eligible, remaining_count, paused_card, + chooser, } = outcome else { panic!("expected a replacement pause, got {outcome:?}"); @@ -1146,6 +1202,20 @@ mod random_discard_authority_tests { hand.contains(&paused_card) && !remaining_eligible.contains(&paused_card), "the paused card must be a hand card that left the un-picked pool" ); + // CR 616.1: the published chooser must be the seat this authority + // actually prompted. A re-parking caller reads `chooser` to rebuild the + // prompt, so if the two ever disagree the wrong seat is asked. Compared + // against `waiting_for` rather than against the request's player, + // because agreeing with the request is the very assumption this pins + // against — the drain used to re-derive it that way. + let prompted = match &state.waiting_for { + crate::types::game_state::WaitingFor::ReplacementChoice { player, .. } => *player, + other => panic!("expected an installed ReplacementChoice, got {other:?}"), + }; + assert_eq!( + chooser, prompted, + "the outcome's chooser must equal the seat `waiting_for` was built from" + ); } /// Caller contract (documented on the authority): a pool shorter than diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 18da0fd893..a8a30d8332 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -9239,6 +9239,7 @@ pub(crate) fn drain_pending_discard_batch( remaining_eligible, remaining_count, paused_card, + chooser, } = discard::discard_at_random( state, discard::RandomDiscardRequest { @@ -9256,9 +9257,13 @@ pub(crate) fn drain_pending_discard_batch( remaining: remaining_count, }; batch.paused_card = paused_card; - // `discard_at_random` already set `state.waiting_for`; pass the - // seat that is choosing so the re-park helper reads one contract. - let chooser = batch.player; + // CR 616.1: the chooser comes from the authority that raised + // the choice, exactly as the `All` arm above threads its own. + // It was `batch.player` here, which happens to agree today + // because a hand card's `affected_player` is its controller — + // but `replacement_choice_player`'s commander carve-out proves + // the engine already has cases where chooser != affected seat, + // and re-deriving at the call site is how those drift. repark_discard_batch(state, batch, events, chooser); return Ok(PendingDiscardBatchOutcome::PausedForReplacement); } diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index 2e07be8160..2e10b201f4 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -913,6 +913,22 @@ fn do_eliminate( } } } + // CR 800.4a: a seat that has left cannot be iterated, so drop it from the + // discard fan-out's not-yet-prompted roster — the same treatment the + // scoped-library-search roster above already gets. + // + // `matching_players` is deliberately NOT pruned. CR 608.2f latches the + // clause's reduction domain when the action begins being processed per + // subject, so a seat that leaves mid-fan-out still contributes its truthful + // zero to the terminal zero-fill. Pruning it would silently change a `Min` + // aggregate's answer, which is the opposite of what this repair is for. + if let Some(fan_out) = state + .pending_discard_batch + .as_mut() + .and_then(|batch| batch.fan_out.as_mut()) + { + fan_out.remaining_players.retain(|seat| *seat != player); + } if let Some(crate::types::game_state::PendingBatchDeliveries { completion: Some(crate::types::game_state::BatchCompletion::LibrarySearchDeliverySettled { diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index d98e685879..9ddb386a55 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -19111,9 +19111,17 @@ mod stage2_injector_tests { // both fired GREEN on the run that caught this (partition still 5/8/28), the // other two effects/mod.rs entries sit ABOVE every hunk and did not move, and // neither `scoped_library_search.rs` nor this file's own producer was touched. + // + // Impl-review fix round: `:10798 ⇒ :10803` (+5), third producer + // only once more — the `Random` re-park's CR 616.1 chooser note + // sits between the second and third producers. Window + // `d7fd67fd…` unchanged at the new coordinate, both neighbours + // differing as controls. Re-measured AFTER `cargo fmt` and after + // the LAST edit of the round, per this log's own rule that a pin + // is only valid against the tree actually committed. "game/effects/mod.rs:7061".to_string(), "game/effects/mod.rs:7138".to_string(), - "game/effects/mod.rs:10798".to_string(), + "game/effects/mod.rs:10803".to_string(), // UNMOVED across the rebase, and that is itself evidence the SET did not // move: a census that had gained or lost a producer would not leave this // entry both byte-identical AND at the same coordinate. diff --git a/crates/engine/src/game/engine_payment_choices.rs b/crates/engine/src/game/engine_payment_choices.rs index de97fd3ff9..c6445b35ee 100644 --- a/crates/engine/src/game/engine_payment_choices.rs +++ b/crates/engine/src/game/engine_payment_choices.rs @@ -942,6 +942,10 @@ pub(super) fn handle_unless_payment( // payment publishes no such ledger, so this caller // has nothing to do with it. paused_card: _, + // Likewise effect-layer: `discard_at_random` already + // set `waiting_for` from this seat, and this caller + // never re-parks, so it has no prompt to keep in step. + chooser: _, } => { state.pending_cost_move_resume = Some(PendingCostMoveResume::RandomDiscardUnlessPayment(Box::new( @@ -2120,8 +2124,9 @@ pub(super) fn resume_random_discard_unless_payment( crate::game::effects::discard::RandomDiscardOutcome::NeedsReplacementChoice { remaining_eligible, remaining_count, - // Effect-layer field — see the sibling site above. + // Effect-layer fields — see the sibling site above. paused_card: _, + chooser: _, } => { state.pending_cost_move_resume = Some(PendingCostMoveResume::RandomDiscardUnlessPayment(Box::new( diff --git a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz index a1cd5f1beb4bea28d611d34cce8aafb46b6010c9..b18f56563d6a7a63709d7678ebd29e8087424459 100644 GIT binary patch delta 18615 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 delta 18616 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 P{*V73R*oR>hm!^XUH(&Z From 48c73edde9ae2576bc3cdf24295c4c4de458227f Mon Sep 17 00:00:00 2001 From: lgray Date: Mon, 17 Aug 2026 00:39:40 -0500 Subject: [PATCH 14/26] test(engine): make three pins discriminate the thing their messages claim Independent implementation review found one test arm that could not fail for the reason it named, one guard that a single character would slip past, and one deliberate asymmetry with nothing pinning it. `absent_pending_discard_batch_deserializes_as_none` built its fixture at `GameState::new_two_player`, whose `waiting_for` is already `Priority`, then asserted `Priority` after the round trip under the message "the restored state machine is intact, not orphaned mid-pause". It restated an input property. The save is now taken genuinely mid-pause -- a CR 616.1 `ReplacementChoice` is installed before serializing, with a reach guard asserting the input does not already satisfy the property -- and the assertion is that a batch-less mid-pause save round-trips its prompt VERBATIM, so the inconsistency stays observable to a caller instead of being silently rewritten into a plausible-looking state. The second revert probe records why the obvious "repair" would be wrong. The CR 120.10 strike guard asserted on `"CR 120.10:"`, so a re-added `// CR 120.10 both channels ...` without the colon would have passed. It now matches the annotation form -- a comment line whose first token is the citation -- which is also why a bare substring test cannot be used: the same window deliberately contains the prose recording that the tag was struck. The two adjacent post-replacement drains publish completion differently on purpose: sacrifice stamps `ThisWayCause::Sacrificed`, the discard drain 25 lines below stamps nothing, because the un-paused discard path does not stamp either and a stamping resume would give a paused discard provenance its own un-paused twin never has. Nothing pinned that. It is pinned as a source census rather than a behavioural assertion, and that is measured rather than lazy: `stamp_active_player_action_completion` early-returns without a `CompletePlayerAction` continuation frame, which a drain unit test does not have, so a behavioural assertion there would itself be vacuous. The census carries its own positive control -- half (a) proves the scan reaches a region that does stamp, so half (b)'s zero cannot be a scan that missed. Also records the scope of the CR 616.1 citations on `PendingDiscardBatch`: 616.1 governs the two-or-more-applicable case, while every pause this type carries in practice is the engine's apply-or-decline prompt for a single optional replacement, which 616.1 does not describe. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/engine_replacement.rs | 57 ++++++++++++++++++++ crates/engine/src/types/game_state.rs | 53 +++++++++++++++++- crates/phase-ai/src/policies/x_reference.rs | 11 +++- 3 files changed, 118 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index 26e0123ea4..fca5ac89a3 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -7673,4 +7673,61 @@ mod tests { "half (iii)'s own positive control: with no dispatch live the same direct call retires it" ); } + + /// CR 701.9a vs CR 701.21a: the two adjacent post-replacement drains in + /// `handle_choose_replacement` publish their completion DIFFERENTLY, and the + /// asymmetry is deliberate. The sacrifice drain stamps + /// `ThisWayCause::Sacrificed`; the discard drain immediately below stamps + /// nothing, because the UN-paused discard path does not stamp either + /// (`grep stamp_active_player_action_completion effects/discard.rs` → 0), so + /// a stamping resume would give a paused discard a provenance its own + /// un-paused twin never has. + /// + /// Nothing else pins this. A reader "fixing the inconsistency" between two + /// blocks twenty-five lines apart would silently change `Discarded`-this-way + /// provenance for the whole class. + /// + /// This is a SOURCE census rather than a behavioural assertion, and that is + /// a measured choice, not a shortcut: `stamp_active_player_action_completion` + /// early-returns unless an active ability continuation frame holds a + /// `CompletePlayerAction` chain, so in a drain unit test — which has no such + /// frame — adding the call would change no observable state. A behavioural + /// assertion there would be vacuous. The census is discriminating because it + /// carries its own positive control: half (a) proves the scan reaches a + /// region that DOES stamp, so half (b)'s zero cannot be a scan that missed. + /// + /// REVERT PROBES: + /// * add a `stamp_active_player_action_completion(… ThisWayCause::Discarded …)` + /// call to the discard `Completed` arm → half (b) fails. + /// * delete the sacrifice arm's existing stamp → half (a) fails, proving + /// the slicing is anchored on real code and not on absent text. + #[test] + fn the_resumed_discard_drain_does_not_stamp_a_completion_while_its_sacrifice_sibling_does() { + let source = include_str!("engine_replacement.rs"); + let needle = concat!("stamp_active_player_action_", "completion("); + + let sacrifice_arm = source + .split_once("PendingPlayerScopeSacrificeOutcome::Completed {") + .expect("the sacrifice drain's Completed arm exists") + .1; + let sacrifice_arm = &sacrifice_arm[..sacrifice_arm + .find("PendingDiscardBatchOutcome::Idle") + .expect("the discard drain follows the sacrifice drain in this function")]; + assert!( + sacrifice_arm.contains(needle), + "(a) positive control: the sacrifice arm must stamp, or this scan is reading the wrong region and (b)'s zero would mean nothing" + ); + + let discard_arm = source + .split_once("PendingDiscardBatchOutcome::Completed =>") + .expect("the discard drain's Completed arm exists") + .1; + let discard_arm = &discard_arm[..discard_arm + .find("drain_pending_continuation") + .expect("the discard arm runs the parked continuation")]; + assert!( + !discard_arm.contains(needle), + "(b) the resumed discard must publish exactly what the un-paused discard publishes — no CompletePlayerAction stamp" + ); + } } diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 681dee9b13..fb287b573e 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -4062,6 +4062,20 @@ pub enum PendingPlayerScopeSacrificeFollowUp { /// that type first — every mechanism here is its, with the two deliberate /// divergences noted on `preceding_events` and in `drain_pending_discard_batch`. /// +/// SCOPE OF THE CR 616.1 CITATIONS HERE, stated because a reader who looks the +/// rule up will otherwise find it describing a case the fixtures never hit. +/// CR 616.1 governs the two-or-more-applicable case: "If **two or more** +/// replacement and/or prevention effects are attempting to modify the way an +/// event affects an object or player, the affected object's controller … or +/// the affected player chooses one to apply." The engine ALSO surfaces a +/// `ReplacementChoice` prompt for a *single* `ReplacementMode::Optional` +/// replacement — apply-or-decline — which 616.1 does not describe. Every pause +/// this type carries is of that second kind in practice (the Library of Leng +/// arm's seven prompts all come from one optional replacement). 616.1 is cited +/// throughout for the choice MECHANISM and its APNAP ordering, which both kinds +/// share; CR 614.6 is what says a replaced event never happens and a modified +/// one happens instead. +/// /// PERSISTENCE ASYMMETRY, stated because it will otherwise mis-triage a bug /// report: this field IS serialized, while `GameState::clause_minimum_snapshot` /// — the CR 608.2h freeze the resumed draw clause reads — is `#[serde(skip)]`. @@ -25777,6 +25791,25 @@ mod tests { /// the `expect` below — which is also why `default` must NOT be sold as /// insurance for that change. On a non-`Option` field it would fabricate a /// `Default` for a missing key instead of failing loudly. + /// The state-machine half is pinned against a save taken GENUINELY + /// mid-pause. An earlier revision built this fixture at + /// `GameState::new_two_player`, whose `waiting_for` is already `Priority`, + /// and then asserted `Priority` after the round trip — restating an input + /// property, so it could not fail for the reason its message named. The + /// prompt below is therefore installed before serializing, which is the + /// only shape in which "the batch is missing but the prompt says paused" + /// can arise at all. + /// + /// REVERT PROBES: + /// * change `waiting_for` back to the default `Priority` before + /// serializing → the `ReplacementChoice` assertion below stops + /// discriminating (it passes for the wrong reason); the `matches!` on + /// the restored prompt fails outright, which is what makes the + /// mid-pause input load-bearing rather than decorative. + /// * add a load-time "repair" that resets `waiting_for` to `Priority` + /// when `pending_discard_batch` is absent → the same assertion fails. + /// That repair would be WRONG: it silently discards a real prompt and + /// converts a detectable inconsistency into a plausible-looking state. #[test] fn absent_pending_discard_batch_deserializes_as_none() { let mut state = GameState::new_two_player(42); @@ -25784,6 +25817,17 @@ mod tests { let record = persisted_zone_change_record(ObjectId(9_101), 19, 0); state.zone_changes_this_turn.push_back(record.clone()); state.pending_discard_batch = Some(parked_discard_batch(record)); + // CR 616.1: the prompt a parked batch is waiting on. Without it the + // save is not mid-pause and this test measures nothing. + state.waiting_for = WaitingFor::ReplacementChoice { + player: PlayerId(0), + candidate_count: 2, + candidates: Vec::new(), + }; + assert!( + !matches!(state.waiting_for, WaitingFor::Priority { .. }), + "reach guard: the INPUT must not already satisfy the property under test" + ); let mut wire = serde_json::to_value(&state).expect("fixture serializes"); assert!( @@ -25798,8 +25842,13 @@ mod tests { serde_json::from_value(wire).expect("an absent parked batch defaults to None"); assert!(restored.pending_discard_batch.is_none()); assert!( - matches!(restored.waiting_for, WaitingFor::Priority { .. }), - "the restored state machine is intact, not orphaned mid-pause" + matches!( + restored.waiting_for, + WaitingFor::ReplacementChoice { player, .. } if player == PlayerId(0) + ), + "a batch-less mid-pause save must round-trip its prompt VERBATIM, so the \ + inconsistency stays observable to a caller instead of being silently \ + rewritten into a plausible-looking state" ); } diff --git a/crates/phase-ai/src/policies/x_reference.rs b/crates/phase-ai/src/policies/x_reference.rs index 50f4aa7a9c..4e3b06be3e 100644 --- a/crates/phase-ai/src/policies/x_reference.rs +++ b/crates/phase-ai/src/policies/x_reference.rs @@ -443,8 +443,17 @@ mod tests { body.contains("chain-derived"), "the rationale for treating both channels alike must survive the strike" ); + // Matched on the ANNOTATION FORM, not on one punctuation variant: an + // earlier revision asserted only on `"CR 120.10:"`, which a re-added + // `// CR 120.10 both channels …` (no colon) would have slipped past — + // while the surrounding window deliberately contains the prose "The + // former CR 120.10 tag is STRUCK", so a bare substring test cannot be + // used either. Any comment line whose first token after `//` is the + // citation is a restored annotation. assert!( - !body.contains("CR 120.10:"), + !body + .lines() + .any(|line| line.trim_start().starts_with("// CR 120.10")), "an AI scoring heuristic implements no game rule, so it carries no CR annotation" ); } From 7308889526920eb0646f691c64f89fa926062803 Mon Sep 17 00:00:00 2001 From: lgray Date: Mon, 17 Aug 2026 01:02:28 -0500 Subject: [PATCH 15/26] fix(engine): widen a census window that guarded a line and a half, and retire two CR stretches Round 2 of independent review, on the previous round's own fixes. Four of six held; these are the two that did not, plus three annotation corrections. The drain-parity census sliced each match arm between two guessed markers, and the end marker for the second window sat INSIDE the arm: the "guarded" region was 36 characters of a five-line arm, so its named revert probe only flipped if a stamp landed as the arm's very first statement. Both windows are now closed by brace balance, each anchor must match exactly once (so a deleted arm cannot let the scan slide onto other text -- the doc comment no longer spells an anchor literally), and each window asserts its OWN non-degeneracy. That last one is the real lesson: the positive control proves the SACRIFICE region is real and says nothing about the extent of the DISCARD window, and the discard window is the one whose zero carries the claim. A positive control on region A does not license a negative on region B. Measured after: 281 chars / 6 lines, up from 36 chars / 1 line, and it now contains the arm's last statement. The mid-pause save/load test deserialized with bare `from_value::`, bypassing `PersistedGameState::into_game_state()` -- which is where this repo puts load-time repairs, and therefore the only door the "helpful" repair its second revert probe warns about would ever come through. It now loads through that chokepoint, and asserts `candidate_count` as well as the variant and player, since a prompt rebuilt with different contents would otherwise pass. Two CR stretches retired, both the class caught earlier with CR 608.2b. CR 608.2f does not latch a reduction domain -- read in full it is simultaneity and APNAP ORDER, and both its examples are about ordering; the honest justification for leaving `matching_players` whole is PARITY with the un-paused driver, which also computes its domain once and never re-derives it, with CR 800.4i ("the effect uses the last known information about that player before they left the game") making the retained seat well-defined. And CR 800.4a is cited now only for what it says -- objects owned by a departing player leave the game, so a departed seat has no hand to discard. The CR 701.9a guard now retires the discard frame, exactly as the `Prevented` arm it is modelled on does; without that a `DiscardedCardMatchesFilter` frame leaks when every listed card has already moved. Its `Complete` return is imprecise on cost paths, but that imprecision is inherited from `Prevented` rather than introduced here, and the shape is recorded in place for whoever next touches `DiscardOutcome`. The comment's "single chokepoint every discard routes through" was false and is corrected: three callers reach `complete_discard_to_graveyard` directly, as resumes of an already-guarded proposal. Also: the prompt-census window digests now state the exact command that reproduces them. Review could not reproduce them from the obvious guesses, and a digest a reader cannot recompute is decoration rather than evidence. Verified: the documented rule reproduces all three and their off-by-one controls. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/effects/discard.rs | 40 ++++-- crates/engine/src/game/elimination.rs | 26 ++-- crates/engine/src/game/engine.rs | 12 ++ crates/engine/src/game/engine_replacement.rs | 118 +++++++++++++----- crates/engine/src/types/game_state.rs | 58 ++++++--- .../fixtures/cr733/authority_matrix.json.gz | Bin 42074 -> 42073 bytes 6 files changed, 191 insertions(+), 63 deletions(-) diff --git a/crates/engine/src/game/effects/discard.rs b/crates/engine/src/game/effects/discard.rs index c5be233681..71cfd6d4f0 100644 --- a/crates/engine/src/game/effects/discard.rs +++ b/crates/engine/src/game/effects/discard.rs @@ -860,16 +860,38 @@ fn route_discard( // player's graveyard." A card that is not in a hand cannot be discarded, so // there is no event to propose. // - // This is the single chokepoint every discard routes through — effect and - // cost layers, whole-hand and random cursors — so the guard belongs here - // rather than at each caller. It became load-bearing with the parked batch: - // a cursor is a hand snapshot latched BEFORE an action boundary, and it is - // drained after one, so anything that moved a listed card in between would - // otherwise be "discarded" out of whatever zone it now occupies — - // `complete_discard_to_graveyard` lowers to a hard-coded `from: Hand`. - // Un-paused callers build and consume their snapshot inside one action and - // cannot observe a difference, so this narrows nothing that works today. + // Placed here because every *proposed* discard routes through this function + // — effect and cost layers, whole-hand and random cursors — so one guard + // covers them all. (Not the same as every discard: three callers reach + // `complete_discard_to_graveyard` directly, at `:397` here and in + // `engine_replacement.rs` / `engine_payment_choices.rs`. Those are RESUMES of + // an event this function already proposed and guarded, which is why they are + // not a hole — but the claim is "every proposal", not "every discard".) + // + // It became load-bearing with the parked batch: a cursor is a hand snapshot + // latched BEFORE an action boundary and drained after one, so anything that + // moved a listed card in between would otherwise be "discarded" out of + // whatever zone it now occupies — `complete_discard_to_graveyard` lowers to + // a hard-coded `from: Hand`. Un-paused callers build and consume their + // snapshot inside one action and cannot observe a difference. + // + // Modelled EXACTLY on the `Prevented` arm below, deliberately: that arm is + // this file's existing answer to "the card never left the hand, so no + // discard occurred", and it retires the discard frame and reports + // `Complete`. Retiring matters — a `DiscardedCardMatchesFilter` frame left + // active would leak when every listed card has already moved. + // + // `Complete` is a known imprecision INHERITED from that arm, not introduced + // here: `DiscardOutcome` has no "nothing happened" variant, so a cost caller + // reads `Complete` as paid. A prevented discard already launders an unpayable + // cost the same way (CR 118.3 wants all-or-nothing). Fixing it means a third + // variant threaded through every caller, which is a change this PR has no + // mandate for and no test for; the shape is recorded here rather than in a + // commit message so the next person to touch `DiscardOutcome` finds it. if state.objects.get(&object_id).map(|obj| obj.zone) != Some(Zone::Hand) { + if let Some(frame_id) = discard_frame { + retire_discard_frame(state, frame_id); + } return DiscardOutcome::Complete; } let proposed = ProposedEvent::Discard { diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index 2e10b201f4..3676a8f8c5 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -913,15 +913,25 @@ fn do_eliminate( } } } - // CR 800.4a: a seat that has left cannot be iterated, so drop it from the - // discard fan-out's not-yet-prompted roster — the same treatment the - // scoped-library-search roster above already gets. + // CR 800.4a: "all objects … owned by that player leave the game", so a + // departed seat has no hand left to discard and iterating it can only be a + // no-op. Drop it from the discard fan-out's not-yet-prompted roster — the + // same treatment the scoped-library-search roster above already gets. // - // `matching_players` is deliberately NOT pruned. CR 608.2f latches the - // clause's reduction domain when the action begins being processed per - // subject, so a seat that leaves mid-fan-out still contributes its truthful - // zero to the terminal zero-fill. Pruning it would silently change a `Min` - // aggregate's answer, which is the opposite of what this repair is for. + // `matching_players` is deliberately NOT pruned, and the reason is PARITY + // rather than a rule: the un-paused driver computes its reduction domain + // once at clause entry and never re-derives it, so a paused clause that + // pruned would answer differently from an identical unpaused one — which is + // precisely the divergence this repair exists to remove. CR 800.4i is what + // makes the retained seat well-defined: "the effect uses the last known + // information about that player before they left the game." The seat's + // truthful contribution is zero, and dropping it would silently change a + // `Min` answer. + // + // (Deliberately NOT cited: CR 608.2f, which an earlier revision leaned on. + // Read in full it is about simultaneity and APNAP ORDER — it latches no + // domain, and both its examples are about ordering. Same class of stretch as + // the CR 608.2b citation removed from `discard.rs`.) if let Some(fan_out) = state .pending_discard_batch .as_mut() diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 9ddb386a55..3b208c8adf 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -19026,6 +19026,18 @@ mod stage2_injector_tests { // Coordinates located by CONTENT in THIS worktree, never by indexing a // line of `upstream/main` — that ref moves, and using it as a coordinate // origin once produced a phantom 79-line discrepancy in this very lane. + // + // WINDOWING RULE, stated because a digest a reader cannot recompute is + // decoration, not evidence (review could not reproduce these from the + // obvious guesses): for a producer at line N in `effects/mod.rs`, the + // window is the inclusive 41-line span N-20..N+20, hashed raw and + // untrimmed, i.e. exactly + // sed -n "$((N-20)),$((N+20))p" crates/engine/src/game/effects/mod.rs \ + // | sha256sum | cut -c1-8 + // The off-by-one control is the same span shifted by one line. This is + // THIS branch's convention and is not main's — the `9869a19f…` triple + // recorded above spans differently, so the two sets are not comparable. + // // Re-measured at the rebased coordinates rather than carried forward: the // 41-line window centred on each producer hashes to `ad615ce4…`, // `a958f070…`, `d7fd67fd…` — byte-for-byte the pre-rebase triple — with the diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index fca5ac89a3..534f32aceb 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -7679,7 +7679,7 @@ mod tests { /// asymmetry is deliberate. The sacrifice drain stamps /// `ThisWayCause::Sacrificed`; the discard drain immediately below stamps /// nothing, because the UN-paused discard path does not stamp either - /// (`grep stamp_active_player_action_completion effects/discard.rs` → 0), so + /// (`grep stamp_active_player_action_completion effects/discard.rs` -> 0), so /// a stamping resume would give a paused discard a provenance its own /// un-paused twin never has. /// @@ -7687,47 +7687,103 @@ mod tests { /// blocks twenty-five lines apart would silently change `Discarded`-this-way /// provenance for the whole class. /// - /// This is a SOURCE census rather than a behavioural assertion, and that is - /// a measured choice, not a shortcut: `stamp_active_player_action_completion` - /// early-returns unless an active ability continuation frame holds a - /// `CompletePlayerAction` chain, so in a drain unit test — which has no such - /// frame — adding the call would change no observable state. A behavioural - /// assertion there would be vacuous. The census is discriminating because it - /// carries its own positive control: half (a) proves the scan reaches a - /// region that DOES stamp, so half (b)'s zero cannot be a scan that missed. + /// A SOURCE census rather than a behavioural assertion, and that is measured: + /// `stamp_active_player_action_completion` early-returns unless an active + /// ability continuation frame holds a `CompletePlayerAction` chain, so in a + /// drain unit test -- which has neither -- adding the call would change no + /// observable state and a behavioural assertion would itself be vacuous. + /// + /// THE WINDOWS ARE BRACE-BALANCED, NOT END-ANCHORED, and that is the whole + /// difficulty of this instrument. An earlier revision ended each slice at a + /// guessed marker; the marker for the second window happened to sit inside + /// the arm, so the "guarded" region collapsed to **36 characters of a + /// five-line arm** and the named revert probe only flipped if a stamp landed + /// as the arm's very first statement. A census whose window is wrong reports + /// a zero that means nothing, and it reports it silently. + /// + /// Three defences, because a negative result needs all of them: + /// 1. each anchor must match EXACTLY ONCE in the file, so a deleted + /// production arm cannot let the scan retarget some other text (this + /// doc comment deliberately never spells an anchor literally); + /// 2. each window is closed by brace balance, so it always spans the whole + /// arm body; + /// 3. each window asserts its OWN non-degeneracy. The positive control on + /// the sacrifice arm proves the file and that anchor are real, but it + /// says nothing about the extent of the DISCARD window -- and the + /// discard window is the one whose zero carries the claim. /// /// REVERT PROBES: - /// * add a `stamp_active_player_action_completion(… ThisWayCause::Discarded …)` - /// call to the discard `Completed` arm → half (b) fails. - /// * delete the sacrifice arm's existing stamp → half (a) fails, proving - /// the slicing is anchored on real code and not on absent text. + /// * add a stamp call ANYWHERE in the discard arm -- first statement, last + /// statement, or nested inside its `if` -- and half (b) fails. Only the + /// brace-balanced window makes all three positions equivalent. + /// * delete the sacrifice arm's existing stamp -> half (a) fails. + /// * delete either arm outright -> the exactly-once assertion fails, rather + /// than the scan silently sliding onto other text. #[test] fn the_resumed_discard_drain_does_not_stamp_a_completion_while_its_sacrifice_sibling_does() { + /// The arm body that follows `anchor`, delimited by brace balance. + fn arm_body<'a>(source: &'a str, anchor: &str) -> &'a str { + assert_eq!( + source.matches(anchor).count(), + 1, + "anchor {anchor:?} must identify exactly one site; a second \ + occurrence lets a deleted arm retarget the scan silently" + ); + let after = source.find(anchor).expect("anchor present") + anchor.len(); + let open = after + + source[after..] + .find('{') + .expect("the arm opens a block after its anchor"); + let mut depth = 0usize; + for (offset, ch) in source[open..].char_indices() { + match ch { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return &source[open..=open + offset]; + } + } + _ => {} + } + } + panic!("unbalanced braces after {anchor:?}"); + } + let source = include_str!("engine_replacement.rs"); let needle = concat!("stamp_active_player_action_", "completion("); + let sacrifice_anchor = concat!("PendingPlayerScopeSacrifice", "Outcome::Completed {"); + let discard_anchor = concat!("PendingDiscardBatch", "Outcome::Completed =>"); + + let sacrifice = arm_body(source, sacrifice_anchor); + let discard = arm_body(source, discard_anchor); + + // (0) Non-degeneracy, asserted per window. Both arms run a multi-line + // body ending in a `drain_pending_continuation` call guarded by a + // `waiting_for` re-check, so a window that cannot see that call has not + // spanned its arm and any verdict drawn from it is worthless. + for (label, window) in [("sacrifice", sacrifice), ("discard", discard)] { + assert!( + window.contains("drain_pending_continuation"), + "{label} window must span its whole arm body; it stops before the \ + arm's last statement, so a scan of it proves nothing (got {} chars)", + window.len() + ); + } - let sacrifice_arm = source - .split_once("PendingPlayerScopeSacrificeOutcome::Completed {") - .expect("the sacrifice drain's Completed arm exists") - .1; - let sacrifice_arm = &sacrifice_arm[..sacrifice_arm - .find("PendingDiscardBatchOutcome::Idle") - .expect("the discard drain follows the sacrifice drain in this function")]; + // (a) Positive control: proves the instrument finds a stamp where one + // exists. Necessary but NOT sufficient for (b) -- different window. assert!( - sacrifice_arm.contains(needle), - "(a) positive control: the sacrifice arm must stamp, or this scan is reading the wrong region and (b)'s zero would mean nothing" + sacrifice.contains(needle), + "(a) positive control: the sacrifice arm must stamp, or this scan is \ + reading the wrong region and (b)'s zero would mean nothing" ); - let discard_arm = source - .split_once("PendingDiscardBatchOutcome::Completed =>") - .expect("the discard drain's Completed arm exists") - .1; - let discard_arm = &discard_arm[..discard_arm - .find("drain_pending_continuation") - .expect("the discard arm runs the parked continuation")]; + // (b) The claim. assert!( - !discard_arm.contains(needle), - "(b) the resumed discard must publish exactly what the un-paused discard publishes — no CompletePlayerAction stamp" + !discard.contains(needle), + "(b) the resumed discard must publish exactly what the un-paused \ + discard publishes -- no CompletePlayerAction stamp" ); } } diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index fb287b573e..da7f61eabc 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -4179,10 +4179,20 @@ pub struct PendingDiscardFanOut { pub original_controller: PlayerId, /// CR 101.4: seats not yet iterated, in APNAP order. pub remaining_players: Vec, - /// CR 608.2f: the clause's full reduction domain, for the terminal - /// zero-fill. Latched at the pause and never re-derived: a player joining - /// or leaving the matching set afterwards cannot alter an action that has - /// already begun being processed per subject. + /// The clause's full reduction domain, for the terminal zero-fill. Latched + /// at the pause and never re-derived, for PARITY rather than by rule: the + /// un-paused driver computes this domain once at clause entry and never + /// re-derives it either, so a paused clause that re-derived would answer + /// differently from an identical un-paused one — the exact divergence this + /// carrier exists to prevent. CR 800.4i is what keeps a seat that has since + /// left the game well-defined here: "the effect uses the last known + /// information about that player before they left the game", and its + /// truthful contribution is zero. `elimination.rs` therefore prunes + /// `remaining_players` and deliberately leaves this list whole. + /// + /// (An earlier revision cited CR 608.2f for the latching. It does not + /// support it: read in full, 608.2f is about simultaneity and APNAP ORDER, + /// and both its examples are about ordering.) pub matching_players: Vec, /// CR 607.2a: carried across the pause so the terminal publication makes the /// same linked-exile decision the un-paused postlude would. @@ -25807,9 +25817,14 @@ mod tests { /// the restored prompt fails outright, which is what makes the /// mid-pause input load-bearing rather than decorative. /// * add a load-time "repair" that resets `waiting_for` to `Priority` - /// when `pending_discard_batch` is absent → the same assertion fails. - /// That repair would be WRONG: it silently discards a real prompt and - /// converts a detectable inconsistency into a plausible-looking state. + /// when `pending_discard_batch` is absent, in + /// `PersistedGameState::into_game_state()` → the same assertion fails. + /// The load below deliberately goes through that chokepoint rather than + /// bare `from_value::`, because it is where this repo puts + /// load-time repairs; deserializing the struct directly would leave this + /// probe guarding a door no repair would ever come through. That repair + /// would be WRONG: it silently discards a real prompt and converts a + /// detectable inconsistency into a plausible-looking state. #[test] fn absent_pending_discard_batch_deserializes_as_none() { let mut state = GameState::new_two_player(42); @@ -25829,26 +25844,39 @@ mod tests { "reach guard: the INPUT must not already satisfy the property under test" ); - let mut wire = serde_json::to_value(&state).expect("fixture serializes"); + // Loaded through `PersistedGameState::into_game_state()`, NOT bare + // `from_value::`. That chokepoint is where this repo puts + // load-time repairs, so it is the only door a "helpfully" reset + // `waiting_for` would come through; deserializing the struct directly + // would leave the second revert probe below guarding a door nobody uses. + let mut wire = serde_json::to_value(PersistedGameState::Raw(Box::new(state))) + .expect("fixture serializes"); assert!( wire.as_object_mut() - .expect("state is an object") + .expect("a raw persisted state is an object") .remove("pending_discard_batch") .is_some(), "reach guard: the field must actually have been serialized to remove" ); - let restored: GameState = - serde_json::from_value(wire).expect("an absent parked batch defaults to None"); + let restored = serde_json::from_value::(wire) + .expect("an absent parked batch defaults to None") + .into_game_state(); assert!(restored.pending_discard_batch.is_none()); assert!( matches!( restored.waiting_for, - WaitingFor::ReplacementChoice { player, .. } if player == PlayerId(0) + WaitingFor::ReplacementChoice { + player, + candidate_count, + .. + } if player == PlayerId(0) && candidate_count == 2 ), - "a batch-less mid-pause save must round-trip its prompt VERBATIM, so the \ - inconsistency stays observable to a caller instead of being silently \ - rewritten into a plausible-looking state" + "a batch-less mid-pause save must round-trip its prompt VERBATIM — player \ + AND candidate_count, since asserting only the variant would pass for a \ + prompt rebuilt with different contents — so the inconsistency stays \ + observable to a caller instead of being silently rewritten into a \ + plausible-looking state" ); } diff --git a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz index b18f56563d6a7a63709d7678ebd29e8087424459..939a659e59fff6d8b5c9204ce26cb27af3c4385f 100644 GIT binary patch delta 18652 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 AzW@LL From 126fc9a3c188d21adae594fb3ef1be762fae98ca Mon Sep 17 00:00:00 2001 From: lgray Date: Mon, 17 Aug 2026 01:43:16 -0500 Subject: [PATCH 16/26] test(engine): pin two guards that nothing would have caught, and route a census through the shared comment rule The CR 701.9a "already left the hand" guard in `route_discard` and the CR 800.4a roster prune in `elimination.rs` both changed runtime behaviour with no test that went red if they were deleted. Review had verified they were correct, which is a different question from whether they were pinned. T-A asserts non-vacuity FIRST -- the in-hand card must actually be discarded, or an inert `route_discard` would satisfy the negative half by doing nothing at all -- then covers the frame half review found missing. That frame test nests two frames, which buys ARITY AND DIRECTION: it separates "retired one frame" from "emptied the stack", and catches a retirement that pops zero, two, or from the wrong end. It deliberately does NOT claim the guard retired the frame it was HANDED. That property is absent from the code rather than unmeasured -- `take_active_discard` pops the top when that top is a `Discard` frame, and `frame_id` is consulted only by a `debug_assert_eq!` -- so a test demanding it would red on HEAD. The doc records that instead of asserting it, and discloses the leak the qualifier implies: `retire_discard_frame` swallows `Err(UnexpectedTop)`, so a non-`Discard` top makes retirement a silent no-op. Unmeasured for reachability, and repairing it is a change to the resolution stack's error contract, not to this guard. T-B asserts an ASYMMETRY: a seat that leaves mid-pause is dropped from the iteration roster (CR 800.4a: its objects leave the game, so iterating it can only be a no-op) and KEPT in the reduction domain (CR 800.4i: last known information, contributing zero). The two lists look like duplicates, so the natural tidy-up prunes both -- which shrinks the domain and changes what a `Min` over it answers. `Min`, not `Max`: `Max` cannot be raised by zeros, as `fill_zero_contributors`' own doc records. One of the revert probes is exactly that tidy-up. The test pins the SHAPE of the two lists; the `Min` consequence is the reason the pin exists, not something it measures, and the doc comment now says so. `source_census::tests::no_source_reading_file_carries_a_private_comment_policy` was red on the parity census in `engine_replacement.rs`: it read Rust source with its own comment policy. That census's claim is a NEGATIVE, so a deleted stamp whose spelling survived in a trailing `//` would have HELD the zero and hidden the regression. Routed through `code_lines`; no probe is quoted because a better measurement exists -- the guard was red before and is green after. The routing is documented as a CLOSURE rather than a live defence: measured on this tree, raw and stripped text are identical for every quantity that census reads, so it discriminates nothing today. Also corrects a comment of my own that named the wrong arm: the two `Prevented` arms that retire the discard frame are in `complete_discard_to_graveyard` and in `resolve`'s specific-target loop, both above; `route_discard`'s own does not. Disclosed rather than repaired -- its reachability with a frame present was never measured. Six revert probes run and observed red, each with a real `test result: FAILED` rather than a bare non-zero exit, and each file restored with a sha256-verified copy. One is disclosed rather than counted clean: retiring the frame twice reds through `retire_discard_frame`'s own `debug_assert_eq!`, not through this test. `[profile.test] inherits = "dev"`, `[profile.release]` never sets `debug-assertions`, and no `--release` test invocation exists in the Tiltfile or any workflow, so the production assertion fires first in every venue this repo actually runs -- the test's own `expect` is unreachable there. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/effects/discard.rs | 173 ++++++++++++++++++- crates/engine/src/game/effects/mod.rs | 61 +++++++ crates/engine/src/game/elimination.rs | 5 + crates/engine/src/game/engine_replacement.rs | 31 +++- 4 files changed, 262 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/game/effects/discard.rs b/crates/engine/src/game/effects/discard.rs index 71cfd6d4f0..48008b19fa 100644 --- a/crates/engine/src/game/effects/discard.rs +++ b/crates/engine/src/game/effects/discard.rs @@ -875,13 +875,21 @@ fn route_discard( // a hard-coded `from: Hand`. Un-paused callers build and consume their // snapshot inside one action and cannot observe a difference. // - // Modelled EXACTLY on the `Prevented` arm below, deliberately: that arm is - // this file's existing answer to "the card never left the hand, so no - // discard occurred", and it retires the discard frame and reports - // `Complete`. Retiring matters — a `DiscardedCardMatchesFilter` frame left - // active would leak when every listed card has already moved. + // Modelled on this file's `Prevented` arms, which are its existing answer to + // "the card never left the hand, so no discard occurred": both retire the + // frame and report `Complete`. Retiring matters — a + // `DiscardedCardMatchesFilter` frame left active would leak when every + // listed card has already moved. // - // `Complete` is a known imprecision INHERITED from that arm, not introduced + // WHICH arms, stated because an earlier revision of this comment named the + // wrong one: the two that retire are in `complete_discard_to_graveyard` and + // in `resolve`'s specific-target loop, both ABOVE. This function's own + // `Prevented` arm below does NOT retire — an inherited asymmetry left + // untouched, since whether that arm is reachable at all with a frame present + // was not measured here, and writing a fix for an unmeasured path is how the + // wrong-arm claim got in. + // + // `Complete` is a known imprecision INHERITED from those arms, not introduced // here: `DiscardOutcome` has no "nothing happened" variant, so a cost caller // reads `Complete` as paid. A prevented discard already launders an unpayable // cost the same way (CR 118.3 wants all-or-nothing). Fixing it means a third @@ -1252,6 +1260,159 @@ mod random_discard_authority_tests { assert_eq!(outcome, RandomDiscardOutcome::Completed); assert_eq!(discarded(&state, &hand).len(), 2); } + + /// CR 701.9a: "To discard a card, move it from its owner's hand to that + /// player's graveyard." A card that is no longer in a hand when its + /// proposal is reached cannot be discarded, so `route_discard` must propose + /// nothing for it. + /// + /// The real shape is a parked batch — a cursor latches a hand snapshot + /// BEFORE an action boundary and drains after one, so a listed card can have + /// left the hand in between, and `complete_discard_to_graveyard` lowers to a + /// hard-coded `from: Hand`. Staged directly here rather than through the + /// batch machinery so a failure names the guard and not the driver. + /// + /// NON-VACUITY is the first assertion, not the second: an inert + /// `route_discard` that discarded nothing at all would satisfy the negative + /// half. The in-hand card must actually be discarded for the moved card's + /// silence to mean anything. + /// + /// REVERT PROBE (RUN, not reasoned): delete the `!= Some(Zone::Hand)` early + /// return at the top of `route_discard`. Observed first failure is the + /// `discarded_ids` assertion, which goes `[stays]` -> `[stays, moved]`. + /// + /// The `relowered` assertion below is therefore DOMINATED under that probe — + /// it never gets to run. It is kept deliberately, and its scope is stated + /// here rather than left implied: it covers a DIFFERENT failure, one that + /// lowers the hand -> graveyard `ZoneChange` while suppressing the + /// `Discarded` push. No probe in this lane exercises that one, and this + /// fixture passes `discard_frame: None`, so it cannot reach the frame-borne + /// route where that split is what actually happens today. + #[test] + fn route_discard_skips_a_card_that_already_left_the_hand() { + let (mut state, hand) = hand_of(42, 2); + let (stays, moved) = (hand[0], hand[1]); + let mut setup = Vec::new(); + crate::game::zones::move_to_zone(&mut state, moved, Zone::Graveyard, &mut setup); + assert_eq!( + state.objects[&moved].zone, + Zone::Graveyard, + "reach guard: the card under test must genuinely be out of the hand" + ); + + let mut events = Vec::new(); + for card in [stays, moved] { + route_discard(&mut state, card, PlayerId(0), None, true, None, &mut events); + } + + let discarded_ids: Vec = events + .iter() + .filter_map(|e| match e { + GameEvent::Discarded { object_id, .. } => Some(*object_id), + _ => None, + }) + .collect(); + assert_eq!( + discarded_ids, + vec![stays], + "the in-hand card must be discarded (non-vacuity) and the already-moved \ + card must produce no discard" + ); + let relowered = events + .iter() + .filter(|e| { + matches!( + e, + GameEvent::ZoneChanged { object_id, from: Some(Zone::Hand), .. } + if *object_id == moved + ) + }) + .count(); + assert_eq!( + relowered, 0, + "no hand -> graveyard move may be lowered for a card that was not in a hand" + ); + } + + /// The frame half of the same guard: a `DiscardedCardMatchesFilter` frame is + /// opened by `resolve` for the whole instruction, so bailing out of a listed + /// card without retiring it leaves an active frame owning nothing — and + /// `active_discard` is LIFO, so the next operation reads it as its own. + /// + /// TWO frames are installed, and what that buys is ARITY AND DIRECTION, not + /// identity: a single-frame fixture cannot separate "retired one frame" from + /// "emptied the stack", while nesting catches a retirement that pops zero, + /// pops two, or pops from the wrong end. + /// + /// It does NOT establish that the guard retired the frame it was HANDED, and + /// an earlier revision of this doc claimed it did. The fixture hands the + /// guard the frame already on top, so "retire the handed frame" and "retire + /// the top" are one action here — and they are one action in PRODUCTION too: + /// `retire_discard_frame` calls `take_active_discard`, which pops the top + /// WHEN THAT TOP IS A `Discard` FRAME — returning `Err(UnexpectedTop)` + /// otherwise — with `frame_id` consulted only by a `debug_assert_eq!`. + /// The id-keyed property is therefore ABSENT FROM THE CODE rather than + /// merely unmeasured, so a test demanding it would red on HEAD. Recorded + /// here instead of asserted: a failing test for a property the design does + /// not claim is noise, not coverage. + /// + /// DISCLOSED, NOT REPAIRED, because the qualifier above is load-bearing: + /// `retire_discard_frame` swallows that `Err` (and the empty case) in an + /// `if let Ok(Some(..))`, so retirement is BEST-EFFORT. If a non-`Discard` + /// frame sits on top when this guard fires, the retirement silently no-ops + /// and the frame survives owning nothing — precisely the hazard the first + /// paragraph of this doc names. Its reachability was not measured, and + /// making retirement total is a change to the resolution stack's error + /// contract rather than to this guard. Same disposition as `route_discard`'s + /// own non-retiring `Prevented` arm. + /// + /// REVERT PROBES (RUN): delete the `retire_discard_frame` call from inside + /// the guard, keeping the early return — reds at this test's own assertion. + /// Calling it TWICE also reds, but through `retire_discard_frame`'s + /// `debug_assert_eq!`, NOT through this test: `[profile.test] inherits = + /// "dev"`, `[profile.release]` never sets `debug-assertions`, and no + /// `--release` test invocation exists in the Tiltfile or any workflow — so + /// the production assertion fires first in every venue this repo runs. + #[test] + fn route_discard_retires_the_frame_for_a_card_that_left_the_hand() { + let (mut state, hand) = hand_of(7, 1); + let card = hand[0]; + let mut setup = Vec::new(); + crate::game::zones::move_to_zone(&mut state, card, Zone::Graveyard, &mut setup); + + let outer = state.resolution_stack.begin_discard(Some(ObjectId(499))); + let frame = state.resolution_stack.begin_discard(Some(ObjectId(500))); + assert_eq!( + state + .resolution_stack + .active_discard() + .expect("reach guard: a frame must be active before the call") + .id, + frame, + "reach guard: the INNER frame must be the one on top, or the pop below proves nothing" + ); + + let mut events = Vec::new(); + route_discard( + &mut state, + card, + PlayerId(0), + None, + true, + Some(frame), + &mut events, + ); + + assert_eq!( + state + .resolution_stack + .active_discard() + .expect("exactly one frame may be retired, leaving the outer one active") + .id, + outer, + "the guard must retire EXACTLY ONE frame, popped from the top: the outer frame survives" + ); + } } #[cfg(test)] diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index a8a30d8332..09606a2143 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -19277,6 +19277,67 @@ mod tests { ); } + /// CR 800.4a ("all objects (see rule 109) owned by that player leave the + /// game …") vs CR 800.4i ("the effect uses the last known information about + /// that player before they left the game"): a seat that leaves mid-pause is + /// dropped from the ITERATION roster and kept in the reduction DOMAIN. + /// + /// The asymmetry is the whole point of the test: the two lists look like + /// duplicates, so the natural "tidy-up" is to prune both. Latching the + /// domain is PARITY with the un-paused driver (which derives it once at + /// clause entry) rather than a rule; CR 800.4i is what keeps the departed + /// seat well-defined in it. + /// + /// WHAT THIS PINS, stated precisely because the honest scope is narrower + /// than the motivation: it pins the SHAPE of the two lists after an + /// elimination, and nothing downstream of them. The consequence that makes + /// the shape matter — a domain short one zero-contributor changes what a + /// `Min` over it answers (`fill_zero_contributors`; `Sum` and `Max` are + /// blind to zeros) — is NOT exercised here: no seat in this fixture holds a + /// hand, so the drain never runs. Treat that consequence as the reason the + /// pin exists, not as something this test measures. + /// + /// Lives here rather than beside the prune so it can reuse the fan-out + /// fixture; `elimination.rs` carries a pointer to it at the prune site. + /// + /// REVERT PROBES (both RUN, not reasoned): + /// * delete `fan_out.remaining_players.retain(..)` in `elimination.rs` + /// -> the roster assertion fails; + /// * add a matching `fan_out.matching_players.retain(..)` beside it + /// -> the domain assertion fails. + #[test] + fn eliminating_a_seat_prunes_the_paused_roster_but_not_its_reduction_domain() { + let mut state = GameState::new(FormatConfig::standard(), 4, 42); + let source = ObjectId(100); + let seats = vec![PlayerId(1), PlayerId(2), PlayerId(3)]; + + park_batch(&mut state, source, PlayerId(0), Vec::new(), None); + state.pending_discard_batch.as_mut().unwrap().fan_out = + Some(fan_out_of(source, seats.clone(), seats.clone())); + + let mut events = Vec::new(); + crate::game::elimination::eliminate_player(&mut state, PlayerId(2), &mut events); + + let fan_out = state + .pending_discard_batch + .as_ref() + .expect("the batch survives an unrelated seat leaving") + .fan_out + .as_ref() + .expect("so does its fan-out"); + assert_eq!( + fan_out.remaining_players, + vec![PlayerId(1), PlayerId(3)], + "CR 800.4a: a departed seat's objects leave the game, so it has no \ + hand left and iterating it can only be a no-op" + ); + assert_eq!( + fan_out.matching_players, seats, + "CR 800.4i: the reduction domain is latched at the pause and keeps \ + the departed seat, whose truthful contribution is zero" + ); + } + /// CR 608.2f: BOUNDARY. A later seat that pauses on something which is NOT /// a batch pause — here an interactive `WaitingFor::DiscardChoice` — hands /// the remaining seats back to the generic continuation queue exactly as the diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index 3676a8f8c5..42902c158b 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -932,6 +932,11 @@ fn do_eliminate( // Read in full it is about simultaneity and APNAP ORDER — it latches no // domain, and both its examples are about ordering. Same class of stretch as // the CR 608.2b citation removed from `discard.rs`.) + // + // PINNED BY `effects/mod.rs`'s + // `eliminating_a_seat_prunes_the_paused_roster_but_not_its_reduction_domain`, + // which lives there to reuse the fan-out fixture. It asserts BOTH halves, + // so pruning the second list too is a red test rather than a silent change. if let Some(fan_out) = state .pending_discard_batch .as_mut() diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index 534f32aceb..3b1d29e11e 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -7701,7 +7701,20 @@ mod tests { /// as the arm's very first statement. A census whose window is wrong reports /// a zero that means nothing, and it reports it silently. /// - /// Three defences, because a negative result needs all of them: + /// Three defences, because a negative result needs all of them, plus one + /// CLOSURE that is deliberately not counted among them: + /// 0. the text scanned is the CODE half only, via the shared + /// `source_census` authority. MEASURED INERT on today's tree: raw and + /// stripped text are byte-identical for every quantity this test reads + /// (both anchors and the needle at 1, sacrifice window 716 chars, + /// discard window 281, `drain_pending_continuation` present in both — + /// those two char counts are a SNAPSHOT and will rot on any edit to + /// either arm body; the durable claim is the raw/stripped IDENTITY, not + /// the numbers), + /// which is exactly what `source_census.rs` predicts of any census that + /// scans the real tree. It discriminates nothing here and is listed + /// apart from 1-3 for that reason: it closes a shape not currently + /// present rather than catching one that is; /// 1. each anchor must match EXACTLY ONCE in the file, so a deleted /// production arm cannot let the scan retarget some other text (this /// doc comment deliberately never spells an anchor literally); @@ -7750,7 +7763,21 @@ mod tests { panic!("unbalanced braces after {anchor:?}"); } - let source = include_str!("engine_replacement.rs"); + // Routed through the shared comment authority rather than scanned raw, + // and this census is exactly the case that module exists for: half (b) + // is a NEGATIVE, so a deleted stamp whose spelling survived in a + // trailing `//` inside the discard arm would HOLD the zero and hide the + // regression, while a comment merely naming the needle would flip it red + // on a pure prose edit. + // + // SCOPE, because "stripping comments" would overclaim: `code_lines` + // removes whole-line and trailing `//` comments and a LEADING block + // comment. The interior lines of a multi-line `/* … */` survive and are + // still scanned — `source_census.rs` says so in its own doc. So a block + // comment inside the discard arm naming the needle would still flip + // half (b) red. That residue is fail-CLOSED (spurious red, never a + // missed site), which is the direction a census may fail in. + let source = &crate::source_census::code_lines(include_str!("engine_replacement.rs")); let needle = concat!("stamp_active_player_action_", "completion("); let sacrifice_anchor = concat!("PendingPlayerScopeSacrifice", "Outcome::Completed {"); let discard_anchor = concat!("PendingDiscardBatch", "Outcome::Completed =>"); From c504e2cdb0bbd5f8a4a78c9d7a65b0b10bd801ca Mon Sep 17 00:00:00 2001 From: matthewevans Date: Mon, 17 Aug 2026 02:10:38 -0700 Subject: [PATCH 17/26] fix(PR-7494): persist paused clause snapshot --- crates/engine/src/game/visibility.rs | 11 ++++ crates/engine/src/types/game_state.rs | 25 ++++---- .../windfall_greatest_discard_aggregate.rs | 62 ++++++++++++++++++- 3 files changed, 86 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index 12f0df6442..cfc1b65b67 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -260,6 +260,11 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState // itself. Viewer projections are display-only clones; the authoritative // state the drain resumes from is never filtered. filtered.pending_discard_batch = None; + // CR 608.2h: a paused player-scope clause retains its frozen aggregate in + // authoritative state so save/restore resumes the same application. The + // value can encode hidden-zone information (for example, hand sizes), so it + // belongs with the private discard cursor rather than any viewer payload. + filtered.clause_minimum_snapshot = None; // Deferred life-cost owners can embed a complete PendingCast, including // hidden card and target context. The projected WaitingFor is the only // viewer-facing interaction surface. @@ -6219,6 +6224,8 @@ mod tests { fan_out: None, preceding_events: Vec::new(), })); + state.clause_minimum_snapshot = + Some(crate::types::game_state::ClauseMinimumSnapshot::default()); let authoritative = serde_json::to_string(&state.pending_discard_batch) .expect("the authoritative batch serializes"); @@ -6233,6 +6240,10 @@ mod tests { view.pending_discard_batch.is_none(), "viewer {viewer:?} must not receive the parked discard batch" ); + assert!( + view.clause_minimum_snapshot.is_none(), + "viewer {viewer:?} must not receive the paused clause's private aggregate" + ); let wire = serde_json::to_string(&view).expect("the filtered snapshot serializes"); assert!( !wire.contains("\"pendingDiscardBatch\":{") diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 193a61fb43..28d048fc4b 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -4082,11 +4082,9 @@ pub enum PendingPlayerScopeSacrificeFollowUp { /// share; CR 614.6 is what says a replaced event never happens and a modified /// one happens instead. /// -/// PERSISTENCE ASYMMETRY, stated because it will otherwise mis-triage a bug -/// report: this field IS serialized, while `GameState::clause_minimum_snapshot` -/// — the CR 608.2h freeze the resumed draw clause reads — is `#[serde(skip)]`. -/// A save taken mid-pause therefore restores the parked batch but not the -/// frozen count. That is pre-existing and out of this type's scope. +/// The companion `GameState::clause_minimum_snapshot` persists with this batch: +/// a save taken mid-pause must resume the same CR 608.2h application with its +/// original frozen value, rather than determine it again after restore. #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct PendingDiscardBatch { /// The discarding seat whose batch paused. @@ -14688,9 +14686,12 @@ impl StackEntryKind { /// been correct, and Windfall would rightly pay out the last discard rather /// than the greatest. /// -/// Transient — never serialized. Captured before a `player_scope` link's -/// fan-out and cleared when the link completes, so the next clause re-enters -/// the driver with `None` and re-captures against the post-clause board. +/// Resolution-scoped, but persisted while a choice pauses the resolution. +/// Captured before a `player_scope` link's fan-out and cleared when the link +/// completes, so the next clause re-enters the driver with `None` and +/// re-captures against the post-clause board. A save during a replacement +/// choice must retain this frozen answer: the resumed clause is still the same +/// application of the effect, not a new time to determine it. /// /// # Single-cell invariant /// @@ -14707,7 +14708,7 @@ impl StackEntryKind { /// snapshot would be silently corrupted by the inner capture. At that point /// this field MUST become a `Vec` stack with /// push/pop bracketing each `player_scope` link entry/exit. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ClauseMinimumSnapshot { /// Reduced cross-player aggregates keyed by the originating quantity /// reference, so multiple distinct refs in one clause do not collide. @@ -17075,8 +17076,10 @@ declare_game_state! { /// completes, so every player in that clause resolves against the same /// pre-clause board. The per-link lifecycle is deliberately narrower than /// `last_vote_ballots`' per-chain reset — three Balance clauses are three - /// links in one chain and must each snapshot independently. Transient. - #[serde(skip)] + /// links in one chain and must each snapshot independently. Resolution- + /// scoped, but serialized across a paused resolution so the frozen value + /// survives authoritative save/restore. + #[serde(default, skip_serializing_if = "Option::is_none")] pub clause_minimum_snapshot: Option, /// CR 400.7 + CR 608.2c: Number of cards exiled from a hand by the most recent diff --git a/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs b/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs index 1368ee0b1a..80287a2260 100644 --- a/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs +++ b/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs @@ -26,7 +26,7 @@ use engine::types::ability::{ AbilityDefinition, AbilityKind, Effect, ReplacementDefinition, ReplacementMode, TargetFilter, }; use engine::types::actions::GameAction; -use engine::types::game_state::WaitingFor; +use engine::types::game_state::{PersistedGameState, WaitingFor}; use engine::types::identifiers::ObjectId; use engine::types::mana::ManaCost; use engine::types::phase::Phase; @@ -611,6 +611,66 @@ fn windfall_paused_mid_fan_out_still_draws_the_greatest() { ); } +/// A replacement choice splits one still-resolving Windfall instruction across +/// an authoritative save/restore boundary. The draw clause must use the +/// `PreviousEffectAmount` captured for that in-flight application, rather than +/// re-read a partially restored ledger or live hand sizes after the pause. +/// +/// Discriminating: the save occurs only after the real cast pipeline has +/// parked `PendingDiscardBatch` at a `ReplacementChoice`; removing the +/// snapshot's serde support fails the restored-snapshot reach guard before the +/// resumed production pipeline is driven. +#[test] +fn windfall_save_during_replacement_choice_preserves_frozen_greatest_discard() { + let mut scenario = GameScenario::new_n_player(4, 42); + scenario.at_phase(Phase::PreCombatMain); + for (seat, hand) in SEATS.iter().zip(PAUSED_HANDS) { + seed_hand_ids(&mut scenario, *seat, hand); + seed_library(&mut scenario, *seat, LIBRARY_DEPTH); + } + scenario + .add_creature_from_oracle(P0, "Library of Leng", 1, 1, LIBRARY_OF_LENG) + .as_artifact(); + let windfall = scenario + .add_spell_to_hand_from_oracle(P0, "Windfall", false, WINDFALL) + .with_mana_cost(ManaCost::zero()) + .id(); + let mut runner = scenario.build(); + + runner.cast(windfall).resolve(); + assert!( + matches!(runner.state().waiting_for, WaitingFor::ReplacementChoice { .. }) + && runner.state().pending_discard_batch.is_some() + && runner.state().clause_minimum_snapshot.is_some(), + "reach guard: the production cast must park both the discard continuation and its frozen clause value" + ); + + let saved = serde_json::to_string(&PersistedGameState::capture(runner.state().clone())) + .expect("the authoritative paused state serializes"); + let restored: PersistedGameState = + serde_json::from_str(&saved).expect("the authoritative paused state deserializes"); + let mut runner = GameRunner::from_state(restored.into_game_state()); + assert!( + runner.state().clause_minimum_snapshot.is_some(), + "the paused resolution's frozen clause value must survive authoritative restore" + ); + + let prompts = answer_every_replacement_choice(&mut runner, "Decline"); + runner.advance_until_stack_empty(); + + assert_eq!( + ( + prompts, + SEATS + .iter() + .map(|p| LIBRARY_DEPTH - state_zone_len(&runner, *p, Zone::Library)) + .collect::>(), + ), + (7, vec![7, 7, 7, 7]), + "restoring a parked Windfall must resume the original CR 608.2h draw value" + ); +} + /// Every observable of the gate-2 arm, asserted as ONE value. #[derive(Debug, PartialEq, Eq)] struct GateTwoSignature { From a803988a237467bb99ee34d08f10387f257b2cf7 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Mon, 17 Aug 2026 03:07:50 -0700 Subject: [PATCH 18/26] test(PR-7494): target paused snapshot persistence --- .../tests/integration/balance_equalization.rs | 74 ++++++++++++++++++- .../windfall_greatest_discard_aggregate.rs | 62 +--------------- 2 files changed, 74 insertions(+), 62 deletions(-) diff --git a/crates/engine/tests/integration/balance_equalization.rs b/crates/engine/tests/integration/balance_equalization.rs index 691d5b1db6..f4ad98f2bc 100644 --- a/crates/engine/tests/integration/balance_equalization.rs +++ b/crates/engine/tests/integration/balance_equalization.rs @@ -21,14 +21,17 @@ use engine::game::ability_utils::build_resolved_from_def; use engine::game::effects::resolve_ability_chain; use engine::game::engine::apply; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; use engine::game::zones::create_object; use engine::parser::oracle_effect::parse_effect_chain; use engine::types::ability::{AbilityKind, ResolvedAbility}; use engine::types::actions::GameAction; use engine::types::card_type::CoreType; use engine::types::format::FormatConfig; -use engine::types::game_state::{GameState, WaitingFor}; +use engine::types::game_state::{GameState, PersistedGameState, WaitingFor}; use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; use engine::types::player::PlayerId; use engine::types::zones::Zone; @@ -37,6 +40,8 @@ control equal to the number of lands controlled by the player who controls \ the fewest, then sacrifices the rest. Players discard cards and sacrifice \ creatures the same way."; +const LIBRARY_OF_LENG_ORACLE: &str = "You have no maximum hand size.\nIf an effect causes you to discard a card, discard it, but you may put it on top of your library instead of into your graveyard."; + /// Build Balance's resolved ability chain controlled by `controller`. fn balance_ability(controller: PlayerId, source_id: ObjectId) -> ResolvedAbility { let def = parse_effect_chain(BALANCE_ORACLE, AbilityKind::Spell); @@ -358,3 +363,70 @@ fn balance_three_player_interactive_fan_out_equalizes() { ); } } + +/// A replacement choice pauses Balance's discard-down-to-the-fewest-cards +/// clause after its cross-player hand-size minimum has been frozen. The +/// authoritative save/restore path must preserve that still-live value: resume +/// continues the same application rather than determining a new minimum. +/// +/// Discriminating: P0's three-card hand must discard down to P1's one-card +/// minimum. The test saves only after the real cast pipeline parks the discard +/// batch at a `ReplacementChoice`; removing the snapshot's serde support leaves +/// the restored reach guard empty before the resumed production path runs. +#[test] +fn balance_save_during_discard_replacement_preserves_frozen_hand_minimum() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + for i in 0..3 { + scenario.add_card_to_hand(P0, &format!("P0 hand card {i}")); + } + scenario.add_card_to_hand(P1, "P1 hand card"); + scenario + .add_creature_from_oracle(P0, "Library of Leng", 1, 1, LIBRARY_OF_LENG_ORACLE) + .as_artifact(); + let balance = scenario + .add_spell_to_hand_from_oracle(P0, "Balance", false, BALANCE_ORACLE) + .with_mana_cost(ManaCost::zero()) + .id(); + let mut runner = scenario.build(); + + runner.cast(balance).resolve(); + assert!( + matches!(runner.state().waiting_for, WaitingFor::ReplacementChoice { .. }) + && runner.state().pending_discard_batch.is_some() + && runner.state().clause_minimum_snapshot.is_some(), + "reach guard: the production cast must park Balance's discard batch with its frozen hand minimum" + ); + + let saved = serde_json::to_string(&PersistedGameState::capture(runner.state().clone())) + .expect("the authoritative paused state serializes"); + let restored: PersistedGameState = + serde_json::from_str(&saved).expect("the authoritative paused state deserializes"); + let mut runner = GameRunner::from_state(restored.into_game_state()); + assert!( + runner.state().clause_minimum_snapshot.is_some(), + "the paused discard clause's frozen hand minimum must survive authoritative restore" + ); + + let mut prompts = 0; + while let WaitingFor::ReplacementChoice { candidates, .. } = runner.state().waiting_for.clone() + { + let index = candidates + .iter() + .position(|candidate| candidate.description == "Decline") + .expect("Library of Leng must offer Decline"); + runner + .act(GameAction::ChooseReplacement { index }) + .expect("declining Library of Leng must resume the parked batch"); + prompts += 1; + } + runner.advance_until_stack_empty(); + + assert_eq!(prompts, 2, "P0's two required discards must each pause"); + assert_eq!(hand_len(runner.state(), P0), 1, "P0 must discard down to 1"); + assert_eq!( + hand_len(runner.state(), P1), + 1, + "P1 was already at the minimum" + ); +} diff --git a/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs b/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs index 80287a2260..1368ee0b1a 100644 --- a/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs +++ b/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs @@ -26,7 +26,7 @@ use engine::types::ability::{ AbilityDefinition, AbilityKind, Effect, ReplacementDefinition, ReplacementMode, TargetFilter, }; use engine::types::actions::GameAction; -use engine::types::game_state::{PersistedGameState, WaitingFor}; +use engine::types::game_state::WaitingFor; use engine::types::identifiers::ObjectId; use engine::types::mana::ManaCost; use engine::types::phase::Phase; @@ -611,66 +611,6 @@ fn windfall_paused_mid_fan_out_still_draws_the_greatest() { ); } -/// A replacement choice splits one still-resolving Windfall instruction across -/// an authoritative save/restore boundary. The draw clause must use the -/// `PreviousEffectAmount` captured for that in-flight application, rather than -/// re-read a partially restored ledger or live hand sizes after the pause. -/// -/// Discriminating: the save occurs only after the real cast pipeline has -/// parked `PendingDiscardBatch` at a `ReplacementChoice`; removing the -/// snapshot's serde support fails the restored-snapshot reach guard before the -/// resumed production pipeline is driven. -#[test] -fn windfall_save_during_replacement_choice_preserves_frozen_greatest_discard() { - let mut scenario = GameScenario::new_n_player(4, 42); - scenario.at_phase(Phase::PreCombatMain); - for (seat, hand) in SEATS.iter().zip(PAUSED_HANDS) { - seed_hand_ids(&mut scenario, *seat, hand); - seed_library(&mut scenario, *seat, LIBRARY_DEPTH); - } - scenario - .add_creature_from_oracle(P0, "Library of Leng", 1, 1, LIBRARY_OF_LENG) - .as_artifact(); - let windfall = scenario - .add_spell_to_hand_from_oracle(P0, "Windfall", false, WINDFALL) - .with_mana_cost(ManaCost::zero()) - .id(); - let mut runner = scenario.build(); - - runner.cast(windfall).resolve(); - assert!( - matches!(runner.state().waiting_for, WaitingFor::ReplacementChoice { .. }) - && runner.state().pending_discard_batch.is_some() - && runner.state().clause_minimum_snapshot.is_some(), - "reach guard: the production cast must park both the discard continuation and its frozen clause value" - ); - - let saved = serde_json::to_string(&PersistedGameState::capture(runner.state().clone())) - .expect("the authoritative paused state serializes"); - let restored: PersistedGameState = - serde_json::from_str(&saved).expect("the authoritative paused state deserializes"); - let mut runner = GameRunner::from_state(restored.into_game_state()); - assert!( - runner.state().clause_minimum_snapshot.is_some(), - "the paused resolution's frozen clause value must survive authoritative restore" - ); - - let prompts = answer_every_replacement_choice(&mut runner, "Decline"); - runner.advance_until_stack_empty(); - - assert_eq!( - ( - prompts, - SEATS - .iter() - .map(|p| LIBRARY_DEPTH - state_zone_len(&runner, *p, Zone::Library)) - .collect::>(), - ), - (7, vec![7, 7, 7, 7]), - "restoring a parked Windfall must resume the original CR 608.2h draw value" - ); -} - /// Every observable of the gate-2 arm, asserted as ONE value. #[derive(Debug, PartialEq, Eq)] struct GateTwoSignature { From 7a35e6b36243597b00d11cea26417cf7ca7c8f63 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Mon, 17 Aug 2026 04:09:42 -0700 Subject: [PATCH 19/26] fix(PR-7494): persist Balance snapshot at discard choice --- crates/engine/src/game/effects/discard.rs | 32 +++++++------- crates/engine/src/game/engine_replacement.rs | 9 ++-- .../tests/integration/balance_equalization.rs | 42 ++++++++----------- 3 files changed, 37 insertions(+), 46 deletions(-) diff --git a/crates/engine/src/game/effects/discard.rs b/crates/engine/src/game/effects/discard.rs index 62fd67d938..0cf506b284 100644 --- a/crates/engine/src/game/effects/discard.rs +++ b/crates/engine/src/game/effects/discard.rs @@ -104,7 +104,7 @@ pub(crate) fn complete_discard_to_graveyard( return DiscardOutcome::Complete; } ReplacementResult::NeedsChoice(player) => { - // CR 616.1: The event retains `discard_frame` on the paused + // CR 614.1: The replacement-effect pipeline retains `discard_frame` on the paused // ZoneChange. Generic replacement resume returns to terminal zone // delivery, which appends the exact result and emits bookkeeping. return DiscardOutcome::NeedsReplacementChoice(player); @@ -158,11 +158,11 @@ pub(crate) fn hand_off_recruit_discard_result( /// Park what this seat's discard instruction still owes so the replacement /// resume can finish it. /// -/// CR 616.1 is the pause: "the affected object's controller … or the affected -/// player chooses one to apply". CR 701.9a is what is still owed: each remaining -/// card must still be moved from its owner's hand to their graveyard. This is -/// the SINGLE AUTHORITY for "this batch paused" — both selection modes park -/// through it, so the two cannot drift on what a parked batch means. +/// CR 614.1: a replacement effect can pause this instruction while it is being +/// applied. CR 701.9a is what is still owed: each remaining card must still be +/// moved from its owner's hand to their graveyard. This is the SINGLE AUTHORITY +/// for "this batch paused" — both selection modes park through it, so the two +/// cannot drift on what a parked batch means. /// /// Deliberately private and called ONLY from `resolve`, the effect layer. The /// cost layer owns its own typed cursor (`PendingCostMoveResume:: @@ -511,7 +511,7 @@ pub fn resolve( // CR 701.9a: this is a resolving effect, so Library-of-Leng-class // replacements DO apply — `DiscardCause::Effect`. // - // CR 616.1: a replacement-application choice mid-batch parks the + // CR 614.1: a replacement-application choice mid-batch parks the // cursor `discard_at_random` returns rather than dropping it; // `drain_pending_discard_batch` (effects/mod.rs) finishes the // remaining picks and publishes the terminal marker. The COST caller @@ -572,7 +572,7 @@ pub fn resolve( { state.waiting_for = crate::game::replacement::replacement_choice_waiting_for(player, state); - // CR 616.1 + CR 701.9a: park the un-iterated tail instead of + // CR 614.1 + CR 701.9a: park the un-iterated tail instead of // abandoning it. `hand_cards[i + 1..]` and not `[i..]`: the // paused card is settled by the replacement itself, exactly // as `discard_at_random`'s cursor documents. The terminal @@ -725,14 +725,14 @@ pub(crate) enum RandomDiscardOutcome { /// identity to stamp the terminal `Discarded` the resumed zone-change /// arm cannot emit. The cost layer does not consume it. paused_card: ObjectId, - /// CR 616.1: the player who chooses among the applicable replacement - /// effects. Published by this authority rather than re-derived at the - /// call site, because it is NOT always the discarding player — see the - /// commander carve-out in `replacement_choice_player`, where the choice - /// belongs to a seat other than the affected one. A re-parking caller - /// that assumed `request.player` would prompt the wrong seat the moment - /// such a case reaches a random discard. Mirrors the `chooser` the - /// single-card `DiscardOutcome::NeedsReplacementChoice` already carries, + /// The replacement pipeline's selected chooser. Published by this + /// authority rather than re-derived at the call site, because it is NOT + /// always the discarding player — see the commander carve-out in + /// `replacement_choice_player`, where the choice belongs to a seat other + /// than the affected one. A re-parking caller that assumed + /// `request.player` would prompt the wrong seat the moment such a case + /// reaches a random discard. Mirrors the `chooser` the single-card + /// `DiscardOutcome::NeedsReplacementChoice` already carries, /// so both cursor arms read one contract. chooser: PlayerId, }, diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index 753e184d26..c8a99a49bb 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -1036,12 +1036,11 @@ pub(super) fn handle_replacement_choice( } } - // CR 616.1 + CR 608.2f: a discard instruction parked mid-batch by a + // CR 608.2c: a discard instruction parked mid-batch by a // replacement-application choice finishes what it still owes BEFORE - // any parked continuation runs — the same ordering the simultaneous - // sacrifice block above states, for the same reason: the clause is - // ONE action, so the instructions after it must not resume until it - // has settled and published its terminal result. + // any parked continuation runs. The resolving effect follows its + // instructions in written order, so later instructions cannot resume + // until this action has settled and published its terminal result. if matches!(waiting_for, WaitingFor::Priority { .. }) && state.pending_discard_batch.is_some() { diff --git a/crates/engine/tests/integration/balance_equalization.rs b/crates/engine/tests/integration/balance_equalization.rs index f4ad98f2bc..fde6a321ad 100644 --- a/crates/engine/tests/integration/balance_equalization.rs +++ b/crates/engine/tests/integration/balance_equalization.rs @@ -40,8 +40,6 @@ control equal to the number of lands controlled by the player who controls \ the fewest, then sacrifices the rest. Players discard cards and sacrifice \ creatures the same way."; -const LIBRARY_OF_LENG_ORACLE: &str = "You have no maximum hand size.\nIf an effect causes you to discard a card, discard it, but you may put it on top of your library instead of into your graveyard."; - /// Build Balance's resolved ability chain controlled by `controller`. fn balance_ability(controller: PlayerId, source_id: ObjectId) -> ResolvedAbility { let def = parse_effect_chain(BALANCE_ORACLE, AbilityKind::Spell); @@ -364,26 +362,24 @@ fn balance_three_player_interactive_fan_out_equalizes() { } } -/// A replacement choice pauses Balance's discard-down-to-the-fewest-cards -/// clause after its cross-player hand-size minimum has been frozen. The -/// authoritative save/restore path must preserve that still-live value: resume -/// continues the same application rather than determining a new minimum. +/// Balance's discard choice pauses after its cross-player hand-size minimum has +/// been frozen. The authoritative save/restore path must preserve that +/// still-live value: resume continues the same application rather than +/// determining a new minimum. /// /// Discriminating: P0's three-card hand must discard down to P1's one-card -/// minimum. The test saves only after the real cast pipeline parks the discard -/// batch at a `ReplacementChoice`; removing the snapshot's serde support leaves -/// the restored reach guard empty before the resumed production path runs. +/// minimum. The test saves only after the real cast pipeline reaches the +/// `DiscardChoice` that selects P0's two discards; removing the snapshot's +/// serde support leaves the restored reach guard empty before the resumed +/// production path runs. #[test] -fn balance_save_during_discard_replacement_preserves_frozen_hand_minimum() { +fn balance_save_during_discard_choice_preserves_frozen_hand_minimum() { let mut scenario = GameScenario::new(); scenario.at_phase(Phase::PreCombatMain); for i in 0..3 { scenario.add_card_to_hand(P0, &format!("P0 hand card {i}")); } scenario.add_card_to_hand(P1, "P1 hand card"); - scenario - .add_creature_from_oracle(P0, "Library of Leng", 1, 1, LIBRARY_OF_LENG_ORACLE) - .as_artifact(); let balance = scenario .add_spell_to_hand_from_oracle(P0, "Balance", false, BALANCE_ORACLE) .with_mana_cost(ManaCost::zero()) @@ -392,10 +388,9 @@ fn balance_save_during_discard_replacement_preserves_frozen_hand_minimum() { runner.cast(balance).resolve(); assert!( - matches!(runner.state().waiting_for, WaitingFor::ReplacementChoice { .. }) - && runner.state().pending_discard_batch.is_some() + matches!(runner.state().waiting_for, WaitingFor::DiscardChoice { .. }) && runner.state().clause_minimum_snapshot.is_some(), - "reach guard: the production cast must park Balance's discard batch with its frozen hand minimum" + "reach guard: the production cast must park Balance's discard choice with its frozen hand minimum" ); let saved = serde_json::to_string(&PersistedGameState::capture(runner.state().clone())) @@ -409,20 +404,17 @@ fn balance_save_during_discard_replacement_preserves_frozen_hand_minimum() { ); let mut prompts = 0; - while let WaitingFor::ReplacementChoice { candidates, .. } = runner.state().waiting_for.clone() - { - let index = candidates - .iter() - .position(|candidate| candidate.description == "Decline") - .expect("Library of Leng must offer Decline"); + while let WaitingFor::DiscardChoice { cards, count, .. } = runner.state().waiting_for.clone() { runner - .act(GameAction::ChooseReplacement { index }) - .expect("declining Library of Leng must resume the parked batch"); + .act(GameAction::SelectCards { + cards: cards.into_iter().take(count).collect(), + }) + .expect("selecting Balance's required discards must resume the cast"); prompts += 1; } runner.advance_until_stack_empty(); - assert_eq!(prompts, 2, "P0's two required discards must each pause"); + assert_eq!(prompts, 1, "P0's two required discards share one choice"); assert_eq!(hand_len(runner.state(), P0), 1, "P0 must discard down to 1"); assert_eq!( hand_len(runner.state(), P1), From 64833fa6cbabd3e9f8212a52fa849ad3db202b10 Mon Sep 17 00:00:00 2001 From: lgray Date: Mon, 17 Aug 2026 12:54:13 -0500 Subject: [PATCH 20/26] fix(PR-7494): bind a paused discard to its parked object incarnation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PendingDiscardBatch.paused_card` was a bare `ObjectId`, and the resume stamp accepted any hand departure carrying that id. CR 400.7 makes an object that changes zones a new object, so a same-id round trip let a later incarnation's departure settle a pause it never belonged to. The provenance was already on the wire. Every production `ZoneChangeRecord` is built by `GameObject::snapshot_for_zone_change` before the incarnation bump, so its `trigger_source_context.identity.reference` is exactly the pre-move occurrence. Retype `paused_card` to `ObjectIncarnationRef` — whose `#[serde(from = "ObjectIncarnationRefCompat")]` already carries the save migration for this exact CR 400.7 reason — pin it at each pause via `pin_paused_occurrence`, and match that occurrence rather than the id. A record with no context is legacy or hand-built and now fails closed, which is the policy `ZoneChangeRecord::trigger_source_context`'s own doc states: callers must not reconstruct a source from a current object. Ungating `entered_incarnation` was rejected rather than overlooked: `resolved_commands.rs` asserts a replay invariant that a non-battlefield destination must leave it `None`. The CR 603.5 prompt census pin moves `:11103` to `:11124`, a pure line shift. The producer's 9-line block hashes `bc850c67` at both coordinates and is re-found at exactly one place in the tree, so the entry moved rather than the set changing. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/effects/discard.rs | 50 ++++++- crates/engine/src/game/effects/mod.rs | 151 +++++++++++++++++++++- crates/engine/src/game/engine.rs | 9 +- crates/engine/src/game/visibility.rs | 2 +- crates/engine/src/types/game_state.rs | 12 +- 5 files changed, 209 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/game/effects/discard.rs b/crates/engine/src/game/effects/discard.rs index 0cf506b284..48b2f0092d 100644 --- a/crates/engine/src/game/effects/discard.rs +++ b/crates/engine/src/game/effects/discard.rs @@ -11,7 +11,7 @@ use crate::types::ability::{ }; use crate::types::events::GameEvent; use crate::types::game_state::GameState; -use crate::types::identifiers::ObjectId; +use crate::types::identifiers::{ObjectId, ObjectIncarnationRef, LEGACY_INCARNATION}; use crate::types::player::PlayerId; use crate::types::proposed_event::{AppliedReplacementKey, ProposedEvent}; use crate::types::zones::Zone; @@ -177,7 +177,7 @@ fn park_discard_batch( cursor: crate::types::game_state::DiscardBatchCursor, source_id: ObjectId, effect_kind: EffectKind, - paused_card: ObjectId, + paused_card: ObjectIncarnationRef, discard_frame: Option, preceding_events: Vec, ) { @@ -195,6 +195,25 @@ fn park_discard_batch( })); } +/// CR 400.7: pin the occurrence a replacement pause parked, while the card is +/// still in its pre-move zone. +/// +/// A pause is only ever raised for a live hand card, so the lookup cannot +/// legitimately miss. The fallback pins `LEGACY_INCARNATION`, which no live +/// object can carry — the resume match then fails closed instead of letting a +/// bare `ObjectId` settle the pause against whichever occurrence happens to be +/// leaving the hand. +pub(crate) fn pin_paused_occurrence( + state: &GameState, + object_id: ObjectId, +) -> ObjectIncarnationRef { + state + .objects + .get(&object_id) + .map(ObjectIncarnationRef::from_object) + .unwrap_or_else(|| ObjectIncarnationRef::of(object_id, LEGACY_INCARNATION)) +} + /// CR 701.9a: To discard a card, move it from owner's hand to their graveyard. /// If targets specify specific cards, discard those; otherwise discard from end of hand. pub fn resolve( @@ -586,7 +605,8 @@ pub fn resolve( }, ability.source_id, EffectKind::from(&ability.effect), - *obj_id, + // CR 400.7: the pause parks the pre-move occurrence. + pin_paused_occurrence(state, *obj_id), discard_frame, events[events_before_self..].to_vec(), ); @@ -724,7 +744,11 @@ pub(crate) enum RandomDiscardOutcome { /// card was still discarded and the effect layer's drain needs its /// identity to stamp the terminal `Discarded` the resumed zone-change /// arm cannot emit. The cost layer does not consume it. - paused_card: ObjectId, + /// + /// CR 400.7: the PRE-move occurrence, captured while the card is still + /// in hand. The drain settles the pause against this exact occurrence + /// leaving the hand, so a later same-id occurrence cannot claim it. + paused_card: ObjectIncarnationRef, /// The replacement pipeline's selected chooser. Published by this /// authority rather than re-derived at the call site, because it is NOT /// always the discarding player — see the commander carve-out in @@ -820,7 +844,9 @@ pub(crate) fn discard_at_random( // The paused pick is settled by the replacement itself, so the // resumed batch owes only the picks after it. remaining_count: count - pick - 1, - paused_card: obj_id, + // CR 400.7: pinned before the redirect moves it, so the resume + // settles against this occurrence and not a later same-id one. + paused_card: pin_paused_occurrence(state, obj_id), // Same value this function just set `waiting_for` from, so a // re-parking caller cannot drift from the prompt actually shown. chooser, @@ -1232,9 +1258,21 @@ mod random_discard_authority_tests { // The cursor's two halves must agree on WHICH card paused: the reported // paused card is the one missing from the un-picked pool. assert!( - hand.contains(&paused_card) && !remaining_eligible.contains(&paused_card), + hand.contains(&paused_card.object_id) + && !remaining_eligible.contains(&paused_card.object_id), "the paused card must be a hand card that left the un-picked pool" ); + // CR 400.7: the pin is the PRE-move occurrence, so it must still name + // the live hand card. A pin taken after the redirect would carry the + // bumped incarnation and never match the departure it is meant to settle. + assert_eq!( + Some(paused_card), + state + .objects + .get(&paused_card.object_id) + .map(ObjectIncarnationRef::from_object), + "the parked pin must equal the live pre-move occurrence" + ); // CR 616.1: the published chooser must be the seat this authority // actually prompted. A re-parking caller reads `chooser` to rebuild the // prompt, so if the two ever disagree the wrong seat is asked. Compared diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 4285f5b94f..d7fc84c65f 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -9486,7 +9486,10 @@ pub(crate) fn drain_pending_discard_batch( batch.cursor = DiscardBatchCursor::All { remaining: remaining[i + 1..].to_vec(), }; - batch.paused_card = *obj_id; + // CR 400.7: pin the pre-move occurrence, matching the park + // the `Random` arm below already receives from + // `discard_at_random`. + batch.paused_card = discard::pin_paused_occurrence(state, *obj_id); repark_discard_batch(state, batch, events, chooser); return Ok(PendingDiscardBatchOutcome::PausedForReplacement); } @@ -9681,7 +9684,8 @@ fn stamp_resumed_discard_if_unrecorded( batch: &crate::types::game_state::PendingDiscardBatch, events: &mut Vec, ) { - let card = batch.paused_card; + let paused = batch.paused_card; + let card = paused.object_id; let already_recorded = events.iter().any(|event| { matches!( event, @@ -9691,14 +9695,31 @@ fn stamp_resumed_discard_if_unrecorded( if already_recorded { return; } + // CR 400.7: "An object that moves from one zone to another becomes a new + // object with no memory of, or relation to, its previous existence." The + // departure that settles this pause is the parked occurrence leaving the + // hand — not any hand departure that happens to reuse the `ObjectId`. A + // same-id round trip (the card returns to hand and leaves again) produces a + // later occurrence, and stamping this batch's `Discarded` from it would + // credit the discard to an object the pause never parked. + // + // The departing occurrence is already on the wire: every production record + // is built by `GameObject::snapshot_for_zone_change` BEFORE the incarnation + // bump, so `trigger_source_context.identity` is exactly the pre-move + // occurrence and its `expected_zone` is the zone it left. A record without + // that context is legacy/hand-built; it fails closed here rather than + // falling back to the id, which is the same policy the record's own doc + // states ("Callers must not reconstruct a source from a current object"). let left_hand = events.iter().any(|event| { matches!( event, GameEvent::ZoneChanged { - object_id, from: Some(crate::types::zones::Zone::Hand), + record, .. - } if *object_id == card + } if record + .trigger_source_context() + .is_some_and(|context| context.identity.reference == paused) ) }); if !left_hand { @@ -19505,7 +19526,10 @@ mod tests { cursor: DiscardBatchCursor::All { remaining }, source_id, effect_kind: EffectKind::Discard, - paused_card: ObjectId(9_999_999), + paused_card: crate::types::identifiers::ObjectIncarnationRef::of( + ObjectId(9_999_999), + 0, + ), discard_frame: None, fan_out, preceding_events: Vec::new(), @@ -19598,6 +19622,123 @@ mod tests { ); } + /// Drive one `ObjectId` through hand → graveyard → hand → graveyard with the + /// production zone authority, returning the live occurrence pinned before + /// each hand departure together with that departure's real `ZoneChanged`. + /// + /// Both records are produced by `GameObject::snapshot_for_zone_change`, so + /// the identity under test is the one production writes, not a hand-built + /// stand-in. The batch itself is still parked directly because no card in + /// the corpus returns a card to hand mid-instruction — see the PR notes. + fn hand_departures_across_a_round_trip( + state: &mut GameState, + card: ObjectId, + ) -> [(crate::types::identifiers::ObjectIncarnationRef, GameEvent); 2] { + let mut departures = Vec::new(); + for to in [Zone::Graveyard, Zone::Hand, Zone::Graveyard] { + let before = + crate::types::identifiers::ObjectIncarnationRef::from_object(&state.objects[&card]); + let from_hand = state.objects[&card].zone == Zone::Hand; + let mut moved = Vec::new(); + crate::game::zones::move_to_zone(state, card, to, &mut moved); + if from_hand { + let event = moved + .into_iter() + .find(|event| { + matches!( + event, + GameEvent::ZoneChanged { object_id, from: Some(Zone::Hand), .. } + if *object_id == card + ) + }) + .expect("a hand departure emits its ZoneChanged"); + departures.push((before, event)); + } + } + let [first, second]: [_; 2] = departures + .try_into() + .unwrap_or_else(|_| panic!("the round trip makes exactly two hand departures")); + assert_ne!( + first.0.incarnation, second.0.incarnation, + "reach guard: the round trip must really advance the incarnation, \ + otherwise the two arms below are the same test twice" + ); + [first, second] + } + + /// CR 400.7: "An object that moves from one zone to another becomes a new + /// object with no memory of, or relation to, its previous existence." + /// + /// The parked batch pins the occurrence whose replacement paused. After a + /// same-`ObjectId` round trip, a LATER occurrence's hand departure must not + /// settle that pause — stamping it would credit the parked discard to an + /// object the batch never parked. The matched positive arm proves the pin + /// still accepts its own departure, so the negative arm is a discriminator + /// and not a blanket refusal to stamp. + /// + /// REVERT PROBE (RUN, not reasoned): restore the bare-id predicate in + /// `stamp_resumed_discard_if_unrecorded` — + /// `GameEvent::ZoneChanged { object_id, from: Some(Zone::Hand), .. } if + /// *object_id == card`. Observed failure — "a later incarnation's hand + /// departure must not settle this pause / left: 1 / right: 0". The positive + /// arm keeps passing under the revert, which is what makes the negative arm + /// the discriminating one. + #[test] + fn resumed_discard_stamp_rejects_a_later_incarnation_of_the_paused_card() { + let stamped_discards = |state: &mut GameState, + card: ObjectId, + pin: crate::types::identifiers::ObjectIncarnationRef, + departure: GameEvent| { + let source = ObjectId(100); + park_batch(state, source, PlayerId(0), Vec::new(), None); + state.pending_discard_batch.as_mut().unwrap().paused_card = pin; + let mut events = vec![departure]; + drain_pending_discard_batch(state, &mut events).unwrap(); + events + .iter() + .filter(|event| { + matches!(event, GameEvent::Discarded { object_id, .. } if *object_id == card) + }) + .count() + }; + + let mut state = GameState::new_two_player(42); + let card = create_object( + &mut state, + CardId(4_000), + PlayerId(0), + "Round Tripper".to_string(), + Zone::Hand, + ); + let [(first_pin, first_departure), (later_pin, later_departure)] = + hand_departures_across_a_round_trip(&mut state, card); + + // Negative arm: the pause parked the FIRST occurrence; the resume window + // carries only the LATER occurrence's departure. + assert_eq!( + stamped_discards(&mut state, card, first_pin, later_departure), + 0, + "a later incarnation's hand departure must not settle this pause" + ); + + // Positive arm: the same pin, offered its own departure, still stamps. + assert_eq!( + stamped_discards(&mut state, card, first_pin, first_departure.clone()), + 1, + "the parked occurrence's own departure must still stamp exactly one \ + Discarded, or the negative arm above proves nothing" + ); + + // The later pin is equally bound: it accepts its own departure and not + // the earlier one, so the predicate is an equality on the occurrence + // rather than an ordering test. + assert_eq!( + stamped_discards(&mut state, card, later_pin, first_departure), + 0, + "an earlier incarnation's departure must not settle a later pause" + ); + } + /// CR 608.2c + CR 101.3: the drain's per-seat resumption boundary. /// /// `cost_payment_failed_flag` is per-iteration. Seat 1 is empty-handed, so diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 9025b7e22f..7d89683943 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -19303,7 +19303,14 @@ mod stage2_injector_tests { // total is unchanged. "game/effects/mod.rs:7319".to_string(), "game/effects/mod.rs:7396".to_string(), - "game/effects/mod.rs:11103".to_string(), + // Incarnation-pin round: `:11103 ⇒ :11124`, a pure +21 line shift from + // the `stamp_resumed_discard_if_unrecorded` CR 400.7 comment block and + // the new round-trip regression, both of which sit ABOVE this producer + // and mint no prompt. Located by digest, not arithmetic: the producer's + // 9-line block hashes `bc850c67` at the committed tip's `:11103` and is + // re-found at exactly ONE coordinate in the working tree (`:11124`), so + // the entry moved rather than the SET changing. + "game/effects/mod.rs:11124".to_string(), // UNMOVED across the rebase, and that is itself evidence the SET did not // move: a census that had gained or lost a producer would not leave this // entry both byte-identical AND at the same coordinate. diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index cfc1b65b67..debfa83318 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -6219,7 +6219,7 @@ mod tests { }, source_id: ObjectId(9_300), effect_kind: crate::types::ability::EffectKind::Discard, - paused_card: hidden, + paused_card: crate::types::identifiers::ObjectIncarnationRef::of(hidden, 0), discard_frame: None, fan_out: None, preceding_events: Vec::new(), diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 28d048fc4b..65880c7fda 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -4108,7 +4108,15 @@ pub struct PendingDiscardBatch { /// `GameEvent::Discarded` for an unframed discard, so the drain stamps one /// from this id. Without it the paused card is the one card that silently /// leaves the ledger even though the batch resumed correctly. - pub paused_card: ObjectId, + /// + /// CR 400.7: pinned as an incarnation reference, not a bare `ObjectId`. The + /// pause parks the pre-move occurrence; the departure that settles it is + /// that same occurrence leaving the hand. A later same-`ObjectId` occurrence + /// (the card returned to hand and left again) is a different object and must + /// not be able to satisfy this pause — see + /// `stamp_resumed_discard_if_unrecorded`, which matches the departing + /// occurrence carried on the zone-change record rather than the id alone. + pub paused_card: ObjectIncarnationRef, #[serde(default, skip_serializing_if = "Option::is_none")] pub discard_frame: Option, /// CR 608.2f: the clause and the seats it has not reached, installed by the @@ -25781,7 +25789,7 @@ mod tests { }, source_id: ObjectId(9_300), effect_kind: crate::types::ability::EffectKind::Discard, - paused_card: ObjectId(9_303), + paused_card: ObjectIncarnationRef::of(ObjectId(9_303), 0), discard_frame: None, fan_out: None, preceding_events: vec![persisted_zone_change_event(record)], From a9d9ec8c8a0ab2855720476b82fec32c8bbb47b2 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Mon, 17 Aug 2026 12:06:09 -0700 Subject: [PATCH 21/26] fix(PR-7494): correct replacement rule annotations --- crates/engine/src/game/effects/mod.rs | 13 +++++++------ crates/engine/src/types/game_state.rs | 22 ++++++---------------- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 5f2c67f6f8..723e6425a3 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -9450,10 +9450,11 @@ pub(crate) enum PendingDiscardBatchOutcome { /// Finish a discard instruction that a replacement-application choice parked /// mid-batch, and publish its terminal result ONCE. /// -/// CR 616.1 is what parked it. CR 608.2f is why the remainder belongs here and -/// not on the generic continuation queue: the clause is one action taken on -/// several players, processed per player only because it could not be processed -/// simultaneously — so it still has exactly one terminal result. +/// CR 614.1: the replacement application pauses the event as it happens. CR +/// 608.2f is why the remainder belongs here and not on the generic continuation +/// queue: the clause is one action taken on several players, processed per +/// player only because it could not be processed simultaneously — so it still +/// has exactly one terminal result. /// /// Composability: an arbitrary number of sequential re-pauses compose, because /// each resume re-enters this same function through the same hook. @@ -9524,8 +9525,8 @@ pub(crate) fn drain_pending_discard_batch( remaining: remaining_count, }; batch.paused_card = paused_card; - // CR 616.1: the chooser comes from the authority that raised - // the choice, exactly as the `All` arm above threads its own. + // The chooser comes from the authority that raised the choice, + // exactly as the `All` arm above threads its own. // It was `batch.player` here, which happens to agree today // because a hand card's `affected_player` is its controller — // but `replacement_choice_player`'s commander carve-out proves diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index a6ff0ee078..9059dd4449 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -4058,9 +4058,8 @@ pub enum PendingPlayerScopeSacrificeFollowUp { Exploit { exploiter: ObjectId }, } -/// One discard instruction, parked mid-batch because a replacement-application -/// choice interrupted it (CR 616.1: "the affected object's controller … or the -/// affected player chooses one to apply"). +/// One discard instruction, parked mid-batch while an optional replacement +/// application awaits its apply-or-decline choice. /// /// This is [`PendingPlayerScopeSacrificeChoice`]'s sibling one layer down, and /// it carries the same two things across the pause: the **cursor** is what the @@ -4069,19 +4068,10 @@ pub enum PendingPlayerScopeSacrificeFollowUp { /// that type first — every mechanism here is its, with the two deliberate /// divergences noted on `preceding_events` and in `drain_pending_discard_batch`. /// -/// SCOPE OF THE CR 616.1 CITATIONS HERE, stated because a reader who looks the -/// rule up will otherwise find it describing a case the fixtures never hit. -/// CR 616.1 governs the two-or-more-applicable case: "If **two or more** -/// replacement and/or prevention effects are attempting to modify the way an -/// event affects an object or player, the affected object's controller … or -/// the affected player chooses one to apply." The engine ALSO surfaces a -/// `ReplacementChoice` prompt for a *single* `ReplacementMode::Optional` -/// replacement — apply-or-decline — which 616.1 does not describe. Every pause -/// this type carries is of that second kind in practice (the Library of Leng -/// arm's seven prompts all come from one optional replacement). 616.1 is cited -/// throughout for the choice MECHANISM and its APNAP ordering, which both kinds -/// share; CR 614.6 is what says a replaced event never happens and a modified -/// one happens instead. +/// CR 614.1: replacement effects apply as events happen. This batch preserves +/// the cursor and already-produced events while the selected optional +/// replacement is applied or declined, then resumes the same discard +/// instruction. /// /// The companion `GameState::clause_minimum_snapshot` persists with this batch: /// a save taken mid-pause must resume the same CR 608.2h application with its From e7922fa8a32d4f92e5cab8767e8ab6d082dcabef Mon Sep 17 00:00:00 2001 From: lgray Date: Mon, 17 Aug 2026 14:38:08 -0500 Subject: [PATCH 22/26] fix(PR-7494): repair the census pin list and finish the CR 616.1 retirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The port across main resolved the CR 603.5 census conflict by keeping BOTH sides of the pin list — main's `:7267/:7344/:10624` and this branch's `:11124` — giving four `effects/mod.rs` entries. There are three producers in that file, so the vector could not match a census computed from source and it contradicted the `(5, 8, 28)` partition assert immediately above it. A union is the wrong merge for a list whose length is asserted: the entries are one coordinate per producer, not additive facts. Measured at `a9d9ec8c8`: left (from source) 5: mod.rs:7325, mod.rs:7402, mod.rs:11131, ... right (pinned) 6: mod.rs:7267, mod.rs:7344, mod.rs:10624, mod.rs:11124, ... Re-measured by digest, not arithmetic: each producer's 9-line block hashed at upstream/main (`f9098299`/`96338f0e`/`bc850c67`) is found at exactly one coordinate. The shift is non-uniform (+58/+58/+507), so adding a delta to all three would have written three wrong numbers. Also finishes the CR 616.1 retirement the review note asked for. The annotation fix reached effects/mod.rs and game_state.rs; the single-optional discard path spans six files, leaving nine assertions live — including windfall_greatest_discard_aggregate.rs, which said "P0 controls an OPTIONAL discard replacement" while citing the two-or-more rule. Replaced with CR 608.2c for instruction order and CR 614.6 where the point is that a replaced event never happens, both grep-verified. Pre-existing CR 616.1 citations elsewhere are untouched; only the nine this PR introduced are changed. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/effects/discard.rs | 4 +-- crates/engine/src/game/effects/mod.rs | 2 +- crates/engine/src/game/engine.rs | 36 +++++++++++++------ crates/engine/src/game/visibility.rs | 2 +- crates/engine/src/types/game_state.rs | 13 ++++--- .../random_discard_cost_replacement_resume.rs | 2 +- .../windfall_greatest_discard_aggregate.rs | 12 ++++--- 7 files changed, 47 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/game/effects/discard.rs b/crates/engine/src/game/effects/discard.rs index 48b2f0092d..e8402f9d96 100644 --- a/crates/engine/src/game/effects/discard.rs +++ b/crates/engine/src/game/effects/discard.rs @@ -1273,8 +1273,8 @@ mod random_discard_authority_tests { .map(ObjectIncarnationRef::from_object), "the parked pin must equal the live pre-move occurrence" ); - // CR 616.1: the published chooser must be the seat this authority - // actually prompted. A re-parking caller reads `chooser` to rebuild the + // The published chooser must be the seat this authority actually + // prompted. A re-parking caller reads `chooser` to rebuild the // prompt, so if the two ever disagree the wrong seat is asked. Compared // against `waiting_for` rather than against the request's player, // because agreeing with the request is the very assumption this pins diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 723e6425a3..d640234af3 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -19485,7 +19485,7 @@ mod tests { } // --------------------------------------------------------------------- - // The CR 616.1 discard-batch carrier. + // The discard-batch carrier (CR 608.2c parked order + CR 614.6 replacement). // --------------------------------------------------------------------- /// A `player_scope: All` "each player discards a card" clause template. diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 63aba940e2..04162abd37 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -19304,17 +19304,31 @@ mod stage2_injector_tests { // #7496's parked Cipher frame extends the exhaustive resume dispatch above // all three producers. It adds six lines without assigning an optional-effect // prompt, so the measured production producers move uniformly by `+6`. - "game/effects/mod.rs:7267".to_string(), - "game/effects/mod.rs:7344".to_string(), - "game/effects/mod.rs:10624".to_string(), - // Incarnation-pin round: `:11103 ⇒ :11124`, a pure +21 line shift from - // the `stamp_resumed_discard_if_unrecorded` CR 400.7 comment block and - // the new round-trip regression, both of which sit ABOVE this producer - // and mint no prompt. Located by digest, not arithmetic: the producer's - // 9-line block hashes `bc850c67` at the committed tip's `:11103` and is - // re-found at exactly ONE coordinate in the working tree (`:11124`), so - // the entry moved rather than the SET changing. - "game/effects/mod.rs:11124".to_string(), + // Incarnation-pin round and the port across main, adjudicated together + // because the port is what moved the pre-port coordinates. + // + // The port's conflict resolution kept BOTH sides of this list: main's + // `:7267/:7344/:10624` and the branch's `:11124`, giving FOUR + // `effects/mod.rs` entries. There are only THREE producers in that file, + // so the union could not match a census computed from source, and it + // contradicted the `(5, 8, 28)` partition assert directly above. A union + // is the wrong merge for a list whose LENGTH is asserted: these entries + // are not additive facts, they are one coordinate per producer. + // + // Re-measured in the ported tree BY DIGEST, not by arithmetic: each + // producer's 9-line block was hashed at `upstream/main` + // (`f9098299`/`96338f0e`/`bc850c67`) and each digest is found at exactly + // ONE coordinate here. `:7267/:7344/:10624` => `:7325/:7402/:11131`. + // + // The shift is NON-UNIFORM (`+58/+58/+507`): this branch's insertions are + // not all above the first producer, and the third sits below every + // discard-carrier and test addition. `bc850c67` is byte-identical to its + // value before the port — the same digest that pinned this producer at + // `:11103` and `:11124` earlier in this log — which is the evidence that + // it MOVED rather than being replaced. + "game/effects/mod.rs:7325".to_string(), + "game/effects/mod.rs:7402".to_string(), + "game/effects/mod.rs:11131".to_string(), // UNMOVED across the rebase, and that is itself evidence the SET did not // move: a census that had gained or lost a producer would not leave this // entry both byte-identical AND at the same coordinate. diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index debfa83318..f5c7261939 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -6188,7 +6188,7 @@ mod tests { ); } - /// CR 400.2 + CR 616.1: `pending_discard_batch` is the EFFECT layer's twin + /// CR 400.2 + CR 608.2c: `pending_discard_batch` is the EFFECT layer's twin /// of the cost cursor above. It retains the object IDs of cards still in a /// HAND — a hidden zone — plus the instruction's pre-pause event span, so it /// must be absent from every viewer projection, including the projection of diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 9059dd4449..0be76f05db 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -16809,8 +16809,9 @@ declare_game_state! { /// `EffectZoneChoice`. #[serde(default, skip_serializing_if = "Option::is_none")] pub pending_player_scope_sacrifice_choice: Option, - /// CR 616.1 + CR 701.9a: a discard instruction parked by a - /// replacement-application choice. See [`PendingDiscardBatch`]. + /// CR 608.2c + CR 701.9a: a discard instruction parked by a + /// replacement-application choice. See [`PendingDiscardBatch`], whose doc + /// records why CR 616.1 does not govern this path. /// /// Boxed: `GameState` is moved by value through the phase-server action and /// AI paths under a hard stack budget (`types/game_state_size.rs`), and this @@ -25943,8 +25944,12 @@ mod tests { let record = persisted_zone_change_record(ObjectId(9_101), 19, 0); state.zone_changes_this_turn.push_back(record.clone()); state.pending_discard_batch = Some(parked_discard_batch(record)); - // CR 616.1: the prompt a parked batch is waiting on. Without it the - // save is not mid-pause and this test measures nothing. + // The prompt a parked batch is waiting on. Without it the save is not + // mid-pause and this test measures nothing. CR 616.1 is accurate for THIS + // fixture, which builds a two-candidate ordering prompt; the production + // discard pause is a single optional apply-or-decline, which CR 616.1 does + // not govern. The carrier does not read the prompt's arity, so the + // persistence measured here is identical either way. state.waiting_for = WaitingFor::ReplacementChoice { player: PlayerId(0), candidate_count: 2, diff --git a/crates/engine/tests/integration/random_discard_cost_replacement_resume.rs b/crates/engine/tests/integration/random_discard_cost_replacement_resume.rs index 47480e35af..68c47e3857 100644 --- a/crates/engine/tests/integration/random_discard_cost_replacement_resume.rs +++ b/crates/engine/tests/integration/random_discard_cost_replacement_resume.rs @@ -331,7 +331,7 @@ fn random_discard_cost_with_no_cards_still_sacrifices() { /// mid-batch pause. const HYMN_TO_TOURACH: &str = "Target player discards two cards at random."; -/// CR 701.9b + CR 616.1: an EFFECT-caused random discard that pauses on its +/// CR 701.9b + CR 608.2c: an EFFECT-caused random discard that pauses on its /// first pick must still make its second one. /// /// This is the random sibling of the forced whole-hand arm in diff --git a/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs b/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs index 1368ee0b1a..769c5e2aa9 100644 --- a/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs +++ b/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs @@ -414,7 +414,8 @@ fn windfall_short_library_does_not_shrink_later_players_draws() { } // --------------------------------------------------------------------------- -// The CR 616.1 pause arms. A replacement choice interrupts the discard fan-out +// The replacement-pause arms (CR 608.2c: replacement effects may modify an +// instruction's actions). A replacement choice interrupts the discard fan-out // mid-batch; the clause must still publish ONE complete per-player table. // --------------------------------------------------------------------------- @@ -527,8 +528,11 @@ fn optional_graveyard_exile_replacement() -> ReplacementDefinition { /// ARM A — gate 1 (`ReplacementEvent::Discard`, Library of Leng), the fan-out /// discriminator. /// -/// CR 616.1: P0 controls an optional discard replacement, so every one of P0's -/// seven discards raises a choice. CR 608.2f: the discard action is taken on +/// P0 controls an OPTIONAL discard replacement, so every one of P0's seven +/// discards raises an apply-or-decline choice. Deliberately NOT cited to +/// CR 616.1: that rule governs choosing among two or more competing +/// replacements, and this arm has exactly one. CR 614.6 is what makes the +/// applied-or-declined event resolve as it does. CR 608.2f: the discard action is taken on /// four players and cannot be processed simultaneously once it pauses, so it is /// processed per player — but it is still ONE action, and the look-back /// (CR 608.2i) that feeds the draw clause must see every seat's contribution. @@ -606,7 +610,7 @@ fn windfall_paused_mid_fan_out_still_draws_the_greatest() { drawn: vec![7, 7, 7, 7], hands: vec![7, 7, 7, 7], }, - "a CR 616.1 pause must not truncate the batch (prompts/graveyards) nor split \ + "a replacement pause must not truncate the batch (prompts/graveyards) nor split \ the clause's per-player table (drawn/hands)" ); } From c980201a478330747858709d50a96d365f91a6ab Mon Sep 17 00:00:00 2001 From: matthewevans Date: Mon, 17 Aug 2026 14:03:34 -0700 Subject: [PATCH 23/26] test(PR-7494): annotate Balance snapshot rule Co-authored-by: lgray --- crates/engine/tests/integration/balance_equalization.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/engine/tests/integration/balance_equalization.rs b/crates/engine/tests/integration/balance_equalization.rs index fde6a321ad..7cc5f3d605 100644 --- a/crates/engine/tests/integration/balance_equalization.rs +++ b/crates/engine/tests/integration/balance_equalization.rs @@ -362,6 +362,9 @@ fn balance_three_player_interactive_fan_out_equalizes() { } } +/// CR 608.2h: Balance's cross-player hand-size minimum is determined once when +/// the effect is applied; persisting it across the pause preserves that value. +/// /// Balance's discard choice pauses after its cross-player hand-size minimum has /// been frozen. The authoritative save/restore path must preserve that /// still-live value: resume continues the same application rather than From defdbe3460951df23fbca76356b1e97b1720c97f Mon Sep 17 00:00:00 2001 From: matthewevans Date: Mon, 17 Aug 2026 16:36:39 -0700 Subject: [PATCH 24/26] test(PR-7494): use typed Leng fixture assertion --- .../tests/integration/windfall_greatest_discard_aggregate.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs b/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs index 769c5e2aa9..5fd698f3f3 100644 --- a/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs +++ b/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs @@ -26,6 +26,7 @@ use engine::types::ability::{ AbilityDefinition, AbilityKind, Effect, ReplacementDefinition, ReplacementMode, TargetFilter, }; use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; use engine::types::game_state::WaitingFor; use engine::types::identifiers::ObjectId; use engine::types::mana::ManaCost; @@ -569,8 +570,8 @@ fn windfall_paused_mid_fan_out_still_draws_the_greatest() { // Fixture self-checks — the prop must be what the derivation assumes. assert_eq!( - format!("{:?}", runner.state().objects[&leng].card_types.core_types), - "[Artifact]", + runner.state().objects[&leng].card_types.core_types, + vec![CoreType::Artifact], "the Leng prop must be an artifact, not a creature" ); assert_eq!( From 0cc0d4595407f9b3d99c0bfc9a86c7f4d6dcb62a Mon Sep 17 00:00:00 2001 From: matthewevans Date: Mon, 17 Aug 2026 17:12:00 -0700 Subject: [PATCH 25/26] fix(engine): resume replacement-paused discard lists --- crates/engine/src/game/effects/discard.rs | 67 +++++--- crates/engine/src/game/effects/mod.rs | 113 ++++++++++++- crates/engine/src/game/engine.rs | 6 +- crates/engine/src/game/engine_replacement.rs | 10 ++ .../src/game/engine_resolution_choices.rs | 148 +++++++----------- crates/engine/src/game/visibility.rs | 1 + crates/engine/src/types/game_state.rs | 26 +++ .../tests/integration/chain_of_smog_copy.rs | 119 +++++++++++++- .../windfall_greatest_discard_aggregate.rs | 91 ++++++++++- 9 files changed, 457 insertions(+), 124 deletions(-) diff --git a/crates/engine/src/game/effects/discard.rs b/crates/engine/src/game/effects/discard.rs index e8402f9d96..ccab4574ab 100644 --- a/crates/engine/src/game/effects/discard.rs +++ b/crates/engine/src/game/effects/discard.rs @@ -180,10 +180,13 @@ fn park_discard_batch( paused_card: ObjectIncarnationRef, discard_frame: Option, preceding_events: Vec, + completion: crate::types::game_state::PendingDiscardBatchCompletion, ) { + let paused_events = preceding_events.clone(); state.pending_discard_batch = Some(Box::new(crate::types::game_state::PendingDiscardBatch { player, cursor, + completion, source_id, effect_kind, paused_card, @@ -193,6 +196,11 @@ fn park_discard_batch( fan_out: None, preceding_events, })); + crate::game::engine_resolution_choices::defer_observer_triggers_for_paused_choice( + state, + &paused_events, + 0, + ); } /// CR 400.7: pin the occurrence a replacement pause parked, while the card is @@ -384,7 +392,7 @@ pub fn resolve( || (object_bound_discard && parent_reveal_choice_found_nothing) { // Discard specific targeted cards - for obj_id in specific_targets { + for (index, obj_id) in specific_targets.iter().copied().enumerate() { let obj = state .objects .get(&obj_id) @@ -424,28 +432,27 @@ pub fn resolve( events, ) { - // SAME SHAPE, NOT YET REPAIRED. Like the - // whole-hand loop before `park_discard_batch`, - // this exits mid-list with no cursor, so the - // untouched targets are never discarded and no - // terminal `EffectResolved` is emitted. It is - // NOT parked here because a specific-target - // list needs a different provenance contract: - // the whole-hand cursor's remainder is any - // subset of one seat's hand and is order-free, - // while this one must preserve the ANNOUNCED - // target list and its order. Parking it under - // the hand-shaped cursor would silently discard - // the wrong cards. (Deliberately uncited: the - // target-legality rule CR 608.2b runs once, as - // the spell begins to resolve, so it does not - // govern a mid-resolution resume. Whatever - // contract this needs must be derived, not - // borrowed.) Tracked with the rest of the class. state.waiting_for = crate::game::replacement::replacement_choice_waiting_for( player, state, ); + park_discard_batch( + state, + player_id, + crate::types::game_state::DiscardBatchCursor::Ordered { + remaining: specific_targets[index + 1..] + .iter() + .filter_map(|id| state.objects.get(id)) + .map(ObjectIncarnationRef::from_object) + .collect(), + }, + ability.source_id, + EffectKind::from(&ability.effect), + pin_paused_occurrence(state, obj_id), + discard_frame, + events[events_before_self..].to_vec(), + crate::types::game_state::PendingDiscardBatchCompletion::Standard, + ); return Ok(()); } } @@ -486,11 +493,25 @@ pub fn resolve( } } ReplacementResult::NeedsChoice(player) => { - // Same un-parked mid-list bail-out as the arm above; see the - // provenance-contract note there for why the specific-target - // list is not carried by the hand-shaped cursor. state.waiting_for = crate::game::replacement::replacement_choice_waiting_for(player, state); + park_discard_batch( + state, + player_id, + crate::types::game_state::DiscardBatchCursor::Ordered { + remaining: specific_targets[index + 1..] + .iter() + .filter_map(|id| state.objects.get(id)) + .map(ObjectIncarnationRef::from_object) + .collect(), + }, + ability.source_id, + EffectKind::from(&ability.effect), + pin_paused_occurrence(state, obj_id), + discard_frame, + events[events_before_self..].to_vec(), + crate::types::game_state::PendingDiscardBatchCompletion::Standard, + ); return Ok(()); } } @@ -570,6 +591,7 @@ pub fn resolve( paused_card, discard_frame, events[events_before_self..].to_vec(), + crate::types::game_state::PendingDiscardBatchCompletion::Standard, ); return Ok(()); } @@ -609,6 +631,7 @@ pub fn resolve( pin_paused_occurrence(state, *obj_id), discard_frame, events[events_before_self..].to_vec(), + crate::types::game_state::PendingDiscardBatchCompletion::Standard, ); return Ok(()); } diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index d640234af3..288cf51a19 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -24,9 +24,9 @@ use crate::types::events::{GameEvent, PlayerActionKind}; use crate::types::game_state::{ AutoMayChoice, CastOfferKind, ClauseMinimumSnapshot, DayNight, DiscardBatchCursor, GameState, LKISnapshot, ManaAbilityResume, MayTriggerAutoChoiceKey, PendingContinuation, - PendingCopyTokenBatch, PendingCostMoveResume, PendingPlayerScopeSacrificeChoice, - PendingPlayerScopeSacrificeCompletion, PendingPlayerScopeSacrificeFollowUp, WaitingFor, - ZoneChangeRecord, + PendingCopyTokenBatch, PendingCostMoveResume, PendingDiscardBatchCompletion, + PendingPlayerScopeSacrificeChoice, PendingPlayerScopeSacrificeCompletion, + PendingPlayerScopeSacrificeFollowUp, WaitingFor, ZoneChangeRecord, }; use crate::types::identifiers::{ObjectId, TrackedSetId}; use crate::types::mana::ManaCost; @@ -9536,6 +9536,44 @@ pub(crate) fn drain_pending_discard_batch( return Ok(PendingDiscardBatchOutcome::PausedForReplacement); } } + DiscardBatchCursor::Ordered { remaining } => { + for (i, card) in remaining.iter().enumerate() { + if !card.is_current(state) + || state.objects.get(&card.object_id).map(|object| object.zone) + != Some(Zone::Hand) + { + continue; + } + let player = state.objects[&card.object_id].owner; + if let discard::DiscardOutcome::NeedsReplacementChoice(chooser) = + discard::discard_caused_by_effect_with_source_and_frame( + state, + card.object_id, + player, + Some(batch.source_id), + batch.discard_frame, + events, + ) + { + batch.cursor = DiscardBatchCursor::Ordered { + remaining: remaining[i + 1..].to_vec(), + }; + batch.player = player; + batch.paused_card = *card; + repark_discard_batch(state, batch, events, chooser); + return Ok(PendingDiscardBatchOutcome::PausedForReplacement); + } + } + } + } + + if matches!( + &batch.completion, + PendingDiscardBatchCompletion::DiscardChoice { .. } + ) { + let mut window = batch.preceding_events.clone(); + window.extend_from_slice(events); + finalize_discard_choice_completion(state, &batch.completion, batch.discard_frame, &window); } // CR 608.2c: the terminal marker the pre-pause action could not emit, @@ -9644,14 +9682,74 @@ pub(crate) fn drain_pending_discard_batch( previous_effect_counts_by_player_from_events(batch.effect_kind, batch.source_id, &window), false, ); - state.last_zone_changed_ids = window + if !matches!( + &batch.completion, + PendingDiscardBatchCompletion::DiscardChoice { .. } + ) { + state.last_zone_changed_ids = window + .iter() + .filter_map(|e| match e { + GameEvent::ZoneChanged { object_id, .. } => Some(*object_id), + _ => None, + }) + .collect(); + } + Ok(PendingDiscardBatchOutcome::Completed) +} + +/// Finish the choice-specific bookkeeping that must precede a discard effect's +/// terminal marker, whether the selected cards settled synchronously or after +/// one or more replacement choices. +pub(crate) fn finalize_discard_choice_completion( + state: &mut GameState, + completion: &PendingDiscardBatchCompletion, + discard_frame: Option, + events: &[GameEvent], +) { + let PendingDiscardBatchCompletion::DiscardChoice { chosen } = completion else { + return; + }; + let discarded_to_graveyard: Vec = events .iter() - .filter_map(|e| match e { - GameEvent::ZoneChanged { object_id, .. } => Some(*object_id), + .filter_map(|event| match event { + GameEvent::ZoneChanged { + object_id, + to: Zone::Graveyard, + .. + } => Some(*object_id), _ => None, }) .collect(); - Ok(PendingDiscardBatchOutcome::Completed) + if !discarded_to_graveyard.is_empty() { + state.last_zone_changed_ids = discarded_to_graveyard.clone(); + publish_tracked_set_with_causes( + state, + discarded_to_graveyard + .into_iter() + .map(|id| (id, Some(ThisWayCause::Discarded))) + .collect(), + ); + } + if !chosen.is_empty() { + if let Some(frame) = state.active_ability_continuation_frame_mut() { + frame + .pending + .chain + .set_optional_effect_performed_recursive(true); + } + } + if let Some(frame_id) = discard_frame { + discard::hand_off_recruit_discard_result(state, frame_id); + } + if let Some(snapshot) = parent_referent_context_from_events(state, events) { + if let Some(frame) = state.active_ability_continuation_frame_mut() { + frame + .pending + .chain + .set_effect_context_object_recursive(snapshot); + } + } + state.last_effect_count = Some(chosen.len() as i32); } /// Re-park a batch that paused again, carrying the resumed action's span into @@ -19531,6 +19629,7 @@ mod tests { Some(Box::new(crate::types::game_state::PendingDiscardBatch { player, cursor: DiscardBatchCursor::All { remaining }, + completion: crate::types::game_state::PendingDiscardBatchCompletion::Standard, source_id, effect_kind: EffectKind::Discard, paused_card: crate::types::identifiers::ObjectIncarnationRef::of( diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 16c388dde6..612ae90237 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -19423,9 +19423,13 @@ mod stage2_injector_tests { // value before the port — the same digest that pinned this producer at // `:11103` and `:11124` earlier in this log — which is the evidence that // it MOVED rather than being replaced. + // #7494 finish: `:11131 => :11229`, +98. The ordered-discard + // resume/finalization helpers are above this existing producer; + // they do not mint an optional-effect prompt. The census above + // still finds exactly the same five production producers. "game/effects/mod.rs:7325".to_string(), "game/effects/mod.rs:7402".to_string(), - "game/effects/mod.rs:11131".to_string(), + "game/effects/mod.rs:11229".to_string(), // UNMOVED across the rebase, and that is itself evidence the SET did not // move: a census that had gained or lost a producer would not leave this // entry both byte-identical AND at the same coordinate. diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index c8a99a49bb..b86c6b2309 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -1050,11 +1050,21 @@ pub(super) fn handle_replacement_choice( effects::PendingDiscardBatchOutcome::Idle => {} effects::PendingDiscardBatchOutcome::PausedForReplacement => { waiting_for = state.waiting_for.clone(); + super::engine_resolution_choices::defer_observer_triggers_for_paused_choice( + state, + events, + replacement_action_event_start, + ); } effects::PendingDiscardBatchOutcome::Completed => { effects::drain_pending_continuation(state, events); if !matches!(state.waiting_for, WaitingFor::Priority { .. }) { waiting_for = state.waiting_for.clone(); + super::engine_resolution_choices::defer_observer_triggers_for_paused_choice( + state, + events, + replacement_action_event_start, + ); } } } diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index af4909b401..84c5f74fba 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -5,7 +5,7 @@ use rand::seq::SliceRandom; use crate::types::ability::{ AbilityCost, ChoiceType, ChosenAttribute, DigRestOrder, Effect, EffectKind, GuessOutcome, - LibraryPosition, QuantityExpr, QuantityRef, ResolvedAbility, TargetRef, ThisWayCause, + LibraryPosition, QuantityExpr, QuantityRef, ResolvedAbility, TargetRef, }; use crate::types::actions::{GameAction, LearnOption, OutsideGameSelection}; use crate::types::events::GameEvent; @@ -631,6 +631,23 @@ fn batch_or_drain_observer_triggers( } } +/// Preserve observer triggers emitted by a resolution choice that pauses before +/// the normal priority-boundary trigger scan can see its event slice. +pub(crate) fn defer_observer_triggers_for_paused_choice( + state: &mut GameState, + events: &[GameEvent], + event_start: usize, +) { + let trigger_events: Vec = events[event_start..] + .iter() + .filter(|event| !matches!(event, GameEvent::PhaseChanged { .. })) + .cloned() + .collect(); + if !trigger_events.is_empty() { + super::triggers::collect_triggers_into_deferred(state, &trigger_events); + } +} + /// CR 603.2 + CR 603.3b + CR 701.23: after a search tutor's put/shuffle /// continuation drains, collect ETB/dies/discards observers before this /// `SelectCards` action reaches its priority checkpoint. The ordinary @@ -4694,8 +4711,14 @@ pub(super) fn handle_resolution_choice( } } + let chosen_refs = chosen + .iter() + .filter_map(|id| state.objects.get(id)) + .map(crate::types::identifiers::ObjectIncarnationRef::from_object) + .collect::>(); + let events_before_effect = events.len(); - for &card_id in &chosen { + for (index, &card_id) in chosen.iter().enumerate() { if let effects::discard::DiscardOutcome::NeedsReplacementChoice(choice_player) = effects::discard::discard_caused_by_effect_with_source_and_frame( state, @@ -4708,100 +4731,43 @@ pub(super) fn handle_resolution_choice( { state.waiting_for = super::replacement::replacement_choice_waiting_for(choice_player, state); + state.pending_discard_batch = Some(Box::new( + crate::types::game_state::PendingDiscardBatch { + player, + cursor: crate::types::game_state::DiscardBatchCursor::Ordered { + remaining: chosen_refs[index + 1..].to_vec(), + }, + completion: + crate::types::game_state::PendingDiscardBatchCompletion::DiscardChoice { + chosen: chosen_refs, + }, + source_id, + effect_kind, + paused_card: crate::types::identifiers::ObjectIncarnationRef::of( + card_id, + state.objects[&card_id].incarnation, + ), + discard_frame, + fan_out: None, + preceding_events: events[events_before_effect..].to_vec(), + }, + )); + defer_observer_triggers_for_paused_choice(state, events, events_before_effect); return Ok(action_result_outcome(events, state.waiting_for.clone())); } } let events_after_move = events.len(); - // CR 608.2e + CR 608.2c: APNAP discard steps accumulate into one - // tracked set. The discard handler is the single authority for - // recording the cards it moved — `discard_as_cost_with_source` - // runs outside `resolve_effect`, so its non-interactive sibling's - // `next_sub_needs_tracked_set` publish never fires for it. Publish - // the cards that reached the graveyard here; `chain_tracked_set_id` - // is preserved across the per-opponent continuation pause, so each - // opponent's publish extends the same set and the "draw a card for - // each card discarded this way" tail reads the union. - // CR 701.9c: only graveyard-bound cards count — a replacement - // redirect (Madness) to another zone is excluded by the filter. - let discarded_to_graveyard: Vec = events[events_before_effect..] - .iter() - .filter_map(|ev| match ev { - GameEvent::ZoneChanged { - object_id, - to: Zone::Graveyard, - .. - } => Some(*object_id), - _ => None, - }) - .collect(); - if !discarded_to_graveyard.is_empty() { - // CR 608.2c: A `ZoneChangedThisWay` reflexive gate ("When you - // discard a card this way, …" — Talion's Messenger, The Ancient - // One) reads `last_zone_changed_ids`. The synchronous resolve path - // populates that ledger from the discard's `ZoneChanged` events - // (`effects/mod.rs`), but a discard that paused for an interactive - // `DiscardChoice` (hand > 1) moves the chosen card HERE, after the - // parent effect already returned. Re-publish the just-moved cards - // into the ledger so the deferred gate, re-evaluated when the - // stashed continuation drains, sees the discarded objects. - state.last_zone_changed_ids = discarded_to_graveyard.clone(); - // CR 701.9a + CR 608.2c: stamp these members with the producer - // action `Discarded` so a `caused_by: Some(Discarded)` "discarded - // this way" consumer counts them while a `caused_by: None` - // consumer still reads the whole id-only set. The cause is the - // action, independent of final zone (CR 614.6). - let with_causes = discarded_to_graveyard - .into_iter() - .map(|id| (id, Some(ThisWayCause::Discarded))) - .collect(); - effects::publish_tracked_set_with_causes(state, with_causes); - } - - // CR 608.2c: "discard a card. If you do, [effect]" — the IfYouDo - // sub_ability condition evaluates against optional_effect_performed. - // Set it on the stashed continuation before draining so the gate - // evaluates true when at least one card was actually discarded. - // Mirrors the recursive AutoMayChoice::Accept path in effects/mod.rs. - if !chosen.is_empty() { - if let Some(frame) = state.active_ability_continuation_frame_mut() { - frame - .pending - .chain - .set_optional_effect_performed_recursive(true); - } - } - - // CR 701.9a + CR 608.2c: A Recruit discard that paused for card - // selection now has its terminal LKI result in the operation-owned - // frame. Stamp that result only onto the deferred direct child before - // the continuation drains; the ordinary parent→child hand-off clears - // it again for grandchildren. - if let Some(frame_id) = discard_frame { - effects::discard::hand_off_recruit_discard_result(state, frame_id); - } - - // CR 608.2c + CR 400.7j: A reflexive sub deferred across this - // interactive discard may name the discarded card anaphorically — - // "When you discard a card this way, target player mills cards equal - // to ITS mana value" (The Ancient One). The synchronous resolve path - // captures that referent via `parent_referent_context_from_events` - // (`effects/mod.rs`); the interactive path moves the card here, after - // the parent returned, so capture it now and stamp it onto the stashed - // continuation. The discarded card is in the public graveyard, so its - // characteristics are read live. Mirrors the `EffectZoneChoice` path. - if let Some(snapshot) = - effects::parent_referent_context_from_events(state, &events[events_before_effect..]) - { - if let Some(frame) = state.active_ability_continuation_frame_mut() { - frame - .pending - .chain - .set_effect_context_object_recursive(snapshot); - } - } - - state.last_effect_count = Some(chosen.len() as i32); + let completion = + crate::types::game_state::PendingDiscardBatchCompletion::DiscardChoice { + chosen: chosen_refs, + }; + effects::finalize_discard_choice_completion( + state, + &completion, + discard_frame, + &events[events_before_effect..], + ); events.push(GameEvent::EffectResolved { kind: effect_kind, source_id, diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index f5c7261939..f47b88d271 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -6217,6 +6217,7 @@ mod tests { cursor: crate::types::game_state::DiscardBatchCursor::All { remaining: vec![hidden], }, + completion: crate::types::game_state::PendingDiscardBatchCompletion::Standard, source_id: ObjectId(9_300), effect_kind: crate::types::ability::EffectKind::Discard, paused_card: crate::types::identifiers::ObjectIncarnationRef::of(hidden, 0), diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 577b81d948..176f9e361f 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -4082,6 +4082,13 @@ pub struct PendingDiscardBatch { pub player: PlayerId, /// What this seat still owes. pub cursor: DiscardBatchCursor, + /// Work that must run exactly once after the cursor has fully settled. + /// + /// An interactive discard choice normally finalizes in its response + /// handler. A replacement can interrupt that handler mid-selection, so its + /// completion belongs to the same typed carrier as the remaining cards. + #[serde(default)] + pub completion: PendingDiscardBatchCompletion, /// The object that caused the discard. Together with `effect_kind` and /// `player` this is the batch's identity: the driver hand-off below refuses /// any batch whose identity does not match the clause it is running. @@ -4158,6 +4165,24 @@ pub enum DiscardBatchCursor { pool: Vec, remaining: usize, }, + /// An announced ordered list of cards. Unlike `All`, these may belong to + /// different owners and their exact pre-move occurrences are part of the + /// instruction's identity. + Ordered { + remaining: Vec, + }, +} + +/// Terminal work coupled to a parked discard cursor. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(tag = "type")] +pub enum PendingDiscardBatchCompletion { + #[default] + Standard, + /// A player already selected these cards through `WaitingFor::DiscardChoice`. + /// Keep their incarnation references so neither an id-reused object nor a + /// later hand card can satisfy the original selection after a pause. + DiscardChoice { chosen: Vec }, } /// The remainder of a `player_scope` discard clause whose fan-out was @@ -25836,6 +25861,7 @@ mod tests { cursor: DiscardBatchCursor::All { remaining: vec![ObjectId(9_301), ObjectId(9_302)], }, + completion: PendingDiscardBatchCompletion::Standard, source_id: ObjectId(9_300), effect_kind: crate::types::ability::EffectKind::Discard, paused_card: ObjectIncarnationRef::of(ObjectId(9_303), 0), diff --git a/crates/engine/tests/integration/chain_of_smog_copy.rs b/crates/engine/tests/integration/chain_of_smog_copy.rs index cd390ad81b..6d9ddeca86 100644 --- a/crates/engine/tests/integration/chain_of_smog_copy.rs +++ b/crates/engine/tests/integration/chain_of_smog_copy.rs @@ -24,7 +24,7 @@ use engine::game::scenario::{GameScenario, P0, P1}; use engine::game::scenario_db::GameScenarioDbExt; use engine::types::ability::{CopyRetargetPermission, Effect}; use engine::types::actions::GameAction; -use engine::types::game_state::WaitingFor; +use engine::types::game_state::{PersistedGameState, WaitingFor}; use engine::types::identifiers::ObjectId; use engine::types::mana::{ManaType, ManaUnit}; use engine::types::phase::Phase; @@ -285,6 +285,123 @@ fn chain_of_smog_copy_controlled_by_targeted_player_and_retargeted() { ); } +#[test] +fn chain_of_smog_discard_choice_resumes_selected_tail_after_library_of_leng() { + let Some(db) = load_db() else { + return; + }; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let smog = scenario.add_real_card(P0, "Chain of Smog", Zone::Hand, db); + scenario.add_real_card(P1, "Library of Leng", Zone::Battlefield, db); + for _ in 0..3 { + scenario.add_card_to_hand(P1, "Mountain"); + } + + let mut runner = scenario.build(); + engine::game::rehydrate_game_from_card_db(runner.state_mut(), db); + add_mana(&mut runner, P0, &[ManaType::Black, ManaType::Colorless]); + let card_id = runner.state().objects[&smog].card_id; + runner + .act(GameAction::CastSpell { + object_id: smog, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("cast Chain of Smog"); + runner + .act(GameAction::SelectTargets { + targets: vec![engine::types::ability::TargetRef::Player(P1)], + }) + .expect("target P1"); + + for _ in 0..16 { + match runner.state().waiting_for.clone() { + WaitingFor::DiscardChoice { count, cards, .. } => { + runner + .act(GameAction::SelectCards { + cards: cards.into_iter().take(count).collect(), + }) + .expect("select two cards to discard"); + break; + } + WaitingFor::Priority { .. } => { + runner + .act(GameAction::PassPriority) + .expect("advance Chain of Smog"); + } + waiting_for => panic!("expected discard choice, got {waiting_for:?}"), + } + } + + assert!(matches!( + runner.state().pending_discard_batch.as_deref(), + Some(engine::types::game_state::PendingDiscardBatch { + completion: engine::types::game_state::PendingDiscardBatchCompletion::DiscardChoice { chosen }, + .. + }) if chosen.len() == 2 + )); + let saved = serde_json::to_string(&PersistedGameState::capture(runner.state().clone())).expect( + "paused selected discard serializes through the authoritative persistence envelope", + ); + let restored: PersistedGameState = serde_json::from_str(&saved) + .expect("paused selected discard restores through the authoritative persistence envelope"); + let restored = restored.into_game_state(); + assert!(matches!( + restored.pending_discard_batch.as_deref(), + Some(engine::types::game_state::PendingDiscardBatch { + completion: engine::types::game_state::PendingDiscardBatchCompletion::DiscardChoice { chosen }, + .. + }) if chosen.len() == 2 + )); + let mut runner = engine::game::scenario::GameRunner::from_state(restored); + + let mut replacement_events = Vec::new(); + for _ in 0..2 { + let WaitingFor::ReplacementChoice { candidates, .. } = runner.state().waiting_for.clone() + else { + panic!("each selected card must reach Library of Leng's replacement choice"); + }; + let decline = candidates + .iter() + .position(|candidate| candidate.description == "Decline") + .expect("Library of Leng supplies a decline choice"); + let result = runner + .act(GameAction::ChooseReplacement { index: decline }) + .expect("decline Library of Leng"); + replacement_events.extend(result.events); + } + + assert!(runner.state().pending_discard_batch.is_none()); + assert_eq!(runner.state().last_effect_count, Some(2)); + assert_eq!( + replacement_events + .iter() + .filter(|event| matches!( + event, + engine::types::events::GameEvent::EffectResolved { + kind: engine::types::ability::EffectKind::Discard, + source_id, + .. + } if *source_id == smog + )) + .count(), + 1, + "the resumed selected discard emits its terminal marker exactly once" + ); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::OptionalEffectChoice { player: P1, .. } + )); + assert_eq!( + hand_size(&runner, P1), + 1, + "both selected cards must discard before Chain of Smog's copy continuation" + ); +} + // --------------------------------------------------------------------------- // Runtime: the copy is itself a Chain of Smog and carries the same nested // optional copy — accepting the re-offered copy must produce a diff --git a/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs b/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs index 5fd698f3f3..b4d51e3a08 100644 --- a/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs +++ b/crates/engine/tests/integration/windfall_greatest_discard_aggregate.rs @@ -21,13 +21,15 @@ //! set to a cross-player SUM, Windfall drew 8+7+3+3 = 21 for every player //! instead of the greatest single player's 8. +use engine::game::engine::apply; use engine::game::scenario::{GameRunner, GameScenario, Outcome, P0, P1}; use engine::types::ability::{ - AbilityDefinition, AbilityKind, Effect, ReplacementDefinition, ReplacementMode, TargetFilter, + AbilityDefinition, AbilityKind, Effect, ReplacementDefinition, ReplacementMode, + ResolvedAbility, TargetFilter, TargetRef, }; use engine::types::actions::GameAction; use engine::types::card_type::CoreType; -use engine::types::game_state::WaitingFor; +use engine::types::game_state::{PersistedGameState, WaitingFor}; use engine::types::identifiers::ObjectId; use engine::types::mana::ManaCost; use engine::types::phase::Phase; @@ -584,6 +586,20 @@ fn windfall_paused_mid_fan_out_still_draws_the_greatest() { // to answer; from there this test drives the prompts itself so it can count // them. runner.cast(windfall).resolve(); + assert!( + runner.state().pending_discard_batch.is_some(), + "reach guard: the first Library of Leng prompt must park Windfall's live discard batch" + ); + let saved = serde_json::to_string(&PersistedGameState::capture(runner.state().clone())) + .expect("parked Windfall state serializes through the authoritative persistence envelope"); + let restored: PersistedGameState = serde_json::from_str(&saved) + .expect("parked Windfall state restores through the authoritative persistence envelope"); + let restored = restored.into_game_state(); + assert!( + restored.pending_discard_batch.is_some(), + "the live discard cursor must survive save and restore before its replacement choice" + ); + let mut runner = GameRunner::from_state(restored); let prompts = answer_every_replacement_choice(&mut runner, "Decline"); runner.advance_until_stack_empty(); @@ -616,6 +632,77 @@ fn windfall_paused_mid_fan_out_still_draws_the_greatest() { ); } +#[test] +fn replacement_resumed_targeted_discard_preserves_the_announced_multi_owner_tail() { + let mut scenario = GameScenario::new_n_player(3, 42); + scenario.at_phase(Phase::PreCombatMain); + let first = scenario.add_card_to_hand(P1, "First Target"); + let second = scenario.add_card_to_hand(P2, "Second Target"); + let source = scenario + .add_spell_to_hand(P0, "Targeted Discard", false) + .id(); + scenario + .add_creature(P0, "Graveyard Warden", 1, 1) + .with_replacement_definition( + optional_graveyard_exile_replacement() + .valid_card(TargetFilter::SpecificObject { id: first }), + ); + let mut runner = scenario.build(); + let ability = ResolvedAbility::new( + Effect::DiscardCard { + count: 2, + target: TargetFilter::SpecificObject { id: first }, + }, + vec![TargetRef::Object(first), TargetRef::Object(second)], + source, + P0, + ); + let mut initial_events = Vec::new(); + engine::game::effects::resolve_ability_chain( + runner.state_mut(), + &ability, + &mut initial_events, + 0, + ) + .expect("the announced targeted discard resolves to its first replacement choice"); + + assert!(matches!( + runner.state().waiting_for, + WaitingFor::ReplacementChoice { .. } + )); + assert!(matches!( + runner.state().pending_discard_batch.as_deref().map(|batch| &batch.cursor), + Some(engine::types::game_state::DiscardBatchCursor::Ordered { remaining }) + if remaining.len() == 1 && remaining[0].object_id == second + )); + + let result = apply( + runner.state_mut(), + P1, + GameAction::ChooseReplacement { index: 0 }, + ) + .expect("accept the first target's graveyard redirect"); + + assert_eq!(runner.state().objects[&first].zone, Zone::Exile); + assert_eq!(runner.state().objects[&second].zone, Zone::Graveyard); + assert!(runner.state().pending_discard_batch.is_none()); + assert_eq!( + result + .events + .iter() + .filter( + |event| matches!(event, engine::types::events::GameEvent::EffectResolved { + kind: engine::types::ability::EffectKind::DiscardCard, + source_id: event_source, + .. + } if *event_source == source) + ) + .count(), + 1, + "the resumed ordered target list emits its terminal marker exactly once" + ); +} + /// Every observable of the gate-2 arm, asserted as ONE value. #[derive(Debug, PartialEq, Eq)] struct GateTwoSignature { From f50f6d5c44da49a9fa314def54b8f5638c377a87 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Mon, 17 Aug 2026 18:08:32 -0700 Subject: [PATCH 26/26] fix(PR-7494): reject duplicate discard selections Validate DiscardChoice card selections at the action boundary before recording a paused ordered cursor, and cover the real cast-to-choice pipeline. Co-authored-by: Lindsey Gray --- .../src/game/engine_resolution_choices.rs | 14 ++++- .../tests/integration/chain_of_smog_copy.rs | 62 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 84c5f74fba..aa677b3b58 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -631,8 +631,9 @@ fn batch_or_drain_observer_triggers( } } -/// Preserve observer triggers emitted by a resolution choice that pauses before -/// the normal priority-boundary trigger scan can see its event slice. +/// CR 603.2 + CR 603.3b: Preserve triggers from events that occurred while a +/// resolution choice paused; they trigger now and wait for the next priority +/// window's APNAP placement rather than being lost with the action's event slice. pub(crate) fn defer_observer_triggers_for_paused_choice( state: &mut GameState, events: &[GameEvent], @@ -4691,6 +4692,15 @@ pub(super) fn handle_resolution_choice( } } + // CR 608.2d: A resolving player can't choose one eligible card + // more than once to satisfy a multi-card discard selection. + let unique_chosen: HashSet = chosen.iter().copied().collect(); + if unique_chosen.len() != chosen.len() { + return Err(EngineError::InvalidAction( + "Selected cards must be distinct".to_string(), + )); + } + let current_hand: std::collections::HashSet = state .players .iter() diff --git a/crates/engine/tests/integration/chain_of_smog_copy.rs b/crates/engine/tests/integration/chain_of_smog_copy.rs index 6d9ddeca86..fc801f7be0 100644 --- a/crates/engine/tests/integration/chain_of_smog_copy.rs +++ b/crates/engine/tests/integration/chain_of_smog_copy.rs @@ -402,6 +402,68 @@ fn chain_of_smog_discard_choice_resumes_selected_tail_after_library_of_leng() { ); } +#[test] +fn chain_of_smog_discard_choice_rejects_a_duplicate_card_submission() { + let Some(db) = load_db() else { + return; + }; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let smog = scenario.add_real_card(P0, "Chain of Smog", Zone::Hand, db); + for _ in 0..3 { + scenario.add_card_to_hand(P1, "Mountain"); + } + + let mut runner = scenario.build(); + engine::game::rehydrate_game_from_card_db(runner.state_mut(), db); + add_mana(&mut runner, P0, &[ManaType::Black, ManaType::Colorless]); + let card_id = runner.state().objects[&smog].card_id; + runner + .act(GameAction::CastSpell { + object_id: smog, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("cast Chain of Smog"); + runner + .act(GameAction::SelectTargets { + targets: vec![engine::types::ability::TargetRef::Player(P1)], + }) + .expect("target P1"); + + let (count, duplicate) = loop { + match runner.state().waiting_for.clone() { + WaitingFor::DiscardChoice { count, cards, .. } => break (count, cards[0]), + WaitingFor::Priority { .. } => { + runner + .act(GameAction::PassPriority) + .expect("advance Chain of Smog"); + } + waiting_for => panic!("expected discard choice, got {waiting_for:?}"), + } + }; + assert_eq!(count, 2, "Chain of Smog requires two distinct discards"); + + let error = runner + .act(GameAction::SelectCards { + cards: vec![duplicate, duplicate], + }) + .expect_err("one card cannot satisfy Chain of Smog's two-card discard"); + assert!(matches!( + error, + engine::game::EngineError::InvalidAction(message) + if message == "Selected cards must be distinct" + )); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::DiscardChoice { count: 2, .. } + )); + assert_eq!(runner.state().objects[&duplicate].zone, Zone::Hand); + assert_eq!(hand_size(&runner, P1), 3); +} + // --------------------------------------------------------------------------- // Runtime: the copy is itself a Chain of Smog and carries the same nested // optional copy — accepting the re-offered copy must produce a