From 8ca079a1f6c6e8a9cc6911a111129ece38b8b253 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 09:15:53 +0000 Subject: [PATCH 01/63] fix(engine): attach the leading intervening-if gate on self-ETB enters-with replacements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Adamant cycle parsed its "enters with a +1/+1 counter" clause as a CR 614.1c replacement but dropped the sentence-initial "If , " gate, leaving ReplacementDefinition.condition = None. The engine therefore gave every copy of these creatures the counter regardless of what mana was actually spent — silently-wrong behaviour that nothing flagged at runtime. Coverage reported it as Swallow:Condition_If on six cards. Root cause: oracle_replacement.rs peeled a TRAILING " unless " gate (extract_enters_with_unless_suffix) and a trailing " if " gate (extract_enters_with_only_if_suffix), but nothing peeled a LEADING one, and the payload scan skips everything before "enters with". Fixed for the class, not the cards: * extract_enters_with_leading_if_gate mirrors the existing trailing extractor one-for-one, including its fail-closed tri-state. Condition recognition delegates to parse_inner_condition (the single authority), so the ENTIRE static-condition grammar is now reachable from the sentence-initial position, where previously zero shapes were. An unrecognised gate returns Unparsed and the clause falls through to Effect::unimplemented rather than committing a replacement with the gate erased. * CR 207.2c ability-word labels are peeled via parse_known_ability_word_name, which enumerates exactly the CR 207.2c list. CR 207.2d flavour words are deliberately NOT peeled: the loose 4-word heuristic would peel "Sensational Save" and regress Scarlet Spider, Ben Reilly to Unparsed. * CastManaSpentMetric::OfColor parameterises the existing metric axis (Total | DistinctColors | FromSource) rather than adding a ReplacementCondition sibling, so the parsed condition flows through the untouched replacement_condition_from_static into OnlyIfQuantity. Zero new ReplacementCondition variants, zero new condition-evaluation arms. * parse_color_mana_spent_threshold reuses parse_amount_threshold, so the comparator axis (at least N / N or more / N or fewer) is carried rather than hardcoded to GE. Coverage: six cards flip to supported (Ardenvale, Embereth, Garenbrig, Locthwain and Vantress Paladin, plus Necromantic Summons), zero cards regress. Dust Animus keeps supported=true and gains its gate, so it is correctly conditional for the first time. Henge Walker ("mana of the same color" — a max-over-colors metric) and Red and Black Legacy now fail closed and stay honestly red instead of silently granting their counters. Measured against a pre-change baseline of all 35516 cards: 0 true->false, 6 false->true, net +6. CR 614.1c, CR 614.12, CR 207.2c, CR 207.2d, CR 106.3, CR 601.2h, CR 400.7d. CR 603.4 is deliberately not cited in code: its intervening-if rule is scoped to triggered abilities, and this gates a replacement effect. Co-Authored-By: Claude Opus 5 --- crates/engine/src/game/ability_rw.rs | 50 +++ crates/engine/src/game/ability_scan.rs | 60 +++ crates/engine/src/game/ability_utils.rs | 4 +- crates/engine/src/game/coverage.rs | 17 +- crates/engine/src/game/layers.rs | 4 +- crates/engine/src/game/quantity.rs | 3 + crates/engine/src/game/triggers.rs | 4 +- .../src/parser/oracle_effect/conditions.rs | 101 ++++- .../engine/src/parser/oracle_nom/condition.rs | 263 +++++++++++++ .../engine/src/parser/oracle_replacement.rs | 351 ++++++++++++++++- crates/engine/src/types/ability.rs | 27 +- .../adamant_enters_with_leading_if_gate.rs | 368 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + docs/parser-misparse-backlog.md | 31 +- 14 files changed, 1251 insertions(+), 33 deletions(-) create mode 100644 crates/engine/tests/integration/adamant_enters_with_leading_if_gate.rs diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index 4727a356a9..acd209a4e8 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -8224,4 +8224,54 @@ mod tests { assert_eq!(p.reads_event_live, twin.reads_event_live); assert_eq!(p.legacy_batch_prompt(), twin.legacy_batch_prompt()); } + + /// CR 400.7d: the Adamant *rider* cards (Slaying Fire, Cauldron's Gift, …) + /// re-route from `AbilityCondition::ManaColorSpent` — an inert, + /// reads-nothing arm — to the generic + /// `QuantityCheck { ManaSpentToCast { OfColor } }`, which resolves through + /// `legacy_ref()`. Pin BOTH sides so the delta is explicit rather than + /// incidental: the flip is toward the MORE conservative classification + /// (batch-prompt rather than silent auto-order), which is fail-safe, and a + /// future retirement of `ManaColorSpent` cannot silently change it. + /// + /// Companion to `adamant_rider_generic_shape_reads_all_scan_axes` in + /// `ability_scan.rs`, which pins the corresponding scan-axis delta. Without + /// this test the `ability_rw` half of that re-routing is unpinned. + #[test] + fn adamant_rider_generic_shape_is_more_conservative_than_legacy() { + use crate::types::ability::{CastManaObjectScope, CastManaSpentMetric}; + use crate::types::mana::ManaColor; + + let generic = AbilityCondition::QuantityCheck { + lhs: qref(QuantityRef::ManaSpentToCast { + scope: CastManaObjectScope::SelfObject, + metric: CastManaSpentMetric::OfColor { + color: ManaColor::Red, + }, + }), + comparator: Comparator::GE, + rhs: qfix(3), + }; + let legacy = AbilityCondition::ManaColorSpent { + color: ManaColor::Red, + minimum: 3, + }; + + // SCOPE OF THIS TEST: it pins the rw CLASSIFICATION of both shapes. It + // constructs the conditions directly and never invokes the parser, so it + // does NOT detect a revert of the `OfColor` lowering — that is guarded by + // `leading_word_mana_spent_condition_parses_adamant` in + // `parser/oracle_effect/conditions.rs`. What reverting the lowering WOULD + // do is make this test's `generic` shape unreachable in production while + // leaving it green; the pin below is what keeps the two classifications + // visibly different so such a change cannot pass unnoticed. + assert!( + rw_ability_condition(&generic).legacy_batch_prompt(), + "the generic ManaSpentToCast shape must carry the legacy_ref batch-prompt marker" + ); + assert!( + !rw_ability_condition(&legacy).legacy_batch_prompt(), + "the legacy ManaColorSpent arm reads nothing; pinned so the delta stays visible" + ); + } } diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index deb416008d..d208c23885 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -7929,6 +7929,66 @@ mod tests { ))); } + /// CR 400.7d + CR 601.2h: the Adamant ability rider ("if at least three red + /// mana was spent to cast this spell, it deals 4 damage instead") now parses + /// to the generic `QuantityCheck { ManaSpentToCast { .., OfColor } }` shape + /// instead of the legacy `AbilityCondition::ManaColorSpent`. That moves it + /// from the `Axes::NONE` arm (`:2572`) onto the `Axes::CONSERVATIVE` arm + /// (`:2452`, reached via `QuantityCheck` → `scan_quantity_expr`), flipping + /// ALL THREE scan axes false→true for the 11 affected cards. + /// + /// RULING — the flip is accepted, and is the intended direction: + /// - `event`: CORRECT, not merely conservative. CR 400.7d makes the paid-mana + /// record a cost-paid-object characteristic, which is precisely what the + /// `event` axis names; the legacy `Axes::NONE` under-read it. + /// - `sibling` / `projected`: over-inclusive but fail-SAFE. The payment record + /// is stamped once at CR 601.2h and is neither sibling-mutable nor a + /// player-level projected resource, so `true` can only make the analysis + /// reject an auto-order cover (prompt) — never auto-resolve something it + /// should have prompted on. + /// + /// Neither `ordering_parity_sweep` (which imports `ability_rw` only) nor + /// `coverage-data.json` observes this axis, hence this direct assertion. + #[test] + fn adamant_rider_generic_shape_reads_all_scan_axes() { + let generic = AbilityCondition::QuantityCheck { + lhs: QuantityExpr::Ref { + qty: QuantityRef::ManaSpentToCast { + scope: CastManaObjectScope::SelfObject, + metric: CastManaSpentMetric::OfColor { + color: ManaColor::Red, + }, + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 3 }, + }; + // SCOPE OF THIS TEST: it pins the scan-axis CLASSIFICATION of both + // shapes. It constructs the conditions directly and never invokes the + // parser, so it does NOT detect a revert of the `OfColor` lowering — + // that is guarded by `leading_word_mana_spent_condition_parses_adamant` + // in `parser/oracle_effect/conditions.rs`. The legacy pin below is what + // keeps the delta explicit. + let generic_axes = scan_ability_condition(&generic, ScanMode::Conservative); + assert!(generic_axes.event); + assert!(generic_axes.sibling); + assert!(generic_axes.projected); + // The public projection consumed by `analysis::resource` agrees. + assert!(ability_condition_reads_projected_resource(&generic)); + + // Pin the legacy shape's classification so the delta is explicit and a + // future retirement of `ManaColorSpent` cannot silently change it. + let legacy = AbilityCondition::ManaColorSpent { + color: ManaColor::Red, + minimum: 3, + }; + let legacy_axes = scan_ability_condition(&legacy, ScanMode::Conservative); + assert!(!legacy_axes.event); + assert!(!legacy_axes.sibling); + assert!(!legacy_axes.projected); + assert!(!ability_condition_reads_projected_resource(&legacy)); + } + // ---- BLOCKER 1 regression: multi_target bounds are traversed ---- #[test] fn multi_target_bound_event_read_classifies() { diff --git a/crates/engine/src/game/ability_utils.rs b/crates/engine/src/game/ability_utils.rs index 72ac21e955..2ea1ef7451 100644 --- a/crates/engine/src/game/ability_utils.rs +++ b/crates/engine/src/game/ability_utils.rs @@ -3965,7 +3965,9 @@ fn quantity_ref_target_slot_spec(qty: &QuantityRef) -> Option { CastManaSpentMetric::FromSource { source_filter } => { filter_target_slot_filter(source_filter) } - CastManaSpentMetric::Total | CastManaSpentMetric::DistinctColors => None, + CastManaSpentMetric::Total + | CastManaSpentMetric::DistinctColors + | CastManaSpentMetric::OfColor { .. } => None, }, QuantityRef::PlayerCount { filter: crate::types::ability::PlayerFilter::ControlsCount { filter, .. }, diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 8268c5e738..22c73cec4d 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -1753,9 +1753,20 @@ fn fmt_quantity_ref(qty: &QuantityRef) -> String { QuantityRef::TimesCostPaidThisResolution => { "times the repeated optional cost was paid this resolution".into() } - QuantityRef::ManaSpentToCast { scope, metric } => { - format!("mana spent to cast ({scope:?}, {metric:?})") - } + QuantityRef::ManaSpentToCast { scope, metric } => match metric { + // CR 106.3: the per-color leaf names a concrete color, so render it + // in words. The other three metrics keep their existing `{metric:?}` + // rendering byte-identically. + crate::types::ability::CastManaSpentMetric::OfColor { color } => format!( + "mana spent to cast ({scope:?}, {} mana)", + fmt_mana_color_full(color) + ), + crate::types::ability::CastManaSpentMetric::Total + | crate::types::ability::CastManaSpentMetric::DistinctColors + | crate::types::ability::CastManaSpentMetric::FromSource { .. } => { + format!("mana spent to cast ({scope:?}, {metric:?})") + } + }, QuantityRef::EventContextSourceCostX => "X of triggering spell".into(), QuantityRef::EventContextSourceModesChosen => { "modes chosen for the triggering spell".into() diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 4e8f6d4623..5ef5893e72 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -2712,7 +2712,9 @@ fn quantity_ref_reads_life(qty: &QuantityRef) -> bool { CastManaSpentMetric::FromSource { source_filter } => { target_filter_reads_life_total(source_filter) } - CastManaSpentMetric::Total | CastManaSpentMetric::DistinctColors => false, + CastManaSpentMetric::Total + | CastManaSpentMetric::DistinctColors + | CastManaSpentMetric::OfColor { .. } => false, }, // CR 603.2c: the trigger-event player set filters candidates through a diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index d37747ca67..bfbc24d174 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -1638,6 +1638,9 @@ pub(crate) fn resolve_mana_spent_to_cast_metric( CastManaSpentMetric::DistinctColors => { usize_to_i32_saturating(spent_colors.distinct_colors()) } + // CR 106.3 + CR 601.2h: how much mana of exactly this color paid the + // cost, read off the same per-color payment tally. + CastManaSpentMetric::OfColor { color } => u32_to_i32_saturating(spent_colors.get(*color)), CastManaSpentMetric::FromSource { source_filter } => usize_to_i32_saturating( source_snapshots .iter() diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 829c099251..4ed55c3b5d 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -10013,7 +10013,9 @@ fn quantity_ref_refs_cost_paid_object(qty: &QuantityRef) -> bool { CastManaSpentMetric::FromSource { source_filter } => { source_filter.references_cost_paid_object() } - CastManaSpentMetric::Total | CastManaSpentMetric::DistinctColors => false, + CastManaSpentMetric::Total + | CastManaSpentMetric::DistinctColors + | CastManaSpentMetric::OfColor { .. } => false, }, // Non-object, non-filter refs never read the cost-paid object. Listed diff --git a/crates/engine/src/parser/oracle_effect/conditions.rs b/crates/engine/src/parser/oracle_effect/conditions.rs index 6775a3a3c9..8eab729f2d 100644 --- a/crates/engine/src/parser/oracle_effect/conditions.rs +++ b/crates/engine/src/parser/oracle_effect/conditions.rs @@ -3870,6 +3870,23 @@ fn parse_symbolic_mana_color_spent_condition( Ok((rest, condition)) } +/// CR 106.3 + CR 601.2h: legacy leading-word form, "at least N mana was +/// spent to cast " → `AbilityCondition::ManaColorSpent`. +/// +/// SHADOWED, kept as a fallback only. `parse_mana_color_spent_condition_text` +/// has exactly one caller (`parse_condition_text`), and every non-test caller of +/// that reaches it only through `.or_else(..)` AFTER +/// `try_nom_condition_as_ability_condition`, whose `parse_inner_condition` +/// fall-through now recognizes this exact phrase via +/// `parse_color_mana_spent_threshold` and bridges it to the canonical +/// `AbilityCondition::QuantityCheck { ManaSpentToCast { scope, OfColor } }`. +/// So on the production path this arm no longer fires for any real input; the +/// generic form carries an explicit `CastManaObjectScope` (CR 400.7d) that +/// `ManaColorSpent` cannot express. +/// +/// `parse_symbolic_mana_color_spent_condition` above is NOT shadowed — the +/// `{W}{W}` symbolic form has no `parse_inner_condition` grammar and stays live +/// for 22 cards. fn parse_word_mana_color_spent_condition( input: &str, ) -> super::super::oracle_nom::error::OracleResult<'_, AbilityCondition> { @@ -8839,6 +8856,13 @@ mod tests { ); } + /// CR 106.3 + CR 601.2h + CR 400.7d: the leading-word Adamant rider now + /// bridges through `parse_inner_condition` → + /// `parse_color_mana_spent_threshold` to the canonical generic shape, which + /// (unlike the legacy `ManaColorSpent`) records the subject anaphora as an + /// explicit `CastManaObjectScope`. Runtime reading is unchanged: + /// `QuantityCheck` resolves `ManaSpentToCast { SelfObject, .. }` against the + /// ability's own source object, exactly as the `ManaColorSpent` arm did. #[test] fn leading_word_mana_spent_condition_parses_adamant() { let (condition, body) = strip_leading_general_conditional( @@ -8848,13 +8872,84 @@ mod tests { assert_eq!(body, "it deals 4 damage instead."); assert_eq!( condition, - Some(AbilityCondition::ManaColorSpent { - color: ManaColor::Red, - minimum: 3, + Some(AbilityCondition::QuantityCheck { + lhs: QuantityExpr::Ref { + qty: QuantityRef::ManaSpentToCast { + scope: CastManaObjectScope::SelfObject, + metric: CastManaSpentMetric::OfColor { + color: ManaColor::Red + }, + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 3 }, }) ); } + /// CR 400.7d: the "that spell" anaphora on the PRODUCTION path. + /// + /// `parse_condition_text`'s leading-word arm + /// (`parse_word_mana_color_spent_condition`) only ever accepted the + /// `{this spell, it, them, ~}` subject set and flattened the anaphora away + /// into a scope-less `ManaColorSpent`. That arm is now shadowed: every + /// non-test caller reaches `try_nom_condition_as_ability_condition` first, + /// and its `parse_inner_condition` fall-through recognizes "that spell" and + /// records it as `CastManaObjectScope::TriggeringSpell`. + /// + /// RULING — this is acceptable and strictly more faithful. CR 400.7d + /// distinguishes reading the payment record of the object the ability is on + /// from reading that of a referenced spell; the generic shape carries the + /// distinction, the legacy shape could not. Zero live cards use "that + /// spell" in this rider today (all 11 leading-word Adamant cards say "this + /// spell"), so this is a latent capability, not a behavior change. The + /// runtime `QuantityCheck` reader resolves `TriggeringSpell` through the + /// trigger event context rather than `ability.source_id`, which is the + /// correct reading for that phrasing. + #[test] + fn leading_word_mana_spent_condition_records_that_spell_anaphora() { + let condition = try_nom_condition_as_ability_condition( + "at least three white mana was spent to cast that spell", + &mut ParseContext::default(), + ) + .expect("the generic grammar must recognize the 'that spell' anaphora"); + assert_eq!( + condition, + AbilityCondition::QuantityCheck { + lhs: QuantityExpr::Ref { + qty: QuantityRef::ManaSpentToCast { + scope: CastManaObjectScope::TriggeringSpell, + metric: CastManaSpentMetric::OfColor { + color: ManaColor::White + }, + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 3 }, + } + ); + + // Reach-guard + contrast: "this spell" stays `SelfObject`, so the scope + // axis is genuinely threaded rather than hardcoded. + let self_scoped = try_nom_condition_as_ability_condition( + "at least three white mana was spent to cast this spell", + &mut ParseContext::default(), + ) + .expect("the 'this spell' anaphora must still parse"); + assert!(matches!( + self_scoped, + AbilityCondition::QuantityCheck { + lhs: QuantityExpr::Ref { + qty: QuantityRef::ManaSpentToCast { + scope: CastManaObjectScope::SelfObject, + .. + }, + }, + .. + } + )); + } + /// CR 122.1 + CR 608.2c: "there are no counters on ~" round-trips through /// the bridge to a `QuantityCheck` against `AnyCountersOnSelf`. Previously /// the bridge returned `None` for `CounterMatch::Any`, which silently diff --git a/crates/engine/src/parser/oracle_nom/condition.rs b/crates/engine/src/parser/oracle_nom/condition.rs index a736b93b94..6a5dbc6b92 100644 --- a/crates/engine/src/parser/oracle_nom/condition.rs +++ b/crates/engine/src/parser/oracle_nom/condition.rs @@ -658,6 +658,7 @@ fn parse_resolution_context_conditions(input: &str) -> OracleResult<'_, StaticCo parse_source_qualified_mana_spent_threshold, parse_mana_spent_vs_source_pt, parse_colors_of_mana_spent_threshold, + parse_color_mana_spent_threshold, parse_mana_spent_threshold, parse_combat_context_conditions, parse_put_onto_battlefield_this_way, @@ -6625,6 +6626,39 @@ fn parse_colors_of_mana_spent_threshold(input: &str) -> OracleResult<'_, StaticC )) } +/// CR 106.3 + CR 601.2h: Parse "{at least N | N or more | N or fewer | N or +/// less} mana {was|were} spent to cast " — the Adamant threshold +/// (CR 207.2c ability word; the label itself carries no rules meaning and is +/// peeled by the caller). Measures `CastManaSpentMetric::OfColor` — how much +/// mana of exactly one color paid the cost — against a fixed threshold. +/// +/// Sibling of `parse_colors_of_mana_spent_threshold` (distinct colors) and +/// `parse_mana_spent_threshold` (total): all three share +/// `parse_amount_threshold` and `parse_mana_spent_self_subject`, differing only +/// in the aggregate leaf. CR 400.7d: the subject anaphora selects the scope. +fn parse_color_mana_spent_threshold(input: &str) -> OracleResult<'_, StaticCondition> { + let (rest, (n, comparator)) = parse_amount_threshold(input)?; + let (rest, _) = tag(" ").parse(rest)?; + let (rest, color) = parse_color(rest)?; + let (rest, _) = tag(" mana ").parse(rest)?; + let (rest, _) = alt((tag("was "), tag("were "))).parse(rest)?; + let (rest, _) = tag("spent to cast ").parse(rest)?; + let (rest, scope) = nom_quantity::parse_mana_spent_self_subject(rest)?; + Ok(( + rest, + StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::ManaSpentToCast { + scope, + metric: CastManaSpentMetric::OfColor { color }, + }, + }, + comparator, + rhs: QuantityExpr::Fixed { value: n as i32 }, + }, + )) +} + /// CR 509.1b + CR 506.5: Parse combat-context conditions. /// /// Handles "defending player controls a/an [type]" and "it's attacking alone". @@ -16735,6 +16769,235 @@ mod tests { ); } + // ----------------------------------------------------------------------- + // CR 106.3 + CR 601.2h: per-color mana-spent threshold (Adamant grammar). + // Group A — accepted forms across every axis of the composed combinator. + // ----------------------------------------------------------------------- + + fn color_mana_spent( + text: &str, + ) -> ( + CastManaObjectScope, + crate::types::mana::ManaColor, + Comparator, + i32, + ) { + let (rest, c) = parse_inner_condition(text) + .unwrap_or_else(|e| panic!("expected {text:?} to parse, got {e:?}")); + assert_eq!(rest, "", "combinator must self-delimit for {text:?}"); + match c { + StaticCondition::QuantityComparison { + lhs: + QuantityExpr::Ref { + qty: + QuantityRef::ManaSpentToCast { + scope, + metric: CastManaSpentMetric::OfColor { color }, + }, + }, + comparator, + rhs: QuantityExpr::Fixed { value }, + } => (scope, color, comparator, value), + other => panic!("expected OfColor QuantityComparison for {text:?}, got {other:?}"), + } + } + + /// All five colors route through the shared `parse_color` leaf — the arm is + /// not hardcoded to white (the color of the first Adamant card fixed). + #[test] + fn color_mana_spent_threshold_all_five_colors() { + use crate::types::mana::ManaColor; + for (text, expected) in [ + ( + "at least three white mana was spent to cast this spell", + ManaColor::White, + ), + ( + "at least three blue mana was spent to cast this spell", + ManaColor::Blue, + ), + ( + "at least three black mana was spent to cast this spell", + ManaColor::Black, + ), + ( + "at least three red mana was spent to cast this spell", + ManaColor::Red, + ), + ( + "at least three green mana was spent to cast this spell", + ManaColor::Green, + ), + ] { + let (scope, color, comparator, value) = color_mana_spent(text); + assert_eq!(scope, CastManaObjectScope::SelfObject); + assert_eq!(color, expected); + assert_eq!(comparator, Comparator::GE); + assert_eq!(value, 3); + } + } + + /// The comparator axis is threaded from the shared `parse_amount_threshold`, + /// not pinned to the single `at least` surface form Adamant happens to use. + #[test] + fn color_mana_spent_threshold_comparator_axis() { + for (text, cmp, n) in [ + ( + "at least two red mana was spent to cast this spell", + Comparator::GE, + 2, + ), + ( + "two or more red mana was spent to cast this spell", + Comparator::GE, + 2, + ), + ( + "two or fewer red mana was spent to cast this spell", + Comparator::LE, + 2, + ), + ( + "two or less red mana was spent to cast this spell", + Comparator::LE, + 2, + ), + ] { + let (_, _, comparator, value) = color_mana_spent(text); + assert_eq!(comparator, cmp, "for {text:?}"); + assert_eq!(value, n, "for {text:?}"); + } + } + + /// CR 400.7d: the subject anaphora selects the scope, and the "was"/"were" + /// number axis is accepted on both surfaces. + #[test] + fn color_mana_spent_threshold_subject_and_number_axes() { + for (text, expected) in [ + ( + "at least three red mana was spent to cast this spell", + CastManaObjectScope::SelfObject, + ), + ( + "at least three red mana was spent to cast this creature", + CastManaObjectScope::SelfObject, + ), + ( + "at least three red mana was spent to cast it", + CastManaObjectScope::SelfObject, + ), + ( + "at least three red mana were spent to cast ~", + CastManaObjectScope::SelfObject, + ), + ( + "at least three red mana was spent to cast that spell", + CastManaObjectScope::TriggeringSpell, + ), + ] { + let (scope, _, _, _) = color_mana_spent(text); + assert_eq!(scope, expected, "for {text:?}"); + } + } + + // ----------------------------------------------------------------------- + // Hostile rows U1-U3: the new arm must not STEAL its siblings' phrases. + // Reach-guard: `color_mana_spent_threshold_all_five_colors` above proves the + // arm is live, so these negatives are not vacuous. + // ----------------------------------------------------------------------- + + /// U1/U2/U3: the sibling phrases must NOT produce an `OfColor` metric. + /// Henge Walker's max-over-colors ("mana of the same color") is explicitly + /// out of scope and must fail closed; the `DistinctColors` and `Total` + /// siblings must keep their own arms. + #[test] + fn color_mana_spent_threshold_does_not_steal_sibling_phrases() { + for text in [ + // U1 — Henge Walker (out of scope, must fail closed entirely). + "at least three mana of the same color was spent to cast this spell", + // U2 — Steel Exemplar's DistinctColors form. + "two or more colors of mana were spent to cast it", + // U3 — the bare Total form, and the source-qualified form. + "at least three mana was spent to cast this spell", + "at least three mana from a treasure was spent to cast this spell", + ] { + let produced_of_color = matches!( + parse_inner_condition(text), + Ok(( + _, + StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::ManaSpentToCast { + metric: CastManaSpentMetric::OfColor { .. }, + .. + }, + }, + .. + } + )) + ); + assert!(!produced_of_color, "new arm stole sibling phrase {text:?}"); + } + // U1 specifically must not parse AT ALL — a silent success would gate + // Henge Walker on the wrong metric instead of failing closed. + assert!( + parse_inner_condition( + "at least three mana of the same color was spent to cast this spell" + ) + .is_err(), + "max-over-colors is out of scope and must fail closed" + ); + } + + /// Group B — sibling non-regression: the three incumbent metrics keep their + /// exact pre-change output now that a fourth arm sits between them in the + /// `parse_resolution_context_conditions` alt. + #[test] + fn color_mana_spent_threshold_siblings_unchanged() { + let metric_of = |text: &str| match parse_inner_condition(text) { + Ok(( + "", + StaticCondition::QuantityComparison { + lhs: + QuantityExpr::Ref { + qty: QuantityRef::ManaSpentToCast { scope, metric }, + }, + comparator, + rhs: QuantityExpr::Fixed { value }, + }, + )) => (scope, metric, comparator, value), + other => panic!("expected mana-spent comparison for {text:?}, got {other:?}"), + }; + + assert_eq!( + metric_of("two or more colors of mana were spent to cast it"), + ( + CastManaObjectScope::SelfObject, + CastManaSpentMetric::DistinctColors, + Comparator::GE, + 2 + ) + ); + assert_eq!( + metric_of("at least three mana was spent to cast this spell"), + ( + CastManaObjectScope::SelfObject, + CastManaSpentMetric::Total, + Comparator::GE, + 3 + ) + ); + assert_eq!( + metric_of("five or more mana was spent to cast that spell"), + ( + CastManaObjectScope::TriggeringSpell, + CastManaSpentMetric::Total, + Comparator::GE, + 5 + ) + ); + } + #[test] fn test_parse_condition_mana_spent_vs_self_power_only() { let (rest, c) = parse_condition( diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 8ddb5a143d..8b07d1676c 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -3905,6 +3905,15 @@ fn parse_enters_with_counters( // the bug class being fixed (Hotheaded Giant / Steel Exemplar). let (work_text, unless_outcome) = extract_enters_with_unless_suffix(work_text); + // CR 614.1c + CR 207.2c: Peel a SENTENCE-INITIAL "[ — ]if + // , " gate (Adamant / Spell mastery / bare leading + // conditional). Before this, no leading-position gate was recognized at all + // and the whole clause was swallowed, making the replacement unconditional. + // Runs AFTER `extract_kicker_enters_condition`, which already consumes and + // strips its own "if ~ was kicked, " prefix, so kicker lines arrive here as + // "it enters with …" → `NoLeadingIf`. + let (work_text, leading_if_outcome) = extract_enters_with_leading_if_gate(work_text); + // CR 702.138c: "escapes with" / plural-subject "escape with" is // semantically "enters with" gated on escape. let is_escape = nom_primitives::scan_contains(work_text, "escapes with") @@ -3993,7 +4002,8 @@ fn parse_enters_with_counters( // one condition slot — so their co-occurrence fails closed. let other_suffix = enters_with_condition_suffix(is_escape, &kicker_condition, work_text); - match resolve_enters_with_condition(&unless_outcome, other_suffix) { + match resolve_enters_with_condition(&unless_outcome, &leading_if_outcome, other_suffix) + { None => return None, Some(Some(cond)) => def = def.condition(cond), Some(None) => {} @@ -4181,7 +4191,7 @@ fn parse_enters_with_counters( // condition slot, so their co-occurrence fails closed (→ unimplemented) // rather than silently dropping one gate. let other_suffix = enters_with_condition_suffix(is_escape, &kicker_condition, work_text); - match resolve_enters_with_condition(&unless_outcome, other_suffix) { + match resolve_enters_with_condition(&unless_outcome, &leading_if_outcome, other_suffix) { None => return None, Some(Some(cond)) => def = def.condition(cond), Some(None) => {} @@ -4223,22 +4233,51 @@ fn enters_with_condition_suffix( } } -/// CR 614.1c: Reconcile the up-front " unless " gate with the trailing -/// conditional-suffix gate. `None` = fail closed (the caller returns `None` so -/// the line falls through to `Effect::unimplemented`): either the unless clause -/// was present-but-unparsed, or both gates co-occur and there is only one -/// condition slot. `Some(None)` = no gate. `Some(Some(cond))` = the single -/// applicable gate. +/// CR 614.1c: Reconcile the three mutually exclusive gate positions an +/// "enters with" clause can carry — the up-front " unless " gate, the +/// sentence-initial "if , " gate, and the trailing conditional suffix. +/// `ReplacementDefinition` has exactly ONE condition slot, so any co-occurrence +/// fails closed rather than silently dropping a gate. +/// +/// `None` = fail closed (the caller returns `None` so the line falls through to +/// `Effect::unimplemented`): a gate was present-but-unparsed, or two gates +/// co-occur. `Some(None)` = no gate. `Some(Some(cond))` = the single applicable +/// gate. fn resolve_enters_with_condition( unless_outcome: &EntersWithUnlessOutcome, + leading_if_outcome: &EntersWithLeadingIfOutcome, other_suffix: Option, ) -> Option> { - match (unless_outcome, other_suffix) { - (EntersWithUnlessOutcome::Unparsed, _) => None, - (EntersWithUnlessOutcome::Parsed(_), Some(_)) => None, - (EntersWithUnlessOutcome::Parsed(cond), None) => Some(Some(cond.clone())), - (EntersWithUnlessOutcome::NoUnlessClause, Some(other)) => Some(Some(other)), - (EntersWithUnlessOutcome::NoUnlessClause, None) => Some(None), + match (unless_outcome, leading_if_outcome, other_suffix) { + // Present-but-unparsed on either gate axis: fail closed. + (EntersWithUnlessOutcome::Unparsed, _, _) + | (_, EntersWithLeadingIfOutcome::Unparsed, _) => None, + // The " unless " gate, alone. + (EntersWithUnlessOutcome::Parsed(cond), EntersWithLeadingIfOutcome::NoLeadingIf, None) => { + Some(Some(cond.clone())) + } + // Any two gates at once: one slot, fail closed. + (EntersWithUnlessOutcome::Parsed(_), _, Some(_)) + | (EntersWithUnlessOutcome::Parsed(_), EntersWithLeadingIfOutcome::Parsed(_), _) + | (_, EntersWithLeadingIfOutcome::Parsed(_), Some(_)) => None, + // The sentence-initial "if , " gate, alone. + ( + EntersWithUnlessOutcome::NoUnlessClause, + EntersWithLeadingIfOutcome::Parsed(cond), + None, + ) => Some(Some(cond.clone())), + // The trailing conditional suffix, alone. + ( + EntersWithUnlessOutcome::NoUnlessClause, + EntersWithLeadingIfOutcome::NoLeadingIf, + Some(other), + ) => Some(Some(other)), + // No gate at all. + ( + EntersWithUnlessOutcome::NoUnlessClause, + EntersWithLeadingIfOutcome::NoLeadingIf, + None, + ) => Some(None), } } @@ -4370,6 +4409,89 @@ fn extract_enters_with_unless_suffix(text: &str) -> (&str, EntersWithUnlessOutco (head, EntersWithUnlessOutcome::Unparsed) } +/// Outcome of scanning an "enters with [counters]" clause for a SENTENCE-INITIAL +/// "if , " gate (CR 614.1c). +/// +/// The presence axis is decided independently of the parse axis: once `"if "` +/// matches at position 0 the clause IS a gate, so an unrecognized condition +/// yields `Unparsed` (caller fails closed) and never `NoLeadingIf`. Silently +/// dropping the gate — which would make the replacement unconditional — is the +/// bug class this fixes. +#[derive(Debug, Clone)] +enum EntersWithLeadingIfOutcome { + /// No sentence-initial "if " — the payload text is returned byte-identical, + /// ability-word label included. + NoLeadingIf, + /// A recognized game-state condition; the replacement applies only while it + /// holds (only-if polarity). + Parsed(ReplacementCondition), + /// A sentence-initial "if " gate is present but unrecognized — the caller + /// MUST fail closed so the line falls through to `Effect::unimplemented`. + /// + /// Note the fall-through is a SHARED `None` signal: `parse_enters_with_counters` + /// returns `None` both for "not my shape" and for "my shape, gate unparseable", + /// so nothing structurally stops a LATER replacement parser from claiming the + /// same line as an UNCONDITIONAL replacement and reintroducing the bug class. + /// That it does not happen is a corpus fact pinned card-level by + /// `unparseable_leading_if_gate_fails_closed` (Henge Walker), not a type-level + /// guarantee — a new sibling shape with a leading gate needs its own pin. + Unparsed, +} + +/// CR 614.1c: Split a SENTENCE-INITIAL "if , " gate off an +/// "enters with [counter payload]" clause and classify the condition. Returns +/// the gate-free body plus the outcome. +/// +/// This is the leading-position mirror of `extract_enters_with_only_if_suffix` +/// (which scans for a TRAILING " if "). Adamant (Ardenvale Paladin), Spell +/// mastery (Necromantic Summons) and bare-conditional cards (Dust Animus) all +/// write the gate first: "[ — ]If , ~ enters with …". +/// +/// CR 207.2c: the optional em-dash label is an ability word, which has no rules +/// meaning and must be peeled before the `"if "` test. +/// +/// The peel uses `parse_known_ability_word_name`, i.e. the CURATED list in +/// `oracle_modal::ABILITY_WORD_NAMES` — the CR 207.2c ability words PLUS a small +/// number of curated CR 207.2d flavor markers ("protector", "proclamator hailer", +/// "increment"). It is deliberately NOT the loose 4-word `strip_ability_word` +/// heuristic, which would peel ANY short em-dash label: that would strip Scarlet +/// Spider, Ben Reilly's "Sensational Save" (a CR 207.2d flavor word, absent from +/// the curated list), exposing its `"if ~ was cast using web-slinging, …"` body +/// to the `"if "` test. `parse_inner_condition` consumes only `"~ was cast"` +/// there, the `", "` check then fails, and the card would regress from its +/// working `CastVariantPaid` routing to `Unparsed`. +/// +/// The three curated flavor markers ARE peelable, so this is a bounded widening +/// rather than a strict CR 207.2c gate. That is accepted, not overlooked: no card +/// in the corpus writes `" — If …, … enters with …"`, and a +/// future one would fail closed to `Effect::unimplemented` (honest gap), never +/// silently drop its gate. Pinned by `flavor_word_label_is_not_peelable_as_an_ability_word`. +fn extract_enters_with_leading_if_gate(text: &str) -> (&str, EntersWithLeadingIfOutcome) { + let after_label = opt(terminated( + super::oracle_modal::parse_known_ability_word_name, + alt((tag(" — "), tag(" – "), tag(" - "))), + )) + .parse(text) + .map_or(text, |(rest, _)| rest); + + let Ok((condition_text, _)) = tag::<_, _, OracleError<'_>>("if ").parse(after_label) else { + return (text, EntersWithLeadingIfOutcome::NoLeadingIf); + }; + + // `parse_inner_condition` is self-delimiting (not `all_consuming`), so it + // stops at the comma that separates the gate from the payload. + let Ok((rest, condition)) = parse_inner_condition(condition_text) else { + return (text, EntersWithLeadingIfOutcome::Unparsed); + }; + let Ok((body, _)) = tag::<_, _, OracleError<'_>>(", ").parse(rest) else { + return (text, EntersWithLeadingIfOutcome::Unparsed); + }; + match replacement_condition_from_static(condition) { + Some(cond) => (body, EntersWithLeadingIfOutcome::Parsed(cond)), + None => (text, EntersWithLeadingIfOutcome::Unparsed), + } +} + /// CR 614.1c + CR 614.1d: Map a parsed `StaticCondition` to the `unless`-polarity /// `ReplacementCondition`. Unlike `replacement_condition_from_static` (the /// only-if polarity used by "enters with ... if ..."), an "unless" clause @@ -20971,6 +21093,207 @@ mod tests { "copy-effect must not match the modal as-enters recognizer" ); } + + // ----------------------------------------------------------------------- + // CR 614.1c + CR 207.2c: the sentence-initial "if , " enters-with gate. + // ----------------------------------------------------------------------- + + /// POSITIVE reach-guard for every negative below: Ardenvale Paladin's + /// Adamant gate is peeled off the ability-word label and attached as an + /// only-if `ReplacementCondition`. Revert the peel and `condition` is + /// `None` — the counter would apply unconditionally. + #[test] + fn adamant_leading_if_gate_attaches_only_if_quantity() { + let def = parse_replacement_line( + "Adamant — If at least three white mana was spent to cast this spell, this creature \ + enters with a +1/+1 counter on it.", + "Ardenvale Paladin", + ) + .expect("Adamant enters-with must still parse to a replacement"); + + assert_eq!( + def.condition, + Some(ReplacementCondition::OnlyIfQuantity { + lhs: QuantityExpr::Ref { + qty: QuantityRef::ManaSpentToCast { + scope: crate::types::ability::CastManaObjectScope::SelfObject, + metric: crate::types::ability::CastManaSpentMetric::OfColor { + color: ManaColor::White, + }, + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 3 }, + active_player_req: None, + }), + "the Adamant gate must be attached, not swallowed" + ); + // Payload untouched by the peel: still one +1/+1 counter on itself. + let execute = def.execute.as_deref().expect("execute present"); + assert!(matches!( + &*execute.effect, + Effect::PutCounter { + counter_type: CounterType::Plus1Plus1, + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::SelfRef, + } + )); + } + + /// U5 — hostile co-occurrence rows. `ReplacementDefinition` has exactly one + /// condition slot, so a leading "if" gate that co-occurs with either of the + /// other two gate positions MUST fail closed (`None`), never silently drop + /// one. Both rows are dead on the current corpus; this pins them. + /// Reach-guard: the two single-gate rows immediately below return `Some`. + #[test] + fn resolve_enters_with_condition_fails_closed_on_gate_co_occurrence() { + let gate = || ReplacementCondition::CastViaEscape; + let other = || ReplacementCondition::YouAttackedThisTurn; + + // (NoUnlessClause, Parsed, Some) — leading gate + trailing suffix. + assert!(resolve_enters_with_condition( + &EntersWithUnlessOutcome::NoUnlessClause, + &EntersWithLeadingIfOutcome::Parsed(gate()), + Some(other()), + ) + .is_none()); + // (Parsed, Parsed, None) — unless gate + leading gate. + assert!(resolve_enters_with_condition( + &EntersWithUnlessOutcome::Parsed(other()), + &EntersWithLeadingIfOutcome::Parsed(gate()), + None, + ) + .is_none()); + // Present-but-unparsed leading gate fails closed regardless of the rest. + assert!(resolve_enters_with_condition( + &EntersWithUnlessOutcome::NoUnlessClause, + &EntersWithLeadingIfOutcome::Unparsed, + None, + ) + .is_none()); + + // POSITIVE reach-guards: each gate ALONE still resolves. + assert_eq!( + resolve_enters_with_condition( + &EntersWithUnlessOutcome::NoUnlessClause, + &EntersWithLeadingIfOutcome::Parsed(gate()), + None, + ), + Some(Some(gate())) + ); + assert_eq!( + resolve_enters_with_condition( + &EntersWithUnlessOutcome::NoUnlessClause, + &EntersWithLeadingIfOutcome::NoLeadingIf, + Some(other()), + ), + Some(Some(other())) + ); + assert_eq!( + resolve_enters_with_condition( + &EntersWithUnlessOutcome::NoUnlessClause, + &EntersWithLeadingIfOutcome::NoLeadingIf, + None, + ), + Some(None) + ); + } + + /// U6 — LOAD-BEARING: the peel uses `parse_known_ability_word_name` (the + /// curated `ABILITY_WORD_NAMES` list — the CR 207.2c ability words plus + /// three curated CR 207.2d flavor markers; see the contract on + /// `extract_enters_with_leading_if_gate`), NOT the loose 4-word + /// `strip_ability_word` heuristic. What matters here is that + /// "Sensational Save" is absent from that curated list; it is NOT the case + /// that every CR 207.2d flavor word is absent. + /// If "Sensational Save" were peelable, Scarlet Spider's body would + /// reach `tag("if ")`, `parse_inner_condition` would consume only + /// "~ was cast", the following `tag(", ")` would fail, and the card would + /// regress from green to `Unparsed` → `Effect::unimplemented`. + #[test] + fn flavor_word_label_is_not_peelable_as_an_ability_word() { + assert!( + super::super::oracle_modal::parse_known_ability_word_name( + "sensational save — if ~ was cast using web-slinging, he enters with x +1/+1 \ + counters on him." + ) + .is_err(), + "\"Sensational Save\" is absent from the curated ABILITY_WORD_NAMES list and must \ + stay unpeelable — peeling it regresses Scarlet Spider, Ben Reilly to Unparsed. \ + (Note the list DOES contain three curated CR 207.2d markers, so this is the \ + specific case, not a universal 'no flavor word is peelable' invariant.)" + ); + // Positive reach-guard: a real CR 207.2c ability word IS peeled, so the + // negative above is not vacuous. + assert_eq!( + super::super::oracle_modal::parse_known_ability_word_name( + "adamant — if at least three white mana was spent to cast this spell, ~ enters \ + with a +1/+1 counter on it." + ) + .map(|(_, name)| name), + Ok("adamant") + ); + } + + /// U7 — positive card-level pin for F1: Scarlet Spider's gate is still the + /// web-slinging suffix condition, reached by the scan-anywhere route the + /// peel deliberately does not intercept. + #[test] + fn flavor_word_card_keeps_its_scan_anywhere_gate() { + let def = parse_replacement_line( + "Sensational Save — If Scarlet Spider was cast using web-slinging, he enters with X \ + +1/+1 counters on him, where X is the mana value of the returned creature.", + "Scarlet Spider, Ben Reilly", + ) + .expect("Scarlet Spider's enters-with must still parse"); + assert_eq!( + def.condition, + Some(ReplacementCondition::CastVariantPaid { + variant: CastVariantPaid::WebSlinging, + }) + ); + } + + /// U9 — honest failure, both polarities. Henge Walker's max-over-colors + /// ("mana of the same color") has no grammar and Red and Black Legac chains + /// three sentence-initial "if"s into one condition slot; both are explicitly + /// out of scope and MUST fail closed rather than parse to an unconditional + /// replacement. Reach-guard: the parseable sibling + /// `adamant_leading_if_gate_attaches_only_if_quantity` proves the peel is + /// live, so `None` here is not a dead-code pass. + #[test] + fn unparseable_leading_if_gate_fails_closed() { + assert!( + parse_replacement_line( + "Adamant — If at least three mana of the same color was spent to cast this spell, \ + this creature enters with a +1/+1 counter on it.", + "Henge Walker", + ) + .is_none(), + "an unrecognized leading gate must fail closed, not become unconditional" + ); + // Card level: the line falls through to `Effect::unimplemented` — the + // engine reports the gap instead of silently mis-resolving. + let card = parse_oracle_text( + "Adamant — If at least three mana of the same color was spent to cast this spell, \ + this creature enters with a +1/+1 counter on it.", + "Henge Walker", + &[], + &["Artifact".to_string(), "Creature".to_string()], + &["Golem".to_string()], + ); + assert!( + card.replacements.is_empty(), + "no replacement may be published for the fail-closed line" + ); + assert!( + card.abilities + .iter() + .any(|a| matches!(&*a.effect, Effect::Unimplemented { .. })), + "the fail-closed line must surface as Effect::Unimplemented, got {:?}", + card.abilities + ); + } } /// Snapshot tests locking current replacement parser output before/after the IR split. diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index c1e9690522..372cb322c0 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -5439,6 +5439,11 @@ pub enum CastManaSpentMetric { Total, /// Number of distinct colors of mana spent. DistinctColors, + /// CR 106.3 + CR 601.2h: Amount of mana of one specific color spent to pay + /// the cost — the Adamant axis ("if at least three white mana was spent to + /// cast this spell"). A leaf parameterization of the same payment record + /// `Total` and `DistinctColors` aggregate differently. + OfColor { color: ManaColor }, /// Amount of mana whose source matched the filter at payment time. FromSource { source_filter: TargetFilter }, } @@ -18271,8 +18276,18 @@ pub enum AbilityCondition { /// CR 601.3b + CR 702.8a: The source permanent came from a spell cast using /// a specific timing permission this turn. CastTimingPermission { permission: CastTimingPermission }, - /// CR 601.2h + CR 608.2c: "if {C} was spent to cast this spell" gates - /// resolution on the source object's recorded paid-mana colors. + /// CR 601.2h: "if {C} was spent to cast this spell" gates resolution on the + /// source object's recorded paid-mana colors. + /// + /// LEGACY SHAPE. The canonical generic form is + /// `QuantityCheck { lhs: ManaSpentToCast { scope, OfColor { color } }, GE, Fixed(minimum) }`, + /// which carries the CR 400.7d subject anaphora as an explicit + /// `CastManaObjectScope` that this variant cannot express. The leading-word + /// Adamant grammar already emits the generic form; this variant survives + /// only for the symbolic `{W}{W}` phrasing, which has no + /// `parse_inner_condition` grammar and fans into `And`/`Not` compositions. + /// Retiring it is a semantic migration (per-card scope decision), not a + /// rename — see `TriggerCondition::ManaColorSpent` for the sibling case. ManaColorSpent { color: ManaColor, minimum: u32 }, /// CR 608.2c: "If it's a [type] card" — gates sub_ability on the last /// revealed card's type, or on the just-moved card when the parent effect @@ -19383,6 +19398,14 @@ pub enum TriggerCondition { /// a specific timing permission this turn. CastTimingPermission { permission: CastTimingPermission }, /// CR 207.2c: "if at least N mana of [color] was spent to cast this spell" — Adamant. + /// + /// LEGACY SHAPE, produced by the independent trigger-side grammar in + /// `parser::oracle_trigger`. The canonical generic form is + /// `QuantityCheck { lhs: ManaSpentToCast { scope, OfColor { color } }, GE, Fixed(minimum) }` + /// (see `AbilityCondition::ManaColorSpent`). Converging this one is a + /// SEMANTIC migration, not a rename: the producer accepts both "this spell" + /// and "that spell" and records neither, so lowering requires a per-card + /// CR 400.7d `CastManaObjectScope` decision. ManaColorSpent { color: ManaColor, minimum: u32 }, /// CR 601.2b: "if no mana was spent to cast it" / "if mana from a [source] was spent" ManaSpentCondition { text: String }, diff --git a/crates/engine/tests/integration/adamant_enters_with_leading_if_gate.rs b/crates/engine/tests/integration/adamant_enters_with_leading_if_gate.rs new file mode 100644 index 0000000000..77993981b8 --- /dev/null +++ b/crates/engine/tests/integration/adamant_enters_with_leading_if_gate.rs @@ -0,0 +1,368 @@ +//! Runtime cast-pipeline coverage for the SENTENCE-INITIAL "if , " +//! enters-with gate (CR 614.1c), the position Adamant and Spell mastery cards +//! write their gate in. +//! +//! Before the fix, `parse_enters_with_counters` recognized a gate only in the +//! trailing " unless …" / " … if …" positions, so a leading gate was silently +//! swallowed and the replacement became UNCONDITIONAL — the Adamant Paladins +//! entered with a +1/+1 counter no matter what mana paid for them. +//! +//! Built via the `/card-test` recipe: `GameScenario` + +//! `GameRunner::cast(..).resolve()` + `CastOutcome` counter/life deltas, on +//! verbatim Oracle text. Every negative assertion is paired with a positive +//! reach-guard in the same test AND with a structural guard proving the card +//! parsed (a `Some` replacement condition, zero `Effect::Unimplemented`), so an +//! upstream parse failure cannot satisfy it vacuously. +//! +//! REVERT DISCRIMINATOR: `ardenvale_paladin_white_below_threshold_no_counter` +//! (R2). Neutralize `extract_enters_with_leading_if_gate` to always return +//! `NoLeadingIf` and the gate is dropped again — the counter applies +//! unconditionally and R2's `assert_counters(.., 0)` fails. + +use engine::game::scenario::{CastOutcome, GameRunner, GameScenario, P0, P1}; +use engine::types::ability::Effect; +use engine::types::counter::CounterType; +use engine::types::identifiers::ObjectId; +use engine::types::keywords::KeywordKind; +use engine::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +/// Ardenvale Paladin {3}{W} 2/3 — verbatim Oracle text (`data/card-data.json`). +const ARDENVALE_PALADIN: &str = "Adamant — If at least three white mana was spent to cast this \ + spell, this creature enters with a +1/+1 counter on it."; + +/// Embereth Paladin {3}{R} 3/1 — verbatim Oracle text, Haste line included. +const EMBERETH_PALADIN: &str = "Haste\nAdamant — If at least three red mana was spent to cast \ + this spell, this creature enters with a +1/+1 counter on it."; + +/// Dust Animus {1}{W} 1/1 — verbatim Oracle text. The Plot line is dropped +/// because plotting is irrelevant here and its reminder text would only add +/// unrelated parse surface; the leading-if line under test is verbatim. +const DUST_ANIMUS: &str = "Flying\nIf you control five or more untapped lands, this creature \ + enters with two +1/+1 counters and a lifelink counter on it."; + +/// Slaying Fire {2}{R} — verbatim Oracle text. Guards the Adamant ABILITY +/// RIDER, which converges on the same `OfColor` quantity shape as the +/// replacement gate. +const SLAYING_FIRE: &str = "Slaying Fire deals 3 damage to any target.\nAdamant — If at least \ + three red mana was spent to cast this spell, it deals 4 damage \ + instead."; + +fn mana(kind: ManaType, n: usize) -> Vec { + vec![ManaUnit::new(kind, ObjectId(0), false, vec![]); n] +} + +/// Build a pool from a colored/colorless mix, in one place so each row reads as +/// its payment record. +fn pool(colored: &[(ManaType, usize)]) -> Vec { + colored + .iter() + .flat_map(|(kind, n)| mana(*kind, *n)) + .collect() +} + +/// Cast a 4-mana Paladin ({3}{}) out of an exactly-sized pool and return +/// the outcome plus its object id. +/// +/// The pool always holds EXACTLY the four mana the cost needs, so the auto-payer +/// has no discretion: every unit in the pool is spent and `colors_spent_to_cast` +/// (CR 601.2h) is fully determined by the pool contents. That removes payment +/// nondeterminism from every assertion below. +fn cast_paladin( + name: &str, + oracle: &str, + shard: ManaCostShard, + power: i32, + toughness: i32, + payment: &[(ManaType, usize)], +) -> (CastOutcome, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let paladin = scenario + .add_creature_to_hand_from_oracle(P0, name, power, toughness, oracle) + .with_mana_cost(ManaCost::Cost { + generic: 3, + shards: vec![shard], + }) + .id(); + scenario.with_mana_pool(P0, pool(payment)); + let mut runner = scenario.build(); + + assert_gate_is_attached(&runner, paladin, name); + + let outcome = runner.cast(paladin).resolve(); + assert_eq!( + outcome.zone_of(paladin), + Zone::Battlefield, + "{name} must resolve onto the battlefield" + ); + (outcome, paladin) +} + +/// Structural reach-guard (the `/card-test` foot-gun #6 defence): the card +/// really parsed, the enters-with replacement really exists, and it really +/// carries a condition. Without this, a "0 counters" assertion would pass just +/// as well on a card whose replacement failed to parse at all. +fn assert_gate_is_attached(runner: &GameRunner, obj: ObjectId, name: &str) { + let object = &runner.state().objects[&obj]; + assert!( + !object + .abilities + .iter() + .any(|a| matches!(&*a.effect, Effect::Unimplemented { .. })), + "{name} must parse with zero Effect::Unimplemented, got {:?}", + object.abilities + ); + let def = object + .replacement_definitions + .first() + .unwrap_or_else(|| panic!("{name} must publish an enters-with replacement")); + assert!( + def.condition.is_some(), + "{name}'s enters-with replacement must carry the leading-if gate; \ + a None condition means the gate was swallowed and the counter is unconditional" + ); +} + +// --------------------------------------------------------------------------- +// R1-R6 — the Adamant per-color threshold, CR 106.3 + CR 601.2h. +// --------------------------------------------------------------------------- + +/// R1 — POSITIVE reach-guard for the whole white family. {W}{W}{W}{W} pays +/// {3}{W}: white spent = 4 >= 3, so the counter applies. Discriminates +/// `OfColor` from `DistinctColors` (which is 1 here, below the threshold). +#[test] +fn ardenvale_paladin_four_white_applies_counter() { + let (outcome, paladin) = cast_paladin( + "Ardenvale Paladin", + ARDENVALE_PALADIN, + ManaCostShard::White, + 2, + 3, + &[(ManaType::White, 4)], + ); + outcome.assert_counters(paladin, CounterType::Plus1Plus1, 1); +} + +/// R2 — **THE PRIMARY REVERT DISCRIMINATOR.** One white + three colorless pays +/// {3}{W}: white spent = 1 < 3, so NO counter. Total mana spent is 4 >= 3, so +/// this row also discriminates `OfColor` from `CastManaSpentMetric::Total`. +/// +/// Drop the leading-if peel and the replacement becomes unconditional → 1 +/// counter → this assertion fails. Paired reach-guard: R1 above. +#[test] +fn ardenvale_paladin_white_below_threshold_no_counter() { + let (outcome, paladin) = cast_paladin( + "Ardenvale Paladin", + ARDENVALE_PALADIN, + ManaCostShard::White, + 2, + 3, + &[(ManaType::White, 1), (ManaType::Colorless, 3)], + ); + outcome.assert_counters(paladin, CounterType::Plus1Plus1, 0); +} + +/// R6 — pins the comparator (GE, not GT) and the literal threshold 3. Exactly +/// three white → counter applies; exactly two white → it does not. +#[test] +fn ardenvale_paladin_threshold_is_greater_or_equal_three() { + let (at_threshold, paladin) = cast_paladin( + "Ardenvale Paladin", + ARDENVALE_PALADIN, + ManaCostShard::White, + 2, + 3, + &[(ManaType::White, 3), (ManaType::Colorless, 1)], + ); + at_threshold.assert_counters(paladin, CounterType::Plus1Plus1, 1); + + let (below, paladin) = cast_paladin( + "Ardenvale Paladin", + ARDENVALE_PALADIN, + ManaCostShard::White, + 2, + 3, + &[(ManaType::White, 2), (ManaType::Colorless, 2)], + ); + below.assert_counters(paladin, CounterType::Plus1Plus1, 0); +} + +/// R4 — POSITIVE reach-guard for the red family, and proof the color is read +/// per-card rather than hardcoded to the first card fixed (white). Three red + +/// one colorless pays {3}{R}: red spent = 3 >= 3 → counter. +#[test] +fn embereth_paladin_three_red_applies_counter() { + let (outcome, paladin) = cast_paladin( + "Embereth Paladin", + EMBERETH_PALADIN, + ManaCostShard::Red, + 3, + 1, + &[(ManaType::Red, 3), (ManaType::Colorless, 1)], + ); + outcome.assert_counters(paladin, CounterType::Plus1Plus1, 1); +} + +/// R3 — the gate reads the card's OWN color. {W}{W}{W}{R} pays Embereth's +/// {3}{R}: red = 1 < 3 (no counter) even though WHITE = 3 would have passed +/// Ardenvale's gate, and total = 4 would have passed a `Total` gate. +/// Paired reach-guard: R4 above. +#[test] +fn embereth_paladin_reads_red_not_white() { + let (outcome, paladin) = cast_paladin( + "Embereth Paladin", + EMBERETH_PALADIN, + ManaCostShard::Red, + 3, + 1, + &[(ManaType::White, 3), (ManaType::Red, 1)], + ); + outcome.assert_counters(paladin, CounterType::Plus1Plus1, 0); +} + +/// R5 — the row that separates `OfColor` from `DistinctColors` at a point where +/// `DistinctColors` PASSES. {W}{U}{B}{R} pays Embereth's {3}{R}: four distinct +/// colors (>= 3, so a `DistinctColors` gate would fire) but red = 1 < 3, so the +/// correct `OfColor` gate does not. Paired reach-guard: R4 above. +#[test] +fn embereth_paladin_four_distinct_colors_still_no_counter() { + let (outcome, paladin) = cast_paladin( + "Embereth Paladin", + EMBERETH_PALADIN, + ManaCostShard::Red, + 3, + 1, + &[ + (ManaType::White, 1), + (ManaType::Blue, 1), + (ManaType::Black, 1), + (ManaType::Red, 1), + ], + ); + outcome.assert_counters(paladin, CounterType::Plus1Plus1, 0); +} + +// --------------------------------------------------------------------------- +// R7 — Dust Animus: a leading-if gate that is NOT a mana-spent threshold. +// Proves the peel makes the WHOLE `parse_inner_condition` grammar reachable +// from the sentence-initial position, not just the one new arm. +// --------------------------------------------------------------------------- + +/// Cast Dust Animus ({1}{W}) out of an exact pool while controlling +/// `untapped_lands` untapped and `tapped_lands` tapped Plains. The lands are +/// never tapped for mana (the pool is pre-staged), so their tap state is +/// controlled purely by the fixture. +fn cast_dust_animus(untapped_lands: usize, tapped_lands: usize) -> (CastOutcome, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let animus = scenario + .add_creature_to_hand_from_oracle(P0, "Dust Animus", 1, 1, DUST_ANIMUS) + .with_mana_cost(ManaCost::Cost { + generic: 1, + shards: vec![ManaCostShard::White], + }) + .id(); + let mut to_tap = Vec::new(); + for _ in 0..untapped_lands { + scenario.add_basic_land(P0, ManaColor::White); + } + for _ in 0..tapped_lands { + to_tap.push(scenario.add_basic_land(P0, ManaColor::White)); + } + scenario.with_mana_pool(P0, pool(&[(ManaType::White, 1), (ManaType::Colorless, 1)])); + let mut runner = scenario.build(); + for land in to_tap { + runner.state_mut().objects.get_mut(&land).unwrap().tapped = true; + } + + assert_gate_is_attached(&runner, animus, "Dust Animus"); + + let outcome = runner.cast(animus).resolve(); + assert_eq!( + outcome.zone_of(animus), + Zone::Battlefield, + "Dust Animus must resolve onto the battlefield" + ); + (outcome, animus) +} + +/// R7a — POSITIVE reach-guard: five untapped lands satisfies the gate, so Dust +/// Animus keeps both counter payloads. This is the "the peel did not break the +/// already-green card" row (Dust Animus is `supported=true` today, but was +/// silently UNCONDITIONAL — the gate was swallowed). +#[test] +fn dust_animus_five_untapped_lands_applies_counters() { + let (outcome, animus) = cast_dust_animus(5, 0); + outcome.assert_counters(animus, CounterType::Plus1Plus1, 2); + assert_eq!( + outcome.counters(animus, CounterType::Keyword(KeywordKind::Lifelink)), + 1, + "the lifelink counter rides the same gated payload" + ); +} + +/// R7b — REVERT DISCRIMINATOR (second polarity). Six lands, one of them TAPPED, +/// leaves four untapped: below the "five or more untapped lands" threshold, so +/// no counters. Drop the peel and the payload applies unconditionally → 2 +1/+1 +/// counters → this fails. Also discriminates `FilterProp::Untapped` from a bare +/// land count: a count-only reading would see six lands and fire. +/// Paired reach-guard: R7a above. +#[test] +fn dust_animus_only_four_untapped_lands_no_counters() { + let (outcome, animus) = cast_dust_animus(4, 2); + outcome.assert_counters(animus, CounterType::Plus1Plus1, 0); + assert_eq!( + outcome.counters(animus, CounterType::Keyword(KeywordKind::Lifelink)), + 0, + "the whole gated payload is suppressed, not just the +1/+1 counters" + ); +} + +// --------------------------------------------------------------------------- +// R8 — the Adamant ABILITY RIDER. The same grammar change re-routes 11 riders +// from `AbilityCondition::ManaColorSpent` to the generic +// `QuantityCheck { ManaSpentToCast { .., OfColor } }`. This is the mandatory +// runtime guard on that AST churn: the observable damage must not move. +// --------------------------------------------------------------------------- + +fn cast_slaying_fire(payment: &[(ManaType, usize)]) -> CastOutcome { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let fire = scenario + .add_spell_to_hand_from_oracle(P0, "Slaying Fire", true, SLAYING_FIRE) + .with_mana_cost(ManaCost::Cost { + generic: 2, + shards: vec![ManaCostShard::Red], + }) + .id(); + scenario.with_mana_pool(P0, pool(payment)); + let mut runner = scenario.build(); + assert!( + !runner.state().objects[&fire] + .abilities + .iter() + .any(|a| matches!(&*a.effect, Effect::Unimplemented { .. })), + "Slaying Fire must parse with zero Effect::Unimplemented, got {:?}", + runner.state().objects[&fire].abilities + ); + runner.cast(fire).target_player(P1).resolve() +} + +/// R8 positive: three red mana satisfies the Adamant rider → 4 damage, not 3. +#[test] +fn slaying_fire_three_red_deals_four() { + let outcome = cast_slaying_fire(&[(ManaType::Red, 3)]); + outcome.assert_life_delta(P1, -4); +} + +/// R8 negative (paired with the positive above): one red + two colorless is +/// three TOTAL mana but only one RED, so the rider does not fire → 3 damage. +/// Discriminates `OfColor` from `Total` on the ability-rider path exactly as R2 +/// does on the replacement path. +#[test] +fn slaying_fire_one_red_deals_three() { + let outcome = cast_slaying_fire(&[(ManaType::Red, 1), (ManaType::Colorless, 2)]); + outcome.assert_life_delta(P1, -3); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 2d91992d5a..59170611d1 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1,6 +1,7 @@ mod abigale_integration; mod abundance_optional_draw_replacement; mod ad_nauseam_repeat; +mod adamant_enters_with_leading_if_gate; mod adapter_contract_fixtures; mod advanced_reconstruction_regression; mod ajani_nacatl_pariah_co_departure_6427; diff --git a/docs/parser-misparse-backlog.md b/docs/parser-misparse-backlog.md index 0b7274791a..fe10c19436 100644 --- a/docs/parser-misparse-backlog.md +++ b/docs/parser-misparse-backlog.md @@ -3,8 +3,8 @@ Consolidated from 50 per-batch clustering passes over the whole card database. Synonymous per-batch clusters were merged into canonical root causes, their card lists unioned and deduped, and ranked by total card appearances (largest first). - **Canonical root causes:** 30 -- **Distinct cards implicated:** 4733 -- **Total card appearances across root causes:** 4767 (a card may appear under more than one root cause when it exhibits multiple distinct misparses) +- **Distinct cards implicated:** 4729 +- **Total card appearances across root causes:** 4763 (a card may appear under more than one root cause when it exhibits multiple distinct misparses) This is the prioritized "fix N root causes → unlock M cards" backlog: the top handful of root causes account for the majority of broken cards. @@ -13,7 +13,7 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top | # | Root cause | # cards | Fix hint (where it likely lives) | |---|------------|--------:|----------------------------------| | 1 | Relative-clause / filter restriction on target dropped | 746 | oracle_target.rs / game/filter.rs — extend TargetFilter property extraction for trailing relative clauses | -| 2 | Dropped intervening-if / gating condition (condition: null) | 590 | oracle_nom/condition.rs parse_inner_condition — trigger/static parsers must delegate condition extraction here | +| 2 | Dropped intervening-if / gating condition (condition: null) | 586 | oracle_nom/condition.rs parse_inner_condition — trigger/static parsers must delegate condition extraction here | | 3 | Anaphor bound to wrong referent | 404 | oracle_quantity.rs context-ref resolution + game/ability_utils.rs forward_result wiring | | 4 | Conjoined / chained second effect clause dropped | 387 | oracle.rs effect-chain composition — split on 'and'/'then'/sentence boundaries and build sub_ability chain | | 5 | Dropped 'for each' / dynamic count collapsed to Fixed | 330 | oracle_quantity.rs parse_for_each_clause / parse_quantity_ref — thread ForEach/ObjectCount into the effect count field | @@ -805,12 +805,31 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top -### 2. Dropped intervening-if / gating condition (condition: null) (590 cards) +### 2. Dropped intervening-if / gating condition (condition: null) (586 cards) **Signature.** Trigger/static/replacement/spell condition left null though Oracle has an 'if/while/as long as/unless' game-state gate; the effect resolves unconditionally (CR 603.4 / 608.2c). **Fix hint.** oracle_nom/condition.rs parse_inner_condition — trigger/static parsers must delegate condition extraction here +**Status note (sentence-initial "If <condition>, ~ enters with …" sub-class, CR 614.1c).** +`parse_enters_with_counters` now peels an optional CR 207.2c ability-word label plus a +sentence-initial `if , ` gate and attaches it as the replacement's condition, +making the whole `parse_inner_condition` grammar reachable from that position (previously +NO shape was). A new `CastManaSpentMetric::OfColor` leaf covers the Adamant per-color +threshold. Removed from the list below: Ardenvale Paladin, Locthwain Paladin, +Vantress Paladin (Adamant, now gated and `supported`), and Dust Animus (its +"five or more untapped lands" gate was silently dropped; now attached). Embereth Paladin +and Garenbrig Paladin are fixed by the same change but appear nowhere in this file, so no +line was removed for them. **Deliberately KEPT:** Henge Walker — its +"at least three mana of the same color" is a max-over-colors metric with no grammar and +still fails closed; and Necromantic Summons (listed under §11, not here) — §11's root cause +remains true because its replacement carries `valid_card: SelfRef` on a sorcery, so +`object_replacement_candidate_applies` can never match it; the condition now parses but the +replacement stays dead code. +Observed, not introduced: this section's committed header count (590 before this edit) did +not match its own bullet list (597 entries). The counts here were decremented by the four +removals; the pre-existing 7-entry drift is untouched. +
Cards - A-Paragon of Modernity @@ -834,7 +853,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Antiquities on the Loose - Arcbound Tracker - Arcum's Sleigh -- Ardenvale Paladin - Arid Archway - Ascendant Packleader - Ashen Ghoul @@ -954,7 +972,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Drop of Honey - Drowner of Truth - Drownyard Behemoth -- Dust Animus - Dúnedain Rangers - Earwig Squad - Ego Drain @@ -1118,7 +1135,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Lighthouse Chronologist - Lightning Dart - Liliana's Defeat -- Locthwain Paladin - Lord Skitter's Blessing - Loxodon Surveyor - Ludevic, Necro-Alchemist @@ -1364,7 +1380,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Valiant Emberkin - Vampire Scrivener - Vampire Socialite -- Vantress Paladin - Venom's Hunger - Venom, Eddie Brock - Verity Circle From 694200ed85ad94fb636eaaff8a8b44fc685f2da0 Mon Sep 17 00:00:00 2001 From: nghetien <61019797+nghetien@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:04:17 +0700 Subject: [PATCH 02/63] fix(parser): Runadi, Behemoth Caller ETB counters and haste threshold (#6735) * fix(parser): Runadi, Behemoth Caller ETB counters and haste threshold Runadi's first ability failed to parse its composite "where X is its mana value minus 4" quantity clause, misrouting the whole ability to the generic self-ETB fallback and scoping the counter grant to Runadi herself instead of the cast creature. Delegate to the shared where-X-is suffix parser (already used by the sibling self-ETB path) so composite/offset quantities resolve here too, and thread the entering object through the Recipient scope resolver so "its mana value" reads the creature actually entering, not the static source. Separately, the haste static's "with three or more +1/+1 counters" filter left "or more" stuck onto the counter-type text (a garbage Generic("or more +1/+1") counter type), so no creature ever matched and haste never applied. Strip the redundant or-more/or-greater qualifier after the count, mirroring the existing mana-value handling. * fix(PR-6735): address review feedback * fix(PR-6735): bind Runadi's floating replacement to its triggering spell The one-shot floating replacement Runadi's SpellCast trigger installs was scoped only by type/mana-value filter, so a different qualifying creature entering the battlefield during the priority window between the trigger resolving and the originally-cast spell resolving could consume it first, leaving the intended entrant uncountered. Bind the install to the specific spell that caused the trigger: the parser embeds a TRIGGERING_SPELL_PLACEHOLDER sentinel inside the replacement's valid_card (AND-combined with the existing filter), and Effect::AddTargetReplacement's resolve function concretizes it to the real triggering spell's id from the current trigger event at install time (or to a match-nothing id if none is extractable). Using a sentinel object id instead of a new ReplacementDefinition field avoids rippling a struct change through every exhaustive construction site, including the dormant mtgish-import crate. Also address review nits: the "Runadi leaves before the spell resolves" regression now drives the departure through the production zone-change pipeline instead of poking the object's zone field directly, and the negative mana-value test asserts the trigger actually registered before checking the zero-counter outcome. Added a new regression that casts two qualifying creatures back to back, proving each entrant gets only its own counters. --------- Co-authored-by: matthewevans --- .../game/effects/add_target_replacement.rs | 76 +++- crates/engine/src/game/quantity.rs | 13 + crates/engine/src/parser/oracle.rs | 22 +- .../engine/src/parser/oracle_replacement.rs | 333 ++++++++++++--- crates/engine/src/parser/oracle_target.rs | 52 +++ crates/engine/src/types/ability.rs | 7 +- crates/engine/src/types/identifiers.rs | 15 + crates/engine/tests/integration/main.rs | 1 + .../runadi_behemoth_caller_etb_counters.rs | 378 ++++++++++++++++++ 9 files changed, 839 insertions(+), 58 deletions(-) create mode 100644 crates/engine/tests/integration/runadi_behemoth_caller_etb_counters.rs diff --git a/crates/engine/src/game/effects/add_target_replacement.rs b/crates/engine/src/game/effects/add_target_replacement.rs index fa3d885445..bee07c2fbd 100644 --- a/crates/engine/src/game/effects/add_target_replacement.rs +++ b/crates/engine/src/game/effects/add_target_replacement.rs @@ -1,4 +1,4 @@ -use crate::game::targeting::resolve_event_context_target; +use crate::game::targeting::{extract_source_from_event, resolve_event_context_target}; use crate::types::ability::{ AbilityDefinition, DamageTargetFilter, DamageTargetPlayerScope, Duration, Effect, EffectError, EffectKind, ReplacementCondition, ReplacementDefinition, ResolvedAbility, RestrictionExpiry, @@ -6,6 +6,7 @@ use crate::types::ability::{ }; use crate::types::events::GameEvent; use crate::types::game_state::GameState; +use crate::types::identifiers::ObjectId; use crate::types::replacements::ReplacementEvent; pub(crate) fn expiry_from_duration( @@ -47,6 +48,76 @@ fn replacement_with_ability_expiry( replacement } +/// CR 603.2 + CR 603.3b + CR 117.3b: Concretize +/// `TRIGGERING_SPELL_PLACEHOLDER` — the parse-time sentinel +/// `parse_whenever_you_cast_enters_with_trigger` embeds inside a floating +/// (`TargetFilter::None`) replacement's `valid_card` — to the SPECIFIC spell +/// object referenced by the currently-resolving triggered ability's own +/// originating event (Runadi, Behemoth Caller and the Wildgrowth Archaic +/// cousin family — issue #6492 review). +/// +/// Without this, a bare type/mana-value filter would let a DIFFERENT +/// qualifying creature — cast by the active player during the CR 117.3b +/// priority window between this trigger resolving and the originally-cast +/// spell resolving — consume the one-shot install first, leaving the intended +/// entrant uncountered. `state.current_trigger_event` is exactly this +/// ability's own trigger event (set by `push_resolving_trigger_context` for +/// the duration of its resolution — see `game/triggers.rs`), so +/// `extract_source_from_event` yields the specific cast spell's `ObjectId`. +/// +/// If the event carries no extractable source, this fails CLOSED — matching +/// no object via the `ObjectId(0)` sentinel (never a real permanent) — rather +/// than silently widening back to the bare filter, which would reopen the +/// exact bug this binding exists to close. +/// +/// No-op for every other floating-replacement install (Kaya's until-EOT token +/// doubler, Rankle and Torbran's damage-modification shields): none of them +/// ever embed the placeholder, so the walk finds nothing to replace. +fn bind_replacement_to_trigger_source(replacement: &mut ReplacementDefinition, state: &GameState) { + let Some(valid_card) = replacement.valid_card.as_mut() else { + return; + }; + if !target_filter_contains_placeholder(valid_card) { + return; + } + let bound = state + .current_trigger_event + .as_ref() + .and_then(extract_source_from_event) + .unwrap_or(ObjectId(0)); + concretize_triggering_spell_placeholder(valid_card, bound); +} + +fn target_filter_contains_placeholder(filter: &TargetFilter) -> bool { + match filter { + TargetFilter::SpecificObject { id } => { + *id == crate::types::identifiers::TRIGGERING_SPELL_PLACEHOLDER + } + TargetFilter::And { filters } | TargetFilter::Or { filters } => { + filters.iter().any(target_filter_contains_placeholder) + } + TargetFilter::Not { filter } => target_filter_contains_placeholder(filter), + _ => false, + } +} + +fn concretize_triggering_spell_placeholder(filter: &mut TargetFilter, bound: ObjectId) { + match filter { + TargetFilter::SpecificObject { id } + if *id == crate::types::identifiers::TRIGGERING_SPELL_PLACEHOLDER => + { + *id = bound; + } + TargetFilter::And { filters } | TargetFilter::Or { filters } => { + for f in filters.iter_mut() { + concretize_triggering_spell_placeholder(f, bound); + } + } + TargetFilter::Not { filter } => concretize_triggering_spell_placeholder(filter, bound), + _ => {} + } +} + // CR 614.12a + CR 707.2: If the resolving spell chose the object to copy, bind // that object into the delayed enter-as-copy replacement when the shield is // created so the later entry event does not ask for a new copy source. @@ -237,7 +308,8 @@ pub fn resolve( // Slaughter's "If a source you control would deal damage this turn, // it deals that much damage plus 1 instead."). if matches!(target, TargetFilter::None) { - let replacement = replacement_with_ability_expiry(replacement, ability); + let mut replacement = replacement_with_ability_expiry(replacement, ability); + bind_replacement_to_trigger_source(&mut replacement, state); state.pending_damage_replacements.push(replacement); attached += 1; } else { diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index 409769adf5..716dbab427 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -4628,6 +4628,17 @@ fn object_for_scope<'a>( _ => None, }) }) + // CR 614.12 + CR 613.4c: in an ETB-scoped replacement ("that + // creature enters with ... counters on it, where X is its mana + // value/power/toughness ..."), the recipient IS the entering + // object, not the static replacement source. `ctx.entering` + // carries that identity (mirrors `QuantityContext::self_object`, + // the same convention `CastManaObjectScope::SelfObject` uses for + // Wildgrowth Archaic's "it"). Outside ETB-replacement contexts + // `ctx.entering` is always `None` (only ETB-counter extraction + // sets it), so this fallback is inert for every layer-evaluation + // `Recipient` caller (Blessing of the Nephilim, Civic Saber). + .or_else(|| ctx.entering.and_then(|id| state.objects.get(&id))) .or_else(|| source_object_for_context(state, ctx.source, ctx.trigger_source.as_ref())), // CR 603.4: an intervening-if condition is checked at trigger detection // (current_trigger_event is None then) and re-checked on resolution. @@ -4692,6 +4703,8 @@ fn object_id_for_scope( _ => None, }) }) + // CR 614.12 + CR 613.4c: see the parallel arm in `object_for_scope`. + .or(ctx.entering) .or_else(|| { source_object_for_context(state, ctx.source, ctx.trigger_source.as_ref()) .map(|object| object.id) diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index ae06345c01..7fb0a1c821 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -90,7 +90,7 @@ use super::oracle_modal::{ use super::oracle_replacement::{ find_copy_verb_present, lower_as_enters_becomes_choice_modal, lower_as_enters_or_face_up_counters, lower_replacement_ir, parse_replacement_line, - parse_replacement_line_ir, + parse_replacement_line_ir, parse_whenever_you_cast_enters_with_trigger, }; use super::oracle_saga::{is_saga_chapter, parse_saga_chapters}; use super::oracle_spacecraft::parse_spacecraft_threshold_lines; @@ -5044,9 +5044,6 @@ pub(crate) fn parse_oracle_ir( // are CR 614.1c replacement effects, not triggered abilities — despite // the "whenever"/"when" framing. Intercept before the generic trigger // dispatch routes them through the SpellCast / ChangesZone matcher. - // Applies to Wildgrowth Archaic and cousin cards (Runadi, Boreal - // Outrider, Torgal, Dragon Broodmother, …). `parse_replacement_line` - // handles all the compositional variants (fixed / X / "where X is …"). // // CR 603.2 exclusion: an ETB-with-counter TRIGGER ("… enters with a // counter on it, ") watches for ANY (untyped) counter and @@ -5064,6 +5061,23 @@ pub(crate) fn parse_oracle_ir( && scan_contains(&lower, "enters with") && !scan_contains(&lower, "enters this way,") { + // CR 603.1 + CR 603.3 + CR 614.1c/614.12: "Whenever you cast [spell], + // that [subject] enters with … counter(s) on it[, where X is …]" + // (Wildgrowth Archaic and cousin cards — Runadi, Boreal Outrider, + // Torgal, Dragon Broodmother, …) is a TRIGGERED ability (CR 603.1), + // not an object-hosted static replacement — the entering-with-counters + // effect must survive the source leaving the battlefield after the + // trigger resolves but before the cast spell does (issue #6492 + // review). Try this shape's dedicated trigger recognizer FIRST so it + // never falls through to the generic object-hosted replacement path. + if let Some(trigger) = parse_whenever_you_cast_enters_with_trigger(&line, card_name) { + emitter.trigger_ir_at(item_line, TriggerNodeIr::from_definition(&line, trigger)); + i += 1; + continue; + } + // Every other "… enters with …" shape here (kicker-conditional + // "if ~ was kicked, it enters with …", external "[type] enters + // with …", etc.) is a genuine CR 614.1c object-hosted replacement. if let Some(replacement_ir) = parse_replacement_line_ir(&line, card_name) { emitter.emit_at(item_line, OracleNodeIr::Replacement(replacement_ir)); i += 1; diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index a74fc1f2e6..3b7bb0a50c 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -43,13 +43,14 @@ use crate::types::ability::{ PermissionGrantee, PlayerFilter, PreventionAmount, QuantityExpr, QuantityModification, QuantityRef, ReplacementCondition, ReplacementDefinition, ReplacementMode, ReplacementPlayerScope, StaticCondition, StaticDefinition, TapStateChange, TargetFilter, - TypeFilter, TypedFilter, + TriggerDefinition, TypeFilter, TypedFilter, }; use crate::types::card_type::Supertype; use crate::types::counter::{CounterMatch, CounterType}; use crate::types::mana::{ManaColor, ManaCost, ManaType}; use crate::types::replacements::ReplacementEvent; use crate::types::statics::CastFrequency; +use crate::types::triggers::TriggerMode; use crate::types::zones::Zone; /// Parse a replacement effect line into a ReplacementDefinition. @@ -698,16 +699,17 @@ fn parse_replacement_line_inner(text: &str, card_name: &str) -> Option) -> Ab /// CR 614.1c + CR 601.2: Parse "Whenever you cast a [spell], that [subject] /// enters with [an additional] [count] [type] counter(s) on it[, where X is -/// [quantity]]" as a replacement effect on the *cast spell itself*. +/// [quantity]]" into the `ChangeZone` + `PutCounter` replacement payload for +/// this shape. Wildgrowth Archaic and its cousin family (Runadi, Boreal +/// Outrider, Torgal, …) all share this shape. /// -/// Despite the "whenever you cast" framing, CR 614.1c classifies "enters with" -/// as a replacement effect, not a triggered ability. Wildgrowth Archaic and its -/// cousin family (Runadi, Boreal Outrider, Torgal, …) all share this shape. +/// CR 603.1 + CR 603.3: "whenever" is a triggered ability that goes on the +/// stack — the entering-with-counters EFFECT (CR 614.1c/614.12) only applies +/// once that ability resolves. This function builds only the reusable +/// replacement PAYLOAD (the `ChangeZone`/`PutCounter` shape keyed to the spell +/// filter); it is never returned as a top-level, object-hosted replacement. +/// `parse_whenever_you_cast_enters_with_trigger` is the actual recognizer — +/// it wraps this payload in `Effect::AddTargetReplacement { target: None, .. }` +/// so the triggered ability installs a floating, filter-scoped replacement +/// that survives the source leaving the battlefield (issue #6492 review). /// /// Composition: /// "whenever you cast " → spell filter → ", that " → subject → @@ -4828,21 +4838,18 @@ fn parse_whenever_you_cast_enters_with( .parse(rest) .ok()?; - // Optional trailing "where X is [quantity]" clause. + // Optional trailing "where X is [quantity]" clause. Delegate to + // `parse_enters_with_where_x_suffix` — the single authority for this tail + // grammar, already shared with the self-ETB `parse_enters_with_counters` + // path — so composite/offset quantities ("its mana value minus 4"; CR + // 107.1 arithmetic over a CR 202.3 mana-value reference) resolve here too, + // not just atomic `QuantityRef`s. The previous atomic-only + // `parse_quantity_ref` call silently failed (via `?`) on any composite + // expression, misrouting the whole ability to the generic self-ETB + // fallback (Runadi, Behemoth Caller — issue #6492). let count_expr = match fixed_count { Some(n) => QuantityExpr::Fixed { value: n as i32 }, - None => { - // Expect ", where x is " then a quantity ref. - let (rest, _) = alt(( - tag::<_, _, OracleError<'_>>(", where x is "), - tag(", where X is "), - )) - .parse(rest) - .ok()?; - let qty_text = rest.trim_end_matches('.').trim(); - let qty = crate::parser::oracle_quantity::parse_quantity_ref(qty_text)?; - QuantityExpr::Ref { qty } - } + None => parse_enters_with_where_x_suffix(rest)?, }; let put_counter = AbilityDefinition::new( @@ -4865,6 +4872,82 @@ fn parse_whenever_you_cast_enters_with( ) } +/// CR 603.1 + CR 603.3 + CR 614.1c/614.12: The actual recognizer for "Whenever +/// you cast a [spell], that [subject] enters with ... counter(s) on it[, +/// where X is [quantity]]" (Wildgrowth Archaic, Runadi, Boreal Outrider, +/// Torgal, …). +/// +/// "Whenever" is a triggered ability — it goes on the stack ABOVE the spell +/// that triggered it (CR 603.3b) and resolves first. Modeling this whole +/// sentence as an object-hosted static replacement (the pre-#6492-review +/// design) is rules-wrong: the entering-with-counters effect must survive the +/// source leaving the battlefield after the trigger resolves but before the +/// cast spell does. Instead, this builds a real `TriggerDefinition` +/// (`TriggerMode::SpellCast`, matching the same spell filter) whose resolving +/// effect installs a FLOATING replacement via `Effect::AddTargetReplacement { +/// target: TargetFilter::None, .. }` — pushed to `GameState::pending_damage_replacements` +/// under the `ObjectId(0)` sentinel (see `add_target_replacement.rs`), which the +/// replacement scan (`find_applicable_replacements`) admits independent of any +/// object's zone. `consume_on_apply` makes it one-shot. +/// +/// CR 117.3b: after Runadi's trigger resolves, the active player receives +/// priority BEFORE the originally-cast spell resolves, and could cast a +/// second qualifying flash creature in response — a bare filter-scoped +/// one-shot install would let that INTERLOPING spell's battlefield entry +/// consume the replacement first, leaving the original entrant uncountered. +/// This is closed by AND-ing the spell filter with +/// `TargetFilter::SpecificObject { id: TRIGGERING_SPELL_PLACEHOLDER }` — a +/// parse-time placeholder id that `Effect::AddTargetReplacement`'s resolve +/// function (`add_target_replacement.rs`) concretizes to the SPECIFIC spell +/// object named by `state.current_trigger_event` (this trigger's own +/// originating `SpellCast` event — CR 603.2) at install time, so only that +/// exact spell's entry can ever satisfy it. +pub(crate) fn parse_whenever_you_cast_enters_with_trigger( + text: &str, + card_name: &str, +) -> Option { + let text = strip_reminder_text(text); + let normalized = replace_self_refs(&text, card_name); + let norm_lower = normalized.to_lowercase(); + + let mut replacement = parse_whenever_you_cast_enters_with(&norm_lower, &text)?; + let spell_filter = replacement.valid_card.clone()?; + // CR 614.1c: one qualifying entry, then gone — this floating install must + // never persist to affect a second, later cast of the same shape. + replacement.consume_on_apply = true; + // CR 603.2 + CR 117.3b: bind to the SPECIFIC spell that caused this trigger, + // not just any spell matching the type/mana-value filter — see the + // interleaving-flash-creature note in the doc comment above. + // `TRIGGERING_SPELL_PLACEHOLDER` is concretized to the real triggering + // spell's id (or `ObjectId(0)`, matching nothing) by + // `Effect::AddTargetReplacement`'s resolve function at install time. + replacement.valid_card = Some(TargetFilter::And { + filters: vec![ + spell_filter.clone(), + TargetFilter::SpecificObject { + id: crate::types::identifiers::TRIGGERING_SPELL_PLACEHOLDER, + }, + ], + }); + + let install = AbilityDefinition::new( + AbilityKind::Spell, + Effect::AddTargetReplacement { + replacement: Box::new(replacement), + target: TargetFilter::None, + }, + ); + + Some( + TriggerDefinition::new(TriggerMode::SpellCast) + .valid_card(spell_filter) + .valid_target(TargetFilter::Controller) + .trigger_zones(vec![Zone::Battlefield]) + .execute(install) + .description(text.to_string()), + ) +} + /// Extract kicker-conditional prefix from "if ~ was kicked [with its {cost} kicker], it enters with..." /// Returns `(Option, remaining_text)` where remaining_text has the /// conditional prefix stripped (just "it enters with..." or the original text if no prefix). @@ -19619,38 +19702,60 @@ mod tests { ); } - /// CR 614.1c + CR 601.2h + CR 202.2: Wildgrowth Archaic's replacement line + /// CR 603.1 + CR 603.3 + CR 614.1c/614.12: Wildgrowth Archaic's ability /// ("Whenever you cast a creature spell, that creature enters with X /// additional +1/+1 counters on it, where X is the number of colors of - /// mana spent to cast it.") parses into a `ChangeZone` replacement on the - /// entering creature with a self-scoped spent-mana counter quantity. - #[test] - fn parses_wildgrowth_archaic_replacement() { + /// mana spent to cast it.") parses into a `SpellCast` TRIGGER — not an + /// object-hosted replacement (issue #6492 review: "whenever" is a + /// triggered ability per CR 603.1/603.3, so the entering-with-counters + /// effect must survive the source leaving the battlefield after the + /// trigger resolves but before the cast spell does). The trigger's + /// resolution installs a floating, one-shot `ChangeZone` replacement via + /// `Effect::AddTargetReplacement`. + #[test] + fn parses_wildgrowth_archaic_trigger() { let text = "Whenever you cast a creature spell, that creature enters with X additional +1/+1 counters on it, where X is the number of colors of mana spent to cast it."; - let def = parse_replacement_line(text, "Wildgrowth Archaic") - .expect("Wildgrowth line should parse as a replacement"); - assert_eq!(def.event, ReplacementEvent::ChangeZone); - assert_eq!(def.destination_zone, Some(Zone::Battlefield)); + let trigger = parse_whenever_you_cast_enters_with_trigger(text, "Wildgrowth Archaic") + .expect("Wildgrowth line should parse as a trigger"); + assert_eq!(trigger.mode, TriggerMode::SpellCast); + assert_eq!(trigger.valid_target, Some(TargetFilter::Controller)); - // valid_card: creature controlled by the Archaic's controller. - let TargetFilter::Typed(ref tf) = def.valid_card.as_ref().expect("valid_card set") else { - panic!("expected Typed filter, got {:?}", def.valid_card); + // valid_card: creature spell. + let TargetFilter::Typed(ref tf) = trigger.valid_card.as_ref().expect("valid_card set") + else { + panic!("expected Typed filter, got {:?}", trigger.valid_card); }; assert_eq!(tf.type_filters, vec![TypeFilter::Creature]); assert_eq!(tf.controller, Some(ControllerRef::You)); - // execute: PutCounter { target: SelfRef, count: Ref(self spent-mana colors) }. - let exec = def.execute.as_ref().expect("execute set"); + // execute: AddTargetReplacement { target: None, replacement: one-shot + // ChangeZone + PutCounter { target: SelfRef, count: Ref(self spent-mana colors) } }. + let exec = trigger.execute.as_ref().expect("execute set"); + let Effect::AddTargetReplacement { + replacement, + target, + } = &*exec.effect + else { + panic!("expected AddTargetReplacement, got {:?}", exec.effect); + }; + assert_eq!(target, &TargetFilter::None); + assert!( + replacement.consume_on_apply, + "floating install must be one-shot (CR 614.1c: one qualifying entry, then gone)" + ); + assert_eq!(replacement.event, ReplacementEvent::ChangeZone); + assert_eq!(replacement.destination_zone, Some(Zone::Battlefield)); + let put_counter = replacement.execute.as_ref().expect("execute set"); let Effect::PutCounter { counter_type, count, - target, - } = &*exec.effect + target: put_target, + } = &*put_counter.effect else { - panic!("expected PutCounter, got {:?}", exec.effect); + panic!("expected PutCounter, got {:?}", put_counter.effect); }; assert_eq!(counter_type, &CounterType::Plus1Plus1); - assert_eq!(target, &TargetFilter::SelfRef); + assert_eq!(put_target, &TargetFilter::SelfRef); assert_eq!( count, &QuantityExpr::Ref { @@ -19663,11 +19768,134 @@ mod tests { } /// Regression: a plain "Whenever you cast" trigger without an "enters with" - /// body must NOT be misrouted to the replacement path. + /// body must NOT be misrouted to either the trigger recognizer above or the + /// object-hosted replacement path. #[test] fn plain_whenever_you_cast_is_not_replacement() { let text = "Whenever you cast a creature spell, draw a card."; assert!(parse_replacement_line(text, "Filler").is_none()); + assert!(parse_whenever_you_cast_enters_with_trigger(text, "Filler").is_none()); + } + + /// CR 603.1 + CR 603.3 + CR 614.1c/614.12 + CR 202.3 + CR 107.1: Runadi, + /// Behemoth Caller's first ability ("Whenever you cast a creature spell + /// with mana value 5 or greater, that creature enters with X additional + /// +1/+1 counters on it, where X is its mana value minus 4.") parses into + /// a `SpellCast` trigger (not a static replacement scoped to Runadi + /// herself — issue #6492 regression), whose resolution installs a + /// floating one-shot `ChangeZone` replacement gated on mana value >= 5, + /// with a composite offset quantity over the entering creature's own + /// mana value. + #[test] + fn parses_runadi_behemoth_caller_trigger() { + let text = "Whenever you cast a creature spell with mana value 5 or greater, that creature enters with X additional +1/+1 counters on it, where X is its mana value minus 4."; + let trigger = parse_whenever_you_cast_enters_with_trigger(text, "Runadi, Behemoth Caller") + .expect("Runadi's first ability should parse as a trigger"); + assert_eq!(trigger.mode, TriggerMode::SpellCast); + assert_eq!(trigger.valid_target, Some(TargetFilter::Controller)); + + // valid_card: creature with mana value >= 5, controlled by Runadi's controller. + let TargetFilter::Typed(ref tf) = trigger.valid_card.as_ref().expect("valid_card set") + else { + panic!("expected Typed filter, got {:?}", trigger.valid_card); + }; + assert_eq!(tf.type_filters, vec![TypeFilter::Creature]); + assert_eq!(tf.controller, Some(ControllerRef::You)); + assert!( + tf.properties.iter().any(|p| matches!( + p, + FilterProp::Cmc { + comparator: Comparator::GE, + value: QuantityExpr::Fixed { value: 5 }, + } + )), + "valid_card must gate on mana value >= 5, got {:?}", + tf.properties + ); + + // execute: AddTargetReplacement { target: None, replacement: one-shot + // ChangeZone + PutCounter { target: SelfRef, count: Offset(its mana value, -4) } }. + let exec = trigger.execute.as_ref().expect("execute set"); + let Effect::AddTargetReplacement { + replacement, + target, + } = &*exec.effect + else { + panic!("expected AddTargetReplacement, got {:?}", exec.effect); + }; + assert_eq!(target, &TargetFilter::None); + assert!( + replacement.consume_on_apply, + "floating install must be one-shot, or it would apply to a later, \ + unrelated qualifying spell too" + ); + // valid_card must AND the spell filter with a `SpecificObject` leaf + // carrying the trigger-source placeholder — `Effect::AddTargetReplacement` + // concretizes this to the SPECIFIC triggering spell's id at install + // time, so a different qualifying creature entering during the + // post-trigger priority window can't steal the install. + let TargetFilter::And { filters } = + replacement.valid_card.as_ref().expect("valid_card set") + else { + panic!( + "expected valid_card to be an And{{spell filter, trigger-source \ + placeholder}}, got {:?}", + replacement.valid_card + ); + }; + assert!( + filters.iter().any(|f| matches!( + f, + TargetFilter::SpecificObject { id } + if *id == crate::types::identifiers::TRIGGERING_SPELL_PLACEHOLDER + )), + "valid_card must carry the trigger-source placeholder, got {filters:?}" + ); + assert_eq!(replacement.event, ReplacementEvent::ChangeZone); + assert_eq!(replacement.destination_zone, Some(Zone::Battlefield)); + let put_counter = replacement.execute.as_ref().expect("execute set"); + let Effect::PutCounter { + counter_type, + count, + target: put_target, + } = &*put_counter.effect + else { + panic!("expected PutCounter, got {:?}", put_counter.effect); + }; + assert_eq!(counter_type, &CounterType::Plus1Plus1); + assert_eq!(put_target, &TargetFilter::SelfRef); + assert_eq!( + count, + &QuantityExpr::Offset { + inner: Box::new(QuantityExpr::Ref { + qty: QuantityRef::ObjectManaValue { + scope: crate::types::ability::ObjectScope::Recipient, + }, + }), + offset: -4, + }, + "count must be the entering creature's own mana value minus 4, not a \ + garbage literal or Runadi's own mana value" + ); + } + + /// Regression: an unparseable composite quantity in the "where X is" clause + /// must still fail closed (return `None`) rather than silently absorbing + /// the condition text as a garbage counter-type literal — the exact + /// failure mode issue #6492 reported before the fix. Checked against both + /// the internal payload builder and the public trigger recognizer. + #[test] + fn whenever_you_cast_enters_with_garbage_quantity_fails_closed() { + let text = "Whenever you cast a creature spell with mana value 5 or greater, that creature enters with X additional +1/+1 counters on it, where X is its unrecognized nonsense value minus 4."; + assert!( + parse_whenever_you_cast_enters_with(&text.to_lowercase(), text).is_none(), + "an unparseable quantity clause must fail this combinator closed, not \ + succeed with a wrong AST" + ); + assert!( + parse_whenever_you_cast_enters_with_trigger(text, "Filler").is_none(), + "the trigger recognizer must also fail closed when its payload builder does" + ); } /// Regression: "Whenever you cast" with a fixed additional counter amount @@ -19676,9 +19904,14 @@ mod tests { #[test] fn parses_fixed_count_variant() { let text = "Whenever you cast a creature spell, that creature enters with an additional +1/+1 counter on it."; - let def = parse_replacement_line(text, "Filler").expect("should parse"); - let exec = def.execute.as_ref().expect("execute set"); - let Effect::PutCounter { count, .. } = &*exec.effect else { + let trigger = + parse_whenever_you_cast_enters_with_trigger(text, "Filler").expect("should parse"); + let exec = trigger.execute.as_ref().expect("execute set"); + let Effect::AddTargetReplacement { replacement, .. } = &*exec.effect else { + panic!("expected AddTargetReplacement, got {:?}", exec.effect); + }; + let put_counter = replacement.execute.as_ref().expect("execute set"); + let Effect::PutCounter { count, .. } = &*put_counter.effect else { panic!("expected PutCounter"); }; assert_eq!(count, &QuantityExpr::Fixed { value: 1 }); diff --git a/crates/engine/src/parser/oracle_target.rs b/crates/engine/src/parser/oracle_target.rs index 2d404c106b..80e17261a1 100644 --- a/crates/engine/src/parser/oracle_target.rs +++ b/crates/engine/src/parser/oracle_target.rs @@ -5532,6 +5532,18 @@ fn parse_counter_spec_after_lead( |input| { let (input, expr) = nom_quantity::parse_quantity_expr_number(input)?; let (input, _) = tag_e::<_, _, OracleError<'_>>(" ").parse(input)?; + // CR 122.1: "with N or more/or greater counters" — redundant + // with the already-GE `with` lead (mirrors the CMC "N or greater" + // handling above `parse_counter_suffix`'s call site), but the + // qualifier must still be consumed here or it leaks into the + // counter-type slice below (issue #6492: "or more +1/+1" parsed as + // a garbage counter type on Runadi, Behemoth Caller's haste static). + let input = alt(( + tag_e::<_, _, OracleError<'_>>("or more "), + tag_e("or greater "), + )) + .parse(input) + .map_or(input, |(rest, _)| rest); Ok((input, expr)) }, )); @@ -11796,6 +11808,46 @@ mod tests { )); } + /// CR 122.1 + CR 613.4c: issue #6492 — Runadi, Behemoth Caller's haste + /// static ("Creatures you control with three or more +1/+1 counters on + /// them have haste.") requires "three or more" to consume cleanly instead + /// of leaking "or more" into the counter-type slice (`Generic("or more + /// +1/+1")` pre-fix — no creature ever matched the filter, so haste never + /// applied). "with N counters" is already GE per the `with` lead, so "or + /// more"/"or greater" is a redundant qualifier that must be consumed, not + /// carried into the counter type. + #[test] + fn parse_counter_suffix_three_or_more_plus1plus1() { + let result = parse_counter_suffix(" with three or more +1/+1 counters on them"); + assert!(result.is_some()); + let (prop, _consumed) = result.unwrap(); + assert!(matches!( + prop, + FilterProp::Counters { + counters: CounterMatch::OfType(CounterType::Plus1Plus1), + comparator: Comparator::GE, + count: QuantityExpr::Fixed { value: 3 }, + } + )); + } + + /// Sibling coverage: "or greater" (not just "or more") must also be + /// stripped cleanly. + #[test] + fn parse_counter_suffix_two_or_greater_stun() { + let result = parse_counter_suffix(" with two or greater stun counters on it"); + assert!(result.is_some()); + let (prop, _consumed) = result.unwrap(); + assert!(matches!( + prop, + FilterProp::Counters { + counters: CounterMatch::OfType(CounterType::Stun), + comparator: Comparator::GE, + count: QuantityExpr::Fixed { value: 2 }, + } + )); + } + #[test] fn parse_counter_suffix_not_counter_phrase() { let result = parse_counter_suffix(" with power 3 or greater"); diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 3a8322bce2..2c8995a3b6 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -5294,9 +5294,12 @@ pub enum ObjectScope { Target, /// CR 613.4c + CR 115.10: The object currently receiving an effect. /// In layer evaluation this is the per-object recipient. Outside layers, - /// it resolves to the first object target when present, then to the source. + /// it resolves to the first object target when present, then to the + /// entering object of an ETB-scoped replacement, then to the source. /// Used for recipient-relative "its colors" boosts such as Blessing of - /// the Nephilim and Civic Saber. + /// the Nephilim and Civic Saber, and for "its mana value"/"its power" + /// quantities inside "that creature enters with ... counters" replacement + /// effects (Runadi, Behemoth Caller). Recipient, /// CR 603.2: The object referenced by the current trigger event. EventSource, diff --git a/crates/engine/src/types/identifiers.rs b/crates/engine/src/types/identifiers.rs index d593728738..abc8bade2f 100644 --- a/crates/engine/src/types/identifiers.rs +++ b/crates/engine/src/types/identifiers.rs @@ -11,6 +11,21 @@ pub struct CardId(pub u64); #[serde(transparent)] pub struct ObjectId(pub u64); +/// CR 603.2 + CR 603.3b + CR 117.3b: parse-time placeholder for "the specific +/// spell object that will cause this trigger to fire", embedded inside a +/// `TargetFilter::SpecificObject` leaf of a floating (`TargetFilter::None`) +/// replacement's `valid_card` tree by `parse_whenever_you_cast_enters_with_trigger`. +/// `Effect::AddTargetReplacement`'s resolve function (`add_target_replacement.rs`) +/// concretizes this to the real triggering spell's id (from +/// `state.current_trigger_event`) — or to `ObjectId(0)` (matches nothing) if +/// none is extractable — before the install is pushed. Never a real object's +/// id (the allocator starts well below `u64::MAX`), so this is safe to use as +/// a sentinel without a dedicated `TargetFilter`/`ReplacementDefinition` +/// variant, which would ripple through every exhaustive match on those types +/// across the workspace (including the dormant `mtgish-import` crate, which +/// must remain untouched). +pub(crate) const TRIGGERING_SPELL_PLACEHOLDER: ObjectId = ObjectId(u64::MAX); + /// Monotonic identity for one logical simultaneous zone-change action. /// /// This remains distinct from an [`ObjectId`]: a logical group can contain diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 7e430108e7..46216a29fa 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -814,6 +814,7 @@ mod roots_of_wisdom_if_you_cant_draw; mod roughshod_mentor_green_trample_grant; mod rules; mod run_for_your_life_escape; +mod runadi_behemoth_caller_etb_counters; mod saddle_become_effect; mod saddle_state_model; mod saruman_white_hand_amass; diff --git a/crates/engine/tests/integration/runadi_behemoth_caller_etb_counters.rs b/crates/engine/tests/integration/runadi_behemoth_caller_etb_counters.rs new file mode 100644 index 0000000000..053b1a991a --- /dev/null +++ b/crates/engine/tests/integration/runadi_behemoth_caller_etb_counters.rs @@ -0,0 +1,378 @@ +//! Runadi, Behemoth Caller — RUNTIME witness for the ETB-counter replacement +//! and its downstream haste consequence (issue #6492). +//! +//! Oracle (verified via Scryfall, card j22/44): +//! "Whenever you cast a creature spell with mana value 5 or greater, that +//! creature enters with X additional +1/+1 counters on it, where X is its +//! mana value minus 4." +//! "Creatures you control with three or more +1/+1 counters on them have +//! haste." +//! "{T}: Add {G}." +//! +//! Pre-fix, the composite "its mana value minus 4" clause failed to parse +//! (the combinator only supported atomic quantity refs), misrouting the +//! ability to the self-ETB fallback with `valid_card: SelfRef` — Runadi would +//! try to put counters on HERSELF, not the cast creature, and the cast +//! creature would enter with 0 counters regardless of its mana value. +//! +//! CR 603.1 + CR 603.3: "Whenever you cast ..." is a TRIGGERED ability — it +//! goes on the stack and resolves independently of Runadi's continued +//! presence. The first ability is modeled as a `SpellCast` trigger whose +//! resolution installs a floating (not object-hosted), one-shot `ChangeZone` +//! replacement, so the entering-with-counters effect survives Runadi leaving +//! the battlefield between the trigger resolving and the cast creature +//! resolving (maintainer review on issue #6492 / PR #6735). +//! +//! CR references (verified against docs/MagicCompRules.txt): +//! - CR 614.1c: "[this permanent] enters with ..." is a replacement effect. +//! - CR 202.3: mana value. +//! - CR 122.1a: a +1/+1 counter adds 1 to power and 1 to toughness. +//! - CR 603.3b: a resolving triggered ability functions independently of +//! its source once it exists on the stack. +//! +//! Discrimination: a mana-value-8 creature must enter with 4 counters (8-4) +//! and gain Haste from the second ability's 3-or-more threshold; a +//! mana-value-4 creature must enter with 0 counters (filter excludes it) and +//! no Haste; a mana-value-8 creature must still enter with 4 counters even +//! when Runadi leaves the battlefield after the qualifying spell is cast but +//! before it resolves. + +use engine::game::layers::evaluate_layers; +use engine::game::scenario::{GameRunner, GameScenario, P0}; +use engine::game::triggers::drain_order_triggers_with_identity; +use engine::types::actions::GameAction; +use engine::types::counter::CounterType; +use engine::types::game_state::{CastPaymentMode, WaitingFor}; +use engine::types::keywords::Keyword; +use engine::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::triggers::TriggerMode; +use engine::types::zones::Zone; +use engine::types::ObjectId; + +const RUNADI: &str = "Whenever you cast a creature spell with mana value 5 or greater, that creature enters with X additional +1/+1 counters on it, where X is its mana value minus 4.\nCreatures you control with three or more +1/+1 counters on them have haste.\n{T}: Add {G}."; + +/// Cast a green creature of the given mana value (shards: GG, generic = mv - +/// 2) while Runadi is on P0's battlefield. Returns `(counters on the +/// entrant, entrant has Haste)`. +fn cast_creature_with_runadi(name: &str, mana_value: u32) -> (u32, bool) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let runadi = scenario + .add_creature_from_oracle(P0, "Runadi, Behemoth Caller", 1, 3, RUNADI) + .id(); + + let generic = mana_value.saturating_sub(2); + let spell = scenario + .add_creature_to_hand_from_oracle(P0, name, 1, 1, "") + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Green, ManaCostShard::Green], + generic, + }) + .id(); + + scenario.with_mana_pool( + P0, + (0..generic) + .map(|_| ManaUnit::new(ManaType::Colorless, ObjectId(0), false, Vec::new())) + .chain((0..2).map(|_| ManaUnit::new(ManaType::Green, ObjectId(0), false, Vec::new()))) + .collect(), + ); + + let mut runner = scenario.build(); + + // Positive reach guard (CodeRabbit review): prove Runadi's first ability + // actually registered as a real `SpellCast` trigger on the permanent + // BEFORE casting anything, so a (0, false) result below is attributable to + // the mana-value filter rejecting the spell, not to the trigger having + // silently failed to parse/attach for every mana value. + assert!( + runner + .state() + .objects + .get(&runadi) + .expect("Runadi must be on the battlefield") + .trigger_definitions + .as_slice() + .iter() + .any(|entry| entry.definition.mode == TriggerMode::SpellCast), + "Runadi's first ability must register as a SpellCast trigger — a missing \ + registration would make every mana value silently give (0, false)" + ); + + let outcome = runner.cast(spell).resolve(); + + let entered = outcome + .find_object(|o| o.name == name && o.zone == Zone::Battlefield) + .expect("cast creature must have entered the battlefield"); + + let counters = outcome.counters(entered, CounterType::Plus1Plus1); + + // Force a full layers re-evaluation to read the haste static's current + // grant — mirrors the established convention (see + // `frostcliff_siege_anchor_word_modes.rs`) since not every action reliably + // bumps `layers_dirty` on its own; this test cares about the counters + // (the actual bug) driving the static's condition, not about dirty-bit + // plumbing. + let state = runner.state_mut(); + state.layers_dirty.mark_full(); + evaluate_layers(state); + let has_haste = runner + .state() + .objects + .get(&entered) + .is_some_and(|obj| obj.keywords.contains(&Keyword::Haste)); + (counters, has_haste) +} + +#[test] +fn runadi_grants_mv_minus_4_counters_and_downstream_haste_at_mv8() { + // MV 8: X = 8 - 4 = 4 counters, crossing the "three or more" haste + // threshold on the SAME creature. + assert_eq!( + cast_creature_with_runadi("Test Behemoth", 8), + (4, true), + "an MV8 creature must enter with 4 counters and gain haste from the \ + 3-or-more threshold; (0, false) means the ETB-counter replacement \ + never fired (issue #6492 regression)" + ); +} + +#[test] +fn runadi_grants_exactly_one_counter_at_mv5_threshold() { + // MV 5: X = 5 - 4 = 1 counter — below the haste threshold. + assert_eq!( + cast_creature_with_runadi("Test Whelp", 5), + (1, false), + "an MV5 creature (the exact threshold) must enter with exactly 1 \ + counter and not yet have haste" + ); +} + +#[test] +fn runadi_grants_no_counters_below_mv5_threshold() { + // MV 4: below the "mana value 5 or greater" filter — the replacement + // must not apply at all (proves the Cmc filter still gates correctly and + // the fix didn't turn this into an unconditional counter grant, and + // doubles as "no qualifying spell → no floating replacement installed" + // coverage: the trigger never fires, so nothing is affected). + assert_eq!( + cast_creature_with_runadi("Test Sprite", 4), + (0, false), + "an MV4 creature must NOT receive any counters (mana value filter \ + excludes it) and must not have haste" + ); +} + +/// CR 603.1 + CR 603.3b + CR 614.1c/614.12: Runadi's ability is a TRIGGERED +/// ability — once her trigger exists on the stack (queued the moment the +/// qualifying spell is cast), it resolves independently of whether Runadi +/// herself is still around. Removing her from the battlefield after the cast +/// commits (her trigger has been created and stacked above the spell) but +/// before the whole stack resolves must NOT prevent the entering creature +/// from getting its counters — the floating replacement the trigger installs +/// is source-independent, unlike an object-hosted static replacement, which +/// would have vanished with Runadi (the pre-review design this test guards +/// against regressing to). +#[test] +fn runadi_leaving_before_spell_resolves_still_grants_counters() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let runadi = scenario + .add_creature_from_oracle(P0, "Runadi, Behemoth Caller", 1, 3, RUNADI) + .id(); + + let mana_value = 8u32; + let generic = mana_value.saturating_sub(2); + let spell = scenario + .add_creature_to_hand_from_oracle(P0, "Test Behemoth", 1, 1, "") + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Green, ManaCostShard::Green], + generic, + }) + .id(); + scenario.with_mana_pool( + P0, + (0..generic) + .map(|_| ManaUnit::new(ManaType::Colorless, ObjectId(0), false, Vec::new())) + .chain((0..2).map(|_| ManaUnit::new(ManaType::Green, ObjectId(0), false, Vec::new()))) + .collect(), + ); + + let mut runner = scenario.build(); + let mut commit = runner.cast(spell).commit(); + // Runadi leaves the battlefield WHILE the spell — and her own trigger, + // already queued by the cast — are still unresolved on the stack. Drive + // this through the production zone-change pipeline (`game::zones::move_to_zone`) + // rather than poking `GameObject.zone` directly, so the departure is a real + // CR 400.7 zone change, not a test-only shortcut. + engine::game::zones::move_to_zone(commit.state_mut(), runadi, Zone::Graveyard, &mut Vec::new()); + assert_eq!( + commit.state().objects.get(&runadi).map(|o| o.zone), + Some(Zone::Graveyard), + "Runadi must actually be in the graveyard before the stack resolves" + ); + let outcome = commit.resolve(); + + let entered = outcome + .find_object(|o| o.name == "Test Behemoth" && o.zone == Zone::Battlefield) + .expect("cast creature must have entered the battlefield"); + assert_eq!( + outcome.counters(entered, CounterType::Plus1Plus1), + 4, + "the entering creature must still get its mana-value-minus-4 counters \ + even though Runadi left the battlefield before the spell resolved — \ + the floating replacement her trigger installs must not depend on her \ + continued presence (issue #6492 maintainer review)" + ); +} + +fn cast_spell(runner: &mut GameRunner, spell: ObjectId) { + let card_id = runner + .state() + .objects + .get(&spell) + .expect("spell object exists") + .card_id; + runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("cast must be accepted"); +} + +/// Pass priority (draining any trigger-ordering prompt) until the stack has +/// exactly `len` items. Runadi's trigger has no target to select, so the only +/// prompts this scenario can surface are `Priority` and `OrderTriggers`. +fn pass_priority_until_stack_len(runner: &mut GameRunner, len: usize) { + for _ in 0..64 { + if runner.state().stack.len() == len { + return; + } + match runner.state().waiting_for.clone() { + WaitingFor::Priority { .. } => { + runner + .act(GameAction::PassPriority) + .expect("pass priority must be accepted"); + } + WaitingFor::OrderTriggers { .. } => { + drain_order_triggers_with_identity(runner.state_mut()); + } + other => panic!("unexpected WaitingFor while pumping to stack length {len}: {other:?}"), + } + } + panic!( + "stack never reached length {len} (currently {})", + runner.state().stack.len() + ); +} + +/// CR 603.2 + CR 117.3b + CR 614.1c/614.12: The floating replacement Runadi's +/// trigger installs must be bound to the SPECIFIC spell that caused it, not +/// just any spell matching the "creature, mana value >= 5" filter — reviewer +/// finding on PR #6735. Cast a first qualifying creature (A), let its trigger +/// resolve (installing a floating replacement bound to A), then cast a SECOND +/// qualifying creature (B) in response — during the CR 117.3b priority window +/// before A resolves — and let everything resolve. B's own trigger installs a +/// second floating replacement bound to B; B resolves first (LIFO) and must +/// get exactly its own counters, NOT steal/consume the replacement meant for +/// A. A must then still resolve with its own correct counters. +/// +/// Revert-to-red: a bare filter-scoped one-shot install (no +/// `bind_to_trigger_source`) lets B's earlier battlefield entry consume A's +/// still-pending floating replacement (insertion-order-first in +/// `pending_damage_replacements`), leaving A uncountered when it resolves. +#[test] +fn runadi_binds_floating_replacement_to_the_specific_triggering_spell() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.add_creature_from_oracle(P0, "Runadi, Behemoth Caller", 1, 3, RUNADI); + + // Both A and B are MV8 (X = 4 counters each) so a correct result is + // symmetric and unambiguous: (4, 4), never (0, 8) or (8, 0) from a stolen + // replacement. + let generic = 8u32.saturating_sub(2); + // CR 117.1a: creature spells are normally sorcery-speed only. Flash lets B + // be cast in response, on the stack above A — the exact CR 117.3b window + // the maintainer's review calls out. + let make_spell = |scenario: &mut GameScenario, name: &str| { + scenario + .add_creature_to_hand_from_oracle(P0, name, 1, 1, "Flash") + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Green, ManaCostShard::Green], + generic, + }) + .id() + }; + let spell_a = make_spell(&mut scenario, "Test Behemoth A"); + let spell_b = make_spell(&mut scenario, "Test Behemoth B"); + + // Ample combined pool for both GG+6-generic casts. + scenario.with_mana_pool( + P0, + (0..generic * 2) + .map(|_| ManaUnit::new(ManaType::Colorless, ObjectId(0), false, Vec::new())) + .chain((0..4).map(|_| ManaUnit::new(ManaType::Green, ObjectId(0), false, Vec::new()))) + .collect(), + ); + + let mut runner = scenario.build(); + + cast_spell(&mut runner, spell_a); + // Stack: [A, triggerA]. Let triggerA resolve (installs the floating + // replacement bound to A), leaving just A on the stack. + pass_priority_until_stack_len(&mut runner, 1); + + // Cast B IN RESPONSE, while A is still on the stack unresolved. + cast_spell(&mut runner, spell_b); + // Stack: [A, B, triggerB]. Let triggerB resolve (installs the floating + // replacement bound to B), leaving [A, B]. + pass_priority_until_stack_len(&mut runner, 2); + + // Resolve the rest of the stack: B resolves first (LIFO), then A. + pass_priority_until_stack_len(&mut runner, 0); + + let state = runner.state(); + let entered_a = state + .objects + .values() + .find(|o| o.name == "Test Behemoth A" && o.zone == Zone::Battlefield) + .map(|o| o.id) + .expect("Test Behemoth A must have entered the battlefield"); + let entered_b = state + .objects + .values() + .find(|o| o.name == "Test Behemoth B" && o.zone == Zone::Battlefield) + .map(|o| o.id) + .expect("Test Behemoth B must have entered the battlefield"); + + let counters_a = state + .objects + .get(&entered_a) + .unwrap() + .counters + .get(&CounterType::Plus1Plus1) + .copied() + .unwrap_or(0); + let counters_b = state + .objects + .get(&entered_b) + .unwrap() + .counters + .get(&CounterType::Plus1Plus1) + .copied() + .unwrap_or(0); + + assert_eq!( + (counters_a, counters_b), + (4, 4), + "each entrant must get its OWN 4 counters; anything else means the \ + floating replacement was stolen by (or applied to) the wrong spell — \ + (0, 8) or (8, 0) means B's earlier entry consumed the replacement \ + meant for A" + ); +} From 28654de9b6736b5822c13b71ef5010db91e81291 Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:55:01 -0700 Subject: [PATCH 03/63] chore: refresh metagame feeds (#6783) --- .../public/feeds/mtggoldfish-commander.json | 1602 ++++++++--------- client/public/feeds/mtggoldfish-modern.json | 566 +++--- client/public/feeds/mtggoldfish-pioneer.json | 907 +++++----- client/public/feeds/mtggoldfish-standard.json | 308 ++-- 4 files changed, 1720 insertions(+), 1663 deletions(-) diff --git a/client/public/feeds/mtggoldfish-commander.json b/client/public/feeds/mtggoldfish-commander.json index 005a2c4f06..19d539cbb7 100644 --- a/client/public/feeds/mtggoldfish-commander.json +++ b/client/public/feeds/mtggoldfish-commander.json @@ -5,14 +5,14 @@ "icon": "G", "format": "commander", "version": 1, - "updated": "2026-07-28T00:00:00Z", + "updated": "2026-07-29T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/commander", "decks": [ { - "name": "Doctor Doom, King of Latveria", + "name": "Codsworth, Handy Helper", "author": "MTGGoldfish", "colors": [ - "U" + "W" ], "tags": [ "metagame" @@ -20,656 +20,656 @@ "main": [ { "count": 1, - "name": "Abomination, World Ravager" + "name": "Ajani, Caller of the Pride" }, { "count": 1, - "name": "Arcane Signet" + "name": "Angel of Destiny" }, { "count": 1, - "name": "Archfiend of Ifnir" + "name": "Sol Ring" }, { "count": 1, - "name": "Archnemesis" + "name": "Wrath of God" }, { "count": 1, - "name": "Baron Strucker, HYDRA Overlord" + "name": "Avenge" }, { "count": 1, - "name": "Bedevil" + "name": "Battle Angels of Tyr" }, { "count": 1, - "name": "Black Market Connections" + "name": "Beza, the Bounding Spring" }, { "count": 1, - "name": "Blasphemous Act" + "name": "Brought Back" }, { "count": 1, - "name": "Chameleon, Master of Disguise" + "name": "Cloud, Midgar Mercenary" }, { "count": 1, - "name": "Chaos Warp" + "name": "Continue?" }, { "count": 1, - "name": "Containment Construct" + "name": "Day of Judgment" }, { "count": 1, - "name": "Cool but Rude" + "name": "Dismantling Wave" }, { "count": 1, - "name": "Counterspell" + "name": "Aettir and Priwen" }, { "count": 1, - "name": "Currency Converter" + "name": "Buster Sword" }, { "count": 1, - "name": "Dark Ritual" + "name": "Caduceus, Staff of Hermes" }, { "count": 1, - "name": "Doctor Octopus, Master Planner" + "name": "Smothering Tithe" }, { "count": 1, - "name": "Doom Reigns Supreme" + "name": "Extraplanar Lens" }, { "count": 1, - "name": "Doom's Time Platform" + "name": "Fishing Gear" }, { "count": 1, - "name": "Endless Ranks of HYDRA" + "name": "Invisible Force Field" }, { "count": 1, - "name": "Extract Power" + "name": "Land Tax" }, { "count": 1, - "name": "Frantic Search" + "name": "Luminarch Ascension" }, { "count": 1, - "name": "Glorious Purpose" + "name": "Luminous Broodmoth" }, { "count": 1, - "name": "Iron Monger, Sadistic Tycoon" + "name": "Master of Ceremonies" }, { "count": 1, - "name": "Kang, Temporal Tyrant" + "name": "No Witnesses" }, { "count": 1, - "name": "Killmonger, Ruthless Usurper" + "name": "Oltec Matterweaver" }, { "count": 1, - "name": "Kindred Dominance" + "name": "Promise of Tomorrow" }, { "count": 1, - "name": "Leader, Super-Genius" + "name": "Protection Magic" }, { "count": 1, - "name": "Lethal Scheme" + "name": "Puresteel Paladin" }, { "count": 1, - "name": "Living Laser" + "name": "Scourglass" }, { "count": 1, - "name": "Loki's Scepter" + "name": "Selfless Spirit" }, { "count": 1, - "name": "Loki, the Deceiver" + "name": "Serra Ascendant" }, { "count": 1, - "name": "M.O.D.O.K." + "name": "Shielded by Faith" }, { "count": 1, - "name": "Madame Hydra" + "name": "Smuggler's Share" }, { "count": 1, - "name": "Molecule Man" + "name": "Sokka, Swordmaster" }, { "count": 1, - "name": "Moonstone, Harsh Mistress" + "name": "Spectacular Spider-Man" }, { "count": 1, - "name": "Night's Whisper" + "name": "Sram, Senior Edificer" }, { "count": 1, - "name": "Norman Osborn" + "name": "Stalwart Pathlighter" }, { "count": 1, - "name": "Propaganda" + "name": "Steelshaper's Gift" }, { "count": 1, - "name": "Prowler, Clawed Thief" + "name": "Stoneforge Mystic" }, { "count": 1, - "name": "Puppet Master, String Puller" + "name": "Vanquish the Horde" }, { "count": 1, - "name": "Red Ghost, Intangible Genius" + "name": "Swordsman's Steel" }, { "count": 1, - "name": "Sol Ring" + "name": "Emeria, the Sky Ruin" }, { "count": 1, - "name": "Spark Double" + "name": "Gift of Estates" }, { - "count": 1, - "name": "Swiftfoot Boots" + "count": 29, + "name": "Plains" }, { "count": 1, - "name": "Talisman of Dominance" + "name": "Soul Warden" }, { "count": 1, - "name": "Talisman of Indulgence" + "name": "Nazgul Battle-Mace" }, { "count": 1, - "name": "Terminate" + "name": "Lithoform Engine" }, { "count": 1, - "name": "The Frightful Four" + "name": "Wrecking Ball Arm" }, { "count": 1, - "name": "The Squadron Sinister" + "name": "Thran Dynamo" }, { "count": 1, - "name": "Titan of Littjara" + "name": "Haliya, Guided by Light" }, { "count": 1, - "name": "Tombstone, Career Criminal" + "name": "Esper Sentinel" }, { "count": 1, - "name": "Toxic Deluge" + "name": "Ultron, Artificial Malevolence" }, { "count": 1, - "name": "Typhoid Mary, Fractured" + "name": "Codsworth, Handy Helper" }, { "count": 1, - "name": "Ultron, Unlimited" + "name": "Heliod, Sun-Crowned" }, { "count": 1, - "name": "Vandalblast" + "name": "Walking Ballista" }, { "count": 1, - "name": "Villainous Hideout" + "name": "Guilty Conscience" }, { "count": 1, - "name": "Withering Torment" + "name": "Phyrexian Vindicator" }, { "count": 1, - "name": "Patchwork Banner" + "name": "Avacyn, Angel of Hope" }, { "count": 1, - "name": "Green Goblin, Revenant" + "name": "Worldslayer" }, { "count": 1, - "name": "Big Score" + "name": "Alhammarret's Archive" }, { "count": 1, - "name": "Reanimate" + "name": "Ancient Tomb" }, { "count": 1, - "name": "Monument to Endurance" + "name": "Enduring Angel" }, { "count": 1, - "name": "Kang Dynasty" + "name": "Enduring Innocence" }, { "count": 1, - "name": "Age of Ultron" + "name": "Gemstone Caverns" }, { "count": 1, - "name": "Canyon Slough" + "name": "Mishra's Workshop" }, { "count": 1, - "name": "Choked Estuary" + "name": "Anvil of Bogardan" }, { "count": 1, - "name": "Coastal Peak" + "name": "Howling Mine" }, { "count": 1, - "name": "Command Tower" + "name": "Aura Blast" }, { "count": 1, - "name": "Crumbling Necropolis" + "name": "Cut a Deal" }, { "count": 1, - "name": "Dragonskull Summit" + "name": "Secret Rendezvous" }, { "count": 1, - "name": "Drowned Catacomb" + "name": "Dawn of a New Age" }, { "count": 1, - "name": "Exotic Orchard" - }, + "name": "Infiltration Lens" + } + ], + "sideboard": [] + }, + { + "name": "Doctor Doom, King of Latveria", + "author": "MTGGoldfish", + "colors": [ + "U" + ], + "tags": [ + "metagame" + ], + "main": [ { "count": 1, - "name": "Fetid Pools" + "name": "Abomination, World Ravager" }, { "count": 1, - "name": "Foreboding Ruins" + "name": "Arcane Signet" }, { "count": 1, - "name": "Frostboil Snarl" + "name": "Archfiend of Ifnir" }, { "count": 1, - "name": "Luxury Suite" + "name": "Archnemesis" }, { "count": 1, - "name": "Oscorp Industries" + "name": "Baron Strucker, HYDRA Overlord" }, { "count": 1, - "name": "Path of Ancestry" + "name": "Bedevil" }, { "count": 1, - "name": "Scavenger Grounds" + "name": "Black Market Connections" }, { "count": 1, - "name": "Scorched Geyser" + "name": "Blasphemous Act" }, { "count": 1, - "name": "Secluded Courtyard" + "name": "Chameleon, Master of Disguise" }, { "count": 1, - "name": "Smoldering Marsh" + "name": "Chaos Warp" }, { "count": 1, - "name": "Sulfur Falls" + "name": "Containment Construct" }, { "count": 1, - "name": "Sunken Hollow" + "name": "Cool but Rude" }, { "count": 1, - "name": "Terramorphic Expanse" + "name": "Counterspell" }, { "count": 1, - "name": "Unclaimed Territory" + "name": "Currency Converter" }, { - "count": 13, - "name": "Island" + "count": 1, + "name": "Dark Ritual" }, { "count": 1, - "name": "Doctor Doom, King of Latveria" - } - ], - "sideboard": [] - }, - { - "name": "Codsworth, Handy Helper", - "author": "MTGGoldfish", - "colors": [ - "W" - ], - "tags": [ - "metagame" - ], - "main": [ + "name": "Doctor Octopus, Master Planner" + }, { "count": 1, - "name": "Ajani, Caller of the Pride" + "name": "Doom Reigns Supreme" }, { "count": 1, - "name": "Angel of Destiny" + "name": "Doom's Time Platform" }, { "count": 1, - "name": "Sol Ring" + "name": "Endless Ranks of HYDRA" }, { "count": 1, - "name": "Wrath of God" + "name": "Extract Power" }, { "count": 1, - "name": "Avenge" + "name": "Frantic Search" }, { "count": 1, - "name": "Battle Angels of Tyr" + "name": "Glorious Purpose" }, { "count": 1, - "name": "Beza, the Bounding Spring" + "name": "Iron Monger, Sadistic Tycoon" }, { "count": 1, - "name": "Brought Back" + "name": "Kang, Temporal Tyrant" }, { "count": 1, - "name": "Cloud, Midgar Mercenary" + "name": "Killmonger, Ruthless Usurper" }, { "count": 1, - "name": "Continue?" + "name": "Kindred Dominance" }, { "count": 1, - "name": "Day of Judgment" + "name": "Leader, Super-Genius" }, { "count": 1, - "name": "Dismantling Wave" + "name": "Lethal Scheme" }, { "count": 1, - "name": "Aettir and Priwen" + "name": "Living Laser" }, { "count": 1, - "name": "Buster Sword" + "name": "Loki's Scepter" }, { "count": 1, - "name": "Caduceus, Staff of Hermes" + "name": "Loki, the Deceiver" }, { "count": 1, - "name": "Smothering Tithe" + "name": "M.O.D.O.K." }, { "count": 1, - "name": "Extraplanar Lens" + "name": "Madame Hydra" }, { "count": 1, - "name": "Fishing Gear" + "name": "Molecule Man" }, { "count": 1, - "name": "Invisible Force Field" + "name": "Moonstone, Harsh Mistress" }, { "count": 1, - "name": "Land Tax" + "name": "Night's Whisper" }, { "count": 1, - "name": "Luminarch Ascension" + "name": "Norman Osborn" }, { "count": 1, - "name": "Luminous Broodmoth" + "name": "Propaganda" }, { "count": 1, - "name": "Master of Ceremonies" + "name": "Prowler, Clawed Thief" }, { "count": 1, - "name": "No Witnesses" + "name": "Puppet Master, String Puller" }, { "count": 1, - "name": "Oltec Matterweaver" + "name": "Red Ghost, Intangible Genius" }, { "count": 1, - "name": "Promise of Tomorrow" + "name": "Sol Ring" }, { "count": 1, - "name": "Protection Magic" + "name": "Spark Double" }, { "count": 1, - "name": "Puresteel Paladin" + "name": "Swiftfoot Boots" }, { "count": 1, - "name": "Scourglass" + "name": "Talisman of Dominance" }, { "count": 1, - "name": "Selfless Spirit" + "name": "Talisman of Indulgence" }, { "count": 1, - "name": "Serra Ascendant" + "name": "Terminate" }, { "count": 1, - "name": "Shielded by Faith" + "name": "The Frightful Four" }, { "count": 1, - "name": "Smuggler's Share" + "name": "The Squadron Sinister" }, { "count": 1, - "name": "Sokka, Swordmaster" + "name": "Titan of Littjara" }, { "count": 1, - "name": "Spectacular Spider-Man" + "name": "Tombstone, Career Criminal" }, { "count": 1, - "name": "Sram, Senior Edificer" + "name": "Toxic Deluge" }, { "count": 1, - "name": "Stalwart Pathlighter" + "name": "Typhoid Mary, Fractured" }, { "count": 1, - "name": "Steelshaper's Gift" + "name": "Ultron, Unlimited" }, { "count": 1, - "name": "Stoneforge Mystic" + "name": "Vandalblast" }, { "count": 1, - "name": "Vanquish the Horde" + "name": "Villainous Hideout" }, { "count": 1, - "name": "Swordsman's Steel" + "name": "Withering Torment" }, { "count": 1, - "name": "Emeria, the Sky Ruin" + "name": "Patchwork Banner" }, { "count": 1, - "name": "Gift of Estates" + "name": "Green Goblin, Revenant" }, { - "count": 29, - "name": "Plains" + "count": 1, + "name": "Big Score" }, { "count": 1, - "name": "Soul Warden" + "name": "Reanimate" }, { "count": 1, - "name": "Nazgul Battle-Mace" + "name": "Monument to Endurance" }, { "count": 1, - "name": "Lithoform Engine" + "name": "Kang Dynasty" }, { "count": 1, - "name": "Wrecking Ball Arm" + "name": "Age of Ultron" }, { "count": 1, - "name": "Thran Dynamo" + "name": "Canyon Slough" }, { "count": 1, - "name": "Haliya, Guided by Light" + "name": "Choked Estuary" }, { "count": 1, - "name": "Esper Sentinel" + "name": "Coastal Peak" }, { "count": 1, - "name": "Ultron, Artificial Malevolence" + "name": "Command Tower" }, { "count": 1, - "name": "Codsworth, Handy Helper" + "name": "Crumbling Necropolis" }, { "count": 1, - "name": "Heliod, Sun-Crowned" + "name": "Dragonskull Summit" }, { "count": 1, - "name": "Walking Ballista" + "name": "Drowned Catacomb" }, { "count": 1, - "name": "Guilty Conscience" + "name": "Exotic Orchard" }, { "count": 1, - "name": "Phyrexian Vindicator" + "name": "Fetid Pools" }, { "count": 1, - "name": "Avacyn, Angel of Hope" + "name": "Foreboding Ruins" }, { "count": 1, - "name": "Worldslayer" + "name": "Frostboil Snarl" }, { "count": 1, - "name": "Alhammarret's Archive" + "name": "Luxury Suite" }, { "count": 1, - "name": "Ancient Tomb" + "name": "Oscorp Industries" }, { "count": 1, - "name": "Enduring Angel" + "name": "Path of Ancestry" }, { "count": 1, - "name": "Enduring Innocence" + "name": "Scavenger Grounds" }, { "count": 1, - "name": "Gemstone Caverns" + "name": "Scorched Geyser" }, { "count": 1, - "name": "Mishra's Workshop" + "name": "Secluded Courtyard" }, { "count": 1, - "name": "Anvil of Bogardan" + "name": "Smoldering Marsh" }, { "count": 1, - "name": "Howling Mine" + "name": "Sulfur Falls" }, { "count": 1, - "name": "Aura Blast" + "name": "Sunken Hollow" }, { "count": 1, - "name": "Cut a Deal" + "name": "Terramorphic Expanse" }, { "count": 1, - "name": "Secret Rendezvous" + "name": "Unclaimed Territory" }, { - "count": 1, - "name": "Dawn of a New Age" + "count": 13, + "name": "Island" }, { "count": 1, - "name": "Infiltration Lens" + "name": "Doctor Doom, King of Latveria" } ], "sideboard": [] @@ -1013,12 +1013,12 @@ "sideboard": [] }, { - "name": "Kaalia of the Vast", + "name": "Fire Lord Azula", "author": "MTGGoldfish", "colors": [ + "B", "R", - "W", - "B" + "U" ], "tags": [ "metagame" @@ -1026,79 +1026,79 @@ "main": [ { "count": 1, - "name": "Kaalia of the Vast" + "name": "An Offer You Can't Refuse" }, { "count": 1, - "name": "Akroma, Angel of Wrath" + "name": "Arcane Signet" }, { "count": 1, - "name": "Ambition's Cost" + "name": "Archmage Emeritus" }, { "count": 1, - "name": "Angel of Despair" + "name": "Ashling, Flame Dancer" }, { "count": 1, - "name": "Arcane Signet" + "name": "Azula, Cunning Usurper" }, { "count": 1, - "name": "Archangel of Tithes" + "name": "Big Score" }, { "count": 1, - "name": "Archfiend of Depravity" + "name": "Birgi, God of Storytelling" }, { "count": 1, - "name": "Armageddon" + "name": "Blasphemous Act" }, { "count": 1, - "name": "Aurelia, the Law Above" + "name": "Blood Crypt" }, { "count": 1, - "name": "Balefire Dragon" + "name": "Bloodstained Mire" }, { "count": 1, - "name": "Bedevil" + "name": "Borne Upon a Wind" }, { "count": 1, - "name": "Bitter Reunion" + "name": "Brainstorm" }, { "count": 1, - "name": "Blitzball" + "name": "Bulk Up" }, { "count": 1, - "name": "Bolt Bend" + "name": "Cascade Bluffs" }, { "count": 1, - "name": "Breaching Dragonstorm" + "name": "Chaos Warp" }, { "count": 1, - "name": "Caves of Koilos" + "name": "Comet Storm" }, { "count": 1, - "name": "Charcoal Diamond" + "name": "Command Tower" }, { "count": 1, - "name": "Clifftop Retreat" + "name": "Counterspell" }, { "count": 1, - "name": "Command Tower" + "name": "Crackle with Power" }, { "count": 1, @@ -1106,7 +1106,11 @@ }, { "count": 1, - "name": "Day of Judgment" + "name": "Deadly Rollick" + }, + { + "count": 1, + "name": "Deflecting Swat" }, { "count": 1, @@ -1114,103 +1118,107 @@ }, { "count": 1, - "name": "Demon of Loathing" + "name": "Dragonskull Summit" }, { "count": 1, - "name": "Demonic Counsel" + "name": "Drowned Catacomb" }, { "count": 1, - "name": "Divine Resilience" + "name": "Dualcaster Mage" }, { "count": 1, - "name": "Dragon Tempest" + "name": "Electro, Assaulting Battery" }, { "count": 1, - "name": "Dragonskull Summit" + "name": "Electrodominance" }, { "count": 1, - "name": "Drakuseth, Maw of Flames" + "name": "Emergence Zone" }, { "count": 1, - "name": "Fetid Heath" + "name": "Etali, Primal Storm" }, { "count": 1, - "name": "Fire Diamond" + "name": "Exotic Orchard" }, { "count": 1, - "name": "Foreboding Ruins" + "name": "Faerie Mastermind" }, { "count": 1, - "name": "Furycalm Snarl" + "name": "Fated Firepower" }, { "count": 1, - "name": "Gisela, Blade of Goldnight" + "name": "Fellwar Stone" }, { "count": 1, - "name": "Gods Willing" + "name": "Fire Nation Palace" }, { "count": 1, - "name": "Harvester of Souls" + "name": "Firebender Ascension" }, { "count": 1, - "name": "Haunted Ridge" + "name": "Firebending Student" }, { "count": 1, - "name": "Hearth Charm" + "name": "Fists of Flame" }, { "count": 1, - "name": "Hellkite Tyrant" + "name": "Frantic Search" }, { "count": 1, - "name": "Hot Soup" + "name": "High Fae Trickster" + }, + { + "count": 4, + "name": "Island" }, { "count": 1, - "name": "Indulgent Tormentor" + "name": "Jeska's Will" }, { "count": 1, - "name": "Inevitable Defeat" + "name": "Kess, Dissident Mage" }, { "count": 1, - "name": "Isolated Chapel" + "name": "Leyline of Anticipation" }, { "count": 1, - "name": "Key to the City" + "name": "Lightning Bolt" }, { "count": 1, - "name": "Lyra Dawnbringer" + "name": "Lightning Greaves" }, { "count": 1, - "name": "Make a Stand" + "name": "Luxury Suite" }, { "count": 1, - "name": "Marble Diamond" + "name": "Mana Geyser" }, { "count": 1, - "name": "Mother of Runes" + "name": "Morphic Pool" }, { "count": 4, @@ -1218,165 +1226,181 @@ }, { "count": 1, - "name": "Myriad Landscape" + "name": "Mystic Remora" }, { "count": 1, - "name": "Neriv, Heart of the Storm" + "name": "Narset's Reversal" }, { "count": 1, - "name": "Night's Whisper" + "name": "Nightscape Familiar" }, { "count": 1, - "name": "Nomad Outpost" + "name": "Opt" }, { "count": 1, - "name": "Painful Truths" - }, - { - "count": 11, - "name": "Plains" + "name": "Ozai, the Phoenix King" }, { "count": 1, - "name": "Rakdos, Patron of Chaos" + "name": "Polluted Delta" }, { "count": 1, - "name": "Rampage of the Valkyries" + "name": "Pyretic Ritual" }, { "count": 1, - "name": "Read the Bones" + "name": "Redirect Lightning" }, { "count": 1, - "name": "Reanimate" + "name": "Reliquary Tower" }, { "count": 1, - "name": "Reliquary Tower" + "name": "Scalding Tarn" }, { "count": 1, - "name": "Rising of the Day" + "name": "Seething Song" }, { "count": 1, - "name": "Rugged Prairie" + "name": "Shivan Reef" }, { "count": 1, - "name": "Rune-Scarred Demon" + "name": "Snap" }, { "count": 1, - "name": "Scoured Barrens" + "name": "Snapcaster Mage" }, { "count": 1, - "name": "Scourge of the Throne" + "name": "Sol Ring" }, { "count": 1, - "name": "Sephara, Sky's Blade" + "name": "Sozin's Comet" }, { "count": 1, - "name": "Serra's Guardian" + "name": "Steam Vents" }, { "count": 1, - "name": "Shineshadow Snarl" + "name": "Storm-Kiln Artist" }, { "count": 1, - "name": "Sign in Blood" + "name": "Stormcarved Coast" }, { "count": 1, - "name": "Smoldering Marsh" + "name": "Sulfur Falls" }, { "count": 1, - "name": "Sol Ring" + "name": "Sulfurous Springs" + }, + { + "count": 3, + "name": "Swamp" }, { "count": 1, - "name": "Subjugator Angel" + "name": "Swan Song" }, { - "count": 2, - "name": "Swamp" + "count": 1, + "name": "Swiftfoot Boots" }, { "count": 1, - "name": "Tainted Field" + "name": "Talisman of Creativity" }, { "count": 1, - "name": "Tainted Peak" + "name": "Talisman of Dominance" }, { "count": 1, - "name": "Temple of Malice" + "name": "Talisman of Indulgence" }, { "count": 1, - "name": "Temple of Silence" + "name": "The Last Agni Kai" }, { "count": 1, - "name": "Temple of Triumph" + "name": "Thrill of Possibility" }, { "count": 1, - "name": "Temple of the False God" + "name": "Toxic Deluge" }, { "count": 1, - "name": "Terror of Mount Velus" + "name": "Training Center" }, { "count": 1, - "name": "Thought Vessel" + "name": "Twinning Staff" }, { "count": 1, - "name": "Thran Dynamo" + "name": "Underground River" }, { "count": 1, - "name": "Tormenting Voice" + "name": "Unexpected Windfall" }, { "count": 1, - "name": "Unbreakable Formation" + "name": "Valley Floodcaller" }, { "count": 1, - "name": "Vilis, Broker of Blood" + "name": "Vedalken Orrery" }, { "count": 1, - "name": "Whispersilk Cloak" + "name": "Veyran, Voice of Duality" }, { "count": 1, - "name": "Windborn Muse" + "name": "Waterlogged Teachings" + }, + { + "count": 1, + "name": "Watery Grave" + }, + { + "count": 1, + "name": "Xander's Lounge" + }, + { + "count": 1, + "name": "Zuko, Firebending Master" + }, + { + "count": 1, + "name": "Fire Lord Azula" } ], "sideboard": [] }, { - "name": "The Ur-Dragon", + "name": "Kaalia of the Vast", "author": "MTGGoldfish", "colors": [ "R", - "G", + "W", "B" ], "tags": [ @@ -1385,905 +1409,805 @@ "main": [ { "count": 1, - "name": "Silumgar, the Drifting Death" + "name": "Kaalia of the Vast" }, { "count": 1, - "name": "Sundown Pass" + "name": "Akroma, Angel of Wrath" }, { "count": 1, - "name": "Rockfall Vale" + "name": "Ambition's Cost" }, { "count": 1, - "name": "Bountiful Promenade" + "name": "Angel of Despair" }, { "count": 1, - "name": "Jodah, Archmage Eternal" + "name": "Arcane Signet" }, { "count": 1, - "name": "Raugrin Triome" + "name": "Archangel of Tithes" }, { "count": 1, - "name": "Scourge of the Throne" + "name": "Archfiend of Depravity" }, { "count": 1, - "name": "Dryad of the Ilysian Grove" + "name": "Armageddon" }, { "count": 1, - "name": "Command Tower" + "name": "Aurelia, the Law Above" }, { "count": 1, - "name": "Atarka, World Render" + "name": "Balefire Dragon" }, { "count": 1, - "name": "Amulet of Vigor" + "name": "Bedevil" }, { "count": 1, - "name": "Orb of Dragonkind" + "name": "Bitter Reunion" }, { "count": 1, - "name": "Flooded Strand" + "name": "Blitzball" }, { "count": 1, - "name": "Ancient Brass Dragon" + "name": "Bolt Bend" }, { "count": 1, - "name": "Zagoth Triome" + "name": "Breaching Dragonstorm" }, { "count": 1, - "name": "Ketria Triome" + "name": "Caves of Koilos" }, { "count": 1, - "name": "Timeless Lotus" + "name": "Charcoal Diamond" }, { "count": 1, - "name": "Rith, Liberated Primeval" + "name": "Clifftop Retreat" }, { "count": 1, - "name": "Hoarding Broodlord" + "name": "Command Tower" }, { "count": 1, - "name": "Utvara Hellkite" + "name": "Dark Ritual" }, { "count": 1, - "name": "Hellkite Courser" + "name": "Day of Judgment" }, { "count": 1, - "name": "Vesuva" + "name": "Demand Answers" }, { "count": 1, - "name": "Herald's Horn" + "name": "Demon of Loathing" }, { "count": 1, - "name": "Dragonlord Dromoka" + "name": "Demonic Counsel" }, { "count": 1, - "name": "Tiamat" + "name": "Divine Resilience" }, { "count": 1, - "name": "Hellkite Tyrant" + "name": "Dragon Tempest" }, { "count": 1, - "name": "Vanquisher's Banner" + "name": "Dragonskull Summit" }, { "count": 1, - "name": "Dragonspeaker Shaman" + "name": "Drakuseth, Maw of Flames" }, { "count": 1, - "name": "There and Back Again" + "name": "Fetid Heath" }, { "count": 1, - "name": "Ancient Gold Dragon" + "name": "Fire Diamond" }, { "count": 1, - "name": "Wrathful Red Dragon" + "name": "Foreboding Ruins" }, { "count": 1, - "name": "Rivaz of the Claw" + "name": "Furycalm Snarl" }, { "count": 1, - "name": "Scourge of Valkas" + "name": "Gisela, Blade of Goldnight" }, { "count": 1, - "name": "Smuggler's Surprise" + "name": "Gods Willing" }, { "count": 1, - "name": "The Ur-Dragon" + "name": "Harvester of Souls" }, { "count": 1, - "name": "Crux of Fate" + "name": "Haunted Ridge" }, { "count": 1, - "name": "Dragonlord Atarka" + "name": "Hearth Charm" }, { "count": 1, - "name": "Korlessa, Scale Singer" + "name": "Hellkite Tyrant" }, { "count": 1, - "name": "Blasphemous Act" + "name": "Hot Soup" }, { "count": 1, - "name": "Old Gnawbone" + "name": "Indulgent Tormentor" }, { "count": 1, - "name": "Sarkhan, Soul Aflame" + "name": "Inevitable Defeat" }, { "count": 1, - "name": "Mana Vault" + "name": "Isolated Chapel" }, { "count": 1, - "name": "Dragon Tempest" + "name": "Key to the City" }, { "count": 1, - "name": "Sol Ring" + "name": "Lyra Dawnbringer" }, { "count": 1, - "name": "Temur Ascendancy" + "name": "Make a Stand" }, { "count": 1, - "name": "Miirym, Sentinel Wyrm" + "name": "Marble Diamond" }, { "count": 1, - "name": "Monster Manual" + "name": "Mother of Runes" }, { - "count": 1, - "name": "Reflecting Pool" + "count": 4, + "name": "Mountain" }, { "count": 1, - "name": "Temur Battlecrier" + "name": "Myriad Landscape" }, { "count": 1, - "name": "Ancient Bronze Dragon" + "name": "Neriv, Heart of the Storm" }, { "count": 1, - "name": "Morophon, the Boundless" + "name": "Night's Whisper" }, { "count": 1, - "name": "Indatha Triome" + "name": "Nomad Outpost" }, { "count": 1, - "name": "Karlach, Fury of Avernus" + "name": "Painful Truths" }, { - "count": 1, - "name": "Savai Triome" + "count": 11, + "name": "Plains" }, { "count": 1, - "name": "Cavern of Souls" + "name": "Rakdos, Patron of Chaos" }, { "count": 1, - "name": "Karplusan Forest" + "name": "Rampage of the Valkyries" }, { "count": 1, - "name": "Great Hall of the Citadel" + "name": "Read the Bones" }, { "count": 1, - "name": "Deathcap Glade" + "name": "Reanimate" }, { "count": 1, - "name": "Morphic Pool" + "name": "Reliquary Tower" }, { "count": 1, - "name": "Wooded Foothills" + "name": "Rising of the Day" }, { "count": 1, - "name": "Cavern-Hoard Dragon" + "name": "Rugged Prairie" }, { "count": 1, - "name": "Overgrown Farmland" + "name": "Rune-Scarred Demon" }, { "count": 1, - "name": "Urborg, Tomb of Yawgmoth" + "name": "Scoured Barrens" }, { "count": 1, - "name": "Luxury Suite" + "name": "Scourge of the Throne" }, { "count": 1, - "name": "Mana Drain" + "name": "Sephara, Sky's Blade" }, { "count": 1, - "name": "Tribute to the World Tree" + "name": "Serra's Guardian" }, { "count": 1, - "name": "Sokenzan, Crucible of Defiance" + "name": "Shineshadow Snarl" }, { "count": 1, - "name": "Stomping Ground" + "name": "Sign in Blood" }, { "count": 1, - "name": "Yavimaya, Cradle of Growth" + "name": "Smoldering Marsh" }, { "count": 1, - "name": "Chromatic Lantern" + "name": "Sol Ring" }, { "count": 1, - "name": "Heroic Intervention" + "name": "Subjugator Angel" }, { - "count": 1, - "name": "Delighted Halfling" + "count": 2, + "name": "Swamp" }, { "count": 1, - "name": "Blood Crypt" + "name": "Tainted Field" }, { "count": 1, - "name": "Zurgo and Ojutai" + "name": "Tainted Peak" }, { "count": 1, - "name": "Black Market Connections" + "name": "Temple of Malice" }, { "count": 1, - "name": "Arcane Signet" + "name": "Temple of Silence" }, { "count": 1, - "name": "Vault of Champions" + "name": "Temple of Triumph" }, { "count": 1, - "name": "Marsh Flats" + "name": "Temple of the False God" }, { "count": 1, - "name": "Minion of the Mighty" + "name": "Terror of Mount Velus" }, { "count": 1, - "name": "Farseek" + "name": "Thought Vessel" }, { "count": 1, - "name": "Three Visits" + "name": "Thran Dynamo" }, { "count": 1, - "name": "Urza's Incubator" + "name": "Tormenting Voice" }, { "count": 1, - "name": "Mirkwood Bats" + "name": "Unbreakable Formation" }, { "count": 1, - "name": "Swashbuckler Extraordinaire" + "name": "Vilis, Broker of Blood" }, { "count": 1, - "name": "Bootleggers' Stash" + "name": "Whispersilk Cloak" }, { "count": 1, - "name": "Ganax, Astral Hunter" - }, + "name": "Windborn Muse" + } + ], + "sideboard": [] + }, + { + "name": "Muldrotha, the Gravetide", + "author": "MTGGoldfish", + "colors": [ + "U", + "B", + "G" + ], + "tags": [ + "metagame" + ], + "main": [ { "count": 1, - "name": "Tireless Provisioner" + "name": "Sakura-Tribe Elder" }, { "count": 1, - "name": "Draconic Muralists" + "name": "Seal of Removal" }, { "count": 1, - "name": "Wasteland" + "name": "Solemn Simulacrum" }, { "count": 1, - "name": "Ugin's Labyrinth" + "name": "Nihil Spellbomb" }, { "count": 1, - "name": "Contaminated Landscape" + "name": "Lord of the Forsaken" }, { "count": 1, - "name": "Two-Headed Hellkite" + "name": "Command Tower" }, { "count": 1, - "name": "Xander's Lounge" + "name": "Nyx Weaver" }, { "count": 1, - "name": "Hidetsugu and Kairi" + "name": "Sinister Concoction" }, { "count": 1, - "name": "Horn of the Mark" + "name": "Commander's Sphere" }, { "count": 1, - "name": "Zhao, the Moon Slayer" + "name": "Syr Konrad, the Grim" }, { "count": 1, - "name": "Dracogenesis" + "name": "Kaya's Ghostform" }, { "count": 1, - "name": "Fable of the Mirror-Breaker" + "name": "Sol Ring" }, { "count": 1, - "name": "Exotic Orchard" + "name": "Farseek" }, { "count": 1, - "name": "Dragonlord's Servant" - } - ], - "sideboard": [] - }, - { - "name": "Fire Lord Azula", - "author": "MTGGoldfish", - "colors": [ - "B", - "R", - "U" - ], - "tags": [ - "metagame" - ], - "main": [ + "name": "Nevinyrral's Disk" + }, { "count": 1, - "name": "An Offer You Can't Refuse" + "name": "Tatyova, Benthic Druid" }, { "count": 1, - "name": "Arcane Signet" + "name": "Assassin's Trophy" }, { "count": 1, - "name": "Archmage Emeritus" + "name": "Arcane Signet" }, { "count": 1, - "name": "Ashling, Flame Dancer" + "name": "Archaeomancer" }, { "count": 1, - "name": "Azula, Cunning Usurper" + "name": "Wood Elves" }, { "count": 1, - "name": "Big Score" + "name": "Ravenous Chupacabra" }, { "count": 1, - "name": "Birgi, God of Storytelling" + "name": "Plaguecrafter" }, { "count": 1, - "name": "Blasphemous Act" + "name": "Lightning Greaves" }, { "count": 1, - "name": "Blood Crypt" + "name": "Animate Dead" }, { "count": 1, - "name": "Bloodstained Mire" + "name": "Noxious Gearhulk" }, { "count": 1, - "name": "Borne Upon a Wind" + "name": "Springbloom Druid" }, { "count": 1, - "name": "Brainstorm" + "name": "Stitcher's Supplier" }, { "count": 1, - "name": "Bulk Up" + "name": "Satyr Wayfinder" }, { "count": 1, - "name": "Cascade Bluffs" + "name": "Spore Frog" }, { "count": 1, - "name": "Chaos Warp" + "name": "World Shaper" }, { "count": 1, - "name": "Comet Storm" + "name": "Caustic Caterpillar" }, { "count": 1, - "name": "Command Tower" + "name": "Temple of the False God" }, { "count": 1, - "name": "Counterspell" + "name": "Executioner's Capsule" }, { "count": 1, - "name": "Crackle with Power" + "name": "Pernicious Deed" }, { "count": 1, - "name": "Dark Ritual" + "name": "Seal of Primordium" }, { "count": 1, - "name": "Deadly Rollick" + "name": "Mulldrifter" }, { "count": 1, - "name": "Deflecting Swat" + "name": "Underrealm Lich" }, { "count": 1, - "name": "Demand Answers" + "name": "Baleful Strix" }, { "count": 1, - "name": "Dragonskull Summit" + "name": "Siren Stormtamer" }, { "count": 1, - "name": "Drowned Catacomb" + "name": "Gravebreaker Lamia" }, { "count": 1, - "name": "Dualcaster Mage" + "name": "Muldrotha, the Gravetide" }, { "count": 1, - "name": "Electro, Assaulting Battery" + "name": "Disciple of Bolas" }, { "count": 1, - "name": "Electrodominance" + "name": "The Gitrog Monster" }, { "count": 1, - "name": "Emergence Zone" + "name": "Woodland Cemetery" }, { "count": 1, - "name": "Etali, Primal Storm" + "name": "Vessel of Nascency" }, { "count": 1, - "name": "Exotic Orchard" + "name": "Villainous Wealth" }, { "count": 1, - "name": "Faerie Mastermind" + "name": "Painful Truths" }, { "count": 1, - "name": "Fated Firepower" + "name": "Grisly Salvage" }, { "count": 1, - "name": "Fellwar Stone" + "name": "Avenger of Zendikar" }, { "count": 1, - "name": "Fire Nation Palace" + "name": "Fleshbag Marauder" }, { "count": 1, - "name": "Firebender Ascension" + "name": "Shriekmaw" }, { "count": 1, - "name": "Firebending Student" + "name": "Jarad, Golgari Lich Lord" }, { "count": 1, - "name": "Fists of Flame" + "name": "Daemogoth Woe-Eater" }, { "count": 1, - "name": "Frantic Search" + "name": "Sidisi, Brood Tyrant" }, { "count": 1, - "name": "High Fae Trickster" + "name": "Woodfall Primus" }, { - "count": 4, - "name": "Island" + "count": 1, + "name": "Thragtusk" }, { "count": 1, - "name": "Jeska's Will" + "name": "Greenwarden of Murasa" }, { "count": 1, - "name": "Kess, Dissident Mage" + "name": "Terastodon" }, { "count": 1, - "name": "Leyline of Anticipation" + "name": "Phyrexian Delver" }, { "count": 1, - "name": "Lightning Bolt" + "name": "Fierce Empath" }, { "count": 1, - "name": "Lightning Greaves" + "name": "Decree of Pain" }, { "count": 1, - "name": "Luxury Suite" + "name": "Black Sun's Zenith" }, { "count": 1, - "name": "Mana Geyser" + "name": "Rampant Growth" }, { "count": 1, - "name": "Morphic Pool" + "name": "Sultai Ascendancy" }, { - "count": 4, - "name": "Mountain" + "count": 1, + "name": "Seal of Doom" }, { "count": 1, - "name": "Mystic Remora" + "name": "Polluted Mire" }, { "count": 1, - "name": "Narset's Reversal" + "name": "Kheru Goldkeeper" }, { "count": 1, - "name": "Nightscape Familiar" + "name": "River Kelpie" }, { "count": 1, - "name": "Opt" + "name": "Lord of Extinction" }, { "count": 1, - "name": "Ozai, the Phoenix King" + "name": "Dread Return" }, { "count": 1, - "name": "Polluted Delta" + "name": "Dakmor Salvage" }, { "count": 1, - "name": "Pyretic Ritual" + "name": "Opulent Palace" }, { "count": 1, - "name": "Redirect Lightning" + "name": "Evolving Wilds" }, { "count": 1, - "name": "Reliquary Tower" + "name": "Terramorphic Expanse" }, { "count": 1, - "name": "Scalding Tarn" + "name": "Hinterland Harbor" }, { "count": 1, - "name": "Seething Song" + "name": "Lonely Sandbar" }, { "count": 1, - "name": "Shivan Reef" + "name": "Tranquil Thicket" }, { "count": 1, - "name": "Snap" + "name": "Exotic Orchard" }, { "count": 1, - "name": "Snapcaster Mage" + "name": "Temple of Malady" }, { - "count": 1, - "name": "Sol Ring" + "count": 6, + "name": "Island" + }, + { + "count": 8, + "name": "Swamp" }, + { + "count": 8, + "name": "Forest" + } + ], + "sideboard": [] + }, + { + "name": "Edgar Markov", + "author": "MTGGoldfish", + "colors": [ + "B", + "R", + "W" + ], + "tags": [ + "metagame" + ], + "main": [ { "count": 1, - "name": "Sozin's Comet" + "name": "Rakdos Charm" }, { "count": 1, - "name": "Steam Vents" + "name": "Strefan, Maurer Progenitor" }, { "count": 1, - "name": "Storm-Kiln Artist" + "name": "Infernal Grasp" }, { "count": 1, - "name": "Stormcarved Coast" + "name": "Restless Bloodseeker" }, { "count": 1, - "name": "Sulfur Falls" + "name": "Swords to Plowshares" }, { "count": 1, - "name": "Sulfurous Springs" + "name": "Balustrade Spy" }, { - "count": 3, - "name": "Swamp" + "count": 1, + "name": "Alluring Suitor" }, { "count": 1, - "name": "Swan Song" + "name": "Falkenrath Perforator" }, { "count": 1, - "name": "Swiftfoot Boots" + "name": "Belligerent Guest" }, { "count": 1, - "name": "Talisman of Creativity" + "name": "Edgar, Charmed Groom" }, { "count": 1, - "name": "Talisman of Dominance" + "name": "Stromkirk Bloodthief" }, { "count": 1, - "name": "Talisman of Indulgence" + "name": "Child of Night" }, { "count": 1, - "name": "The Last Agni Kai" + "name": "Humiliate" }, { "count": 1, - "name": "Thrill of Possibility" + "name": "Indulgent Aristocrat" }, { "count": 1, - "name": "Toxic Deluge" + "name": "Markov Purifier" }, { "count": 1, - "name": "Training Center" + "name": "Rakdos Signet" }, { "count": 1, - "name": "Twinning Staff" + "name": "Blasphemous Act" }, { "count": 1, - "name": "Underground River" + "name": "Vampire of the Dire Moon" }, { "count": 1, - "name": "Unexpected Windfall" + "name": "Silverquill Pledgemage" }, { "count": 1, - "name": "Valley Floodcaller" + "name": "Bloodthrone Vampire" }, { "count": 1, - "name": "Vedalken Orrery" + "name": "Immersturm Predator" }, { "count": 1, - "name": "Veyran, Voice of Duality" + "name": "Bloodthirsty Aerialist" }, { "count": 1, - "name": "Waterlogged Teachings" + "name": "Captivating Vampire" }, { "count": 1, - "name": "Watery Grave" + "name": "Thrill of Possibility" }, { "count": 1, - "name": "Xander's Lounge" + "name": "Vanquish the Horde" }, { "count": 1, - "name": "Zuko, Firebending Master" - }, - { - "count": 1, - "name": "Fire Lord Azula" - } - ], - "sideboard": [] - }, - { - "name": "Edgar Markov", - "author": "MTGGoldfish", - "colors": [ - "B", - "R", - "W" - ], - "tags": [ - "metagame" - ], - "main": [ - { - "count": 1, - "name": "Rakdos Charm" - }, - { - "count": 1, - "name": "Strefan, Maurer Progenitor" - }, - { - "count": 1, - "name": "Infernal Grasp" - }, - { - "count": 1, - "name": "Restless Bloodseeker" - }, - { - "count": 1, - "name": "Swords to Plowshares" - }, - { - "count": 1, - "name": "Balustrade Spy" - }, - { - "count": 1, - "name": "Alluring Suitor" - }, - { - "count": 1, - "name": "Falkenrath Perforator" - }, - { - "count": 1, - "name": "Belligerent Guest" - }, - { - "count": 1, - "name": "Edgar, Charmed Groom" - }, - { - "count": 1, - "name": "Stromkirk Bloodthief" - }, - { - "count": 1, - "name": "Child of Night" - }, - { - "count": 1, - "name": "Humiliate" - }, - { - "count": 1, - "name": "Indulgent Aristocrat" - }, - { - "count": 1, - "name": "Markov Purifier" - }, - { - "count": 1, - "name": "Rakdos Signet" - }, - { - "count": 1, - "name": "Blasphemous Act" - }, - { - "count": 1, - "name": "Vampire of the Dire Moon" - }, - { - "count": 1, - "name": "Silverquill Pledgemage" - }, - { - "count": 1, - "name": "Bloodthrone Vampire" - }, - { - "count": 1, - "name": "Immersturm Predator" - }, - { - "count": 1, - "name": "Bloodthirsty Aerialist" - }, - { - "count": 1, - "name": "Captivating Vampire" - }, - { - "count": 1, - "name": "Thrill of Possibility" - }, - { - "count": 1, - "name": "Vanquish the Horde" - }, - { - "count": 1, - "name": "Oversold Cemetery" + "name": "Oversold Cemetery" }, { "count": 1, @@ -2509,12 +2433,12 @@ "sideboard": [] }, { - "name": "Y'shtola, Night's Blessed", + "name": "The Ur-Dragon", "author": "MTGGoldfish", "colors": [ - "W", - "B", - "U" + "R", + "G", + "B" ], "tags": [ "metagame" @@ -2522,398 +2446,414 @@ "main": [ { "count": 1, - "name": "Anguished Unmaking" + "name": "Silumgar, the Drifting Death" }, { "count": 1, - "name": "Arcane Sanctum" + "name": "Sundown Pass" }, { "count": 1, - "name": "Arcane Signet" + "name": "Rockfall Vale" }, { "count": 1, - "name": "Archmage Emeritus" + "name": "Bountiful Promenade" }, { "count": 1, - "name": "Ash Barrens" + "name": "Jodah, Archmage Eternal" }, { "count": 1, - "name": "Authority of the Consuls" + "name": "Raugrin Triome" }, { "count": 1, - "name": "Avatar's Wrath" + "name": "Scourge of the Throne" }, { "count": 1, - "name": "Baleful Strix" + "name": "Dryad of the Ilysian Grove" }, { "count": 1, - "name": "Baral, Chief of Compliance" + "name": "Command Tower" }, { "count": 1, - "name": "Bender's Waterskin" + "name": "Atarka, World Render" }, { "count": 1, - "name": "Bolas's Citadel" + "name": "Amulet of Vigor" }, { "count": 1, - "name": "Choked Estuary" + "name": "Orb of Dragonkind" }, { "count": 1, - "name": "Circle of Power" + "name": "Flooded Strand" }, { "count": 1, - "name": "Cleansing Nova" + "name": "Ancient Brass Dragon" }, { "count": 1, - "name": "Command Tower" + "name": "Zagoth Triome" }, { "count": 1, - "name": "Concealed Courtyard" + "name": "Ketria Triome" }, { "count": 1, - "name": "Contaminated Aquifer" + "name": "Timeless Lotus" }, { "count": 1, - "name": "Crux of Fate" + "name": "Rith, Liberated Primeval" }, { "count": 1, - "name": "Curiosity" + "name": "Hoarding Broodlord" }, { "count": 1, - "name": "Cut a Deal" + "name": "Utvara Hellkite" }, { "count": 1, - "name": "Darkwater Catacombs" + "name": "Hellkite Courser" }, { "count": 1, - "name": "Delney, Streetwise Lookout" + "name": "Vesuva" }, { "count": 1, - "name": "Desolate Mire" + "name": "Herald's Horn" }, { "count": 1, - "name": "Dig Through Time" + "name": "Dragonlord Dromoka" }, { "count": 1, - "name": "Disallow" + "name": "Tiamat" }, { "count": 1, - "name": "Dovin's Veto" + "name": "Hellkite Tyrant" }, { "count": 1, - "name": "Drowned Catacomb" + "name": "Vanquisher's Banner" }, { "count": 1, - "name": "Emet-Selch of the Third Seat" + "name": "Dragonspeaker Shaman" }, { "count": 1, - "name": "Evolving Wilds" + "name": "There and Back Again" }, { "count": 1, - "name": "Exsanguinate" + "name": "Ancient Gold Dragon" }, { "count": 1, - "name": "Fandaniel, Telophoroi Ascian" + "name": "Wrathful Red Dragon" }, { "count": 1, - "name": "Fell the Profane" + "name": "Rivaz of the Claw" }, { "count": 1, - "name": "Fetid Heath" + "name": "Scourge of Valkas" }, { "count": 1, - "name": "Flooded Strand" + "name": "Smuggler's Surprise" }, { "count": 1, - "name": "Floodfarm Verge" + "name": "The Ur-Dragon" }, { "count": 1, - "name": "Frantic Search" + "name": "Crux of Fate" }, { "count": 1, - "name": "Ghostly Prison" + "name": "Dragonlord Atarka" }, { "count": 1, - "name": "Glacial Fortress" + "name": "Korlessa, Scale Singer" }, { "count": 1, - "name": "Godless Shrine" + "name": "Blasphemous Act" }, { "count": 1, - "name": "Grim Tutor" + "name": "Old Gnawbone" }, { "count": 1, - "name": "Hallowed Fountain" + "name": "Sarkhan, Soul Aflame" }, { "count": 1, - "name": "Haughty Djinn" + "name": "Mana Vault" }, { "count": 1, - "name": "Helm of the Ghastlord" + "name": "Dragon Tempest" }, { "count": 1, - "name": "Hermes, Overseer of Elpis" + "name": "Sol Ring" }, { "count": 1, - "name": "Hindering Light" + "name": "Temur Ascendancy" }, { "count": 1, - "name": "Hullbreaker Horror" + "name": "Miirym, Sentinel Wyrm" }, { "count": 1, - "name": "Hypnotic Sprite" + "name": "Monster Manual" }, { "count": 1, - "name": "Irrigated Farmland" + "name": "Reflecting Pool" }, { - "count": 2, - "name": "Island" + "count": 1, + "name": "Temur Battlecrier" }, { "count": 1, - "name": "Isolated Chapel" + "name": "Ancient Bronze Dragon" }, { "count": 1, - "name": "Kambal, Consul of Allocation" + "name": "Morophon, the Boundless" }, { "count": 1, - "name": "Krile Baldesion" + "name": "Indatha Triome" }, { "count": 1, - "name": "Lingering Souls" + "name": "Karlach, Fury of Avernus" }, { "count": 1, - "name": "Lotho, Corrupt Shirriff" + "name": "Savai Triome" }, { "count": 1, - "name": "Lyse Hext" + "name": "Cavern of Souls" }, { "count": 1, - "name": "Mockingbird" + "name": "Karplusan Forest" }, { "count": 1, - "name": "Murderous Rider" + "name": "Great Hall of the Citadel" }, { "count": 1, - "name": "Ophidian Eye" + "name": "Deathcap Glade" }, { "count": 1, - "name": "Papalymo Totolymo" + "name": "Morphic Pool" }, { "count": 1, - "name": "Path of Ancestry" + "name": "Wooded Foothills" }, { "count": 1, - "name": "Phyrexian Arena" + "name": "Cavern-Hoard Dragon" }, { - "count": 3, - "name": "Plains" + "count": 1, + "name": "Overgrown Farmland" }, { "count": 1, - "name": "Port Town" + "name": "Urborg, Tomb of Yawgmoth" }, { "count": 1, - "name": "Prairie Stream" + "name": "Luxury Suite" }, { "count": 1, - "name": "Precognition Field" + "name": "Mana Drain" }, { "count": 1, - "name": "Propaganda" + "name": "Tribute to the World Tree" }, { "count": 1, - "name": "Relic of Legends" + "name": "Sokenzan, Crucible of Defiance" }, { "count": 1, - "name": "Scavenger Grounds" + "name": "Stomping Ground" }, { "count": 1, - "name": "Shineshadow Snarl" + "name": "Yavimaya, Cradle of Growth" }, { "count": 1, - "name": "Skycloud Expanse" + "name": "Chromatic Lantern" }, { "count": 1, - "name": "Slaughter" + "name": "Heroic Intervention" }, { "count": 1, - "name": "Snuff Out" + "name": "Delighted Halfling" }, { "count": 1, - "name": "Sol Ring" + "name": "Blood Crypt" }, { "count": 1, - "name": "Spirit Water Revival" + "name": "Zurgo and Ojutai" }, { "count": 1, - "name": "Sublime Epiphany" + "name": "Black Market Connections" }, { "count": 1, - "name": "Sunken Hollow" + "name": "Arcane Signet" }, { "count": 1, - "name": "Sunken Ruins" + "name": "Vault of Champions" }, { - "count": 2, - "name": "Swamp" + "count": 1, + "name": "Marsh Flats" }, { "count": 1, - "name": "Syphon Mind" + "name": "Minion of the Mighty" }, { "count": 1, - "name": "Talisman of Dominance" + "name": "Farseek" }, { "count": 1, - "name": "Talisman of Hierarchy" + "name": "Three Visits" }, { "count": 1, - "name": "Talisman of Progress" + "name": "Urza's Incubator" }, { "count": 1, - "name": "Tandem Lookout" + "name": "Mirkwood Bats" }, { "count": 1, - "name": "Teferi, Time Raveler" + "name": "Swashbuckler Extraordinaire" }, { "count": 1, - "name": "Temple of the False God" + "name": "Bootleggers' Stash" }, { "count": 1, - "name": "The Mechanist, Aerial Artisan" + "name": "Ganax, Astral Hunter" }, { "count": 1, - "name": "Thought Vessel" + "name": "Tireless Provisioner" }, { "count": 1, - "name": "Tome of Legends" + "name": "Draconic Muralists" }, { "count": 1, - "name": "Torment of Hailfire" + "name": "Wasteland" }, { "count": 1, - "name": "Toxic Deluge" + "name": "Ugin's Labyrinth" }, { "count": 1, - "name": "Transpose" + "name": "Contaminated Landscape" }, { "count": 1, - "name": "Underground River" + "name": "Two-Headed Hellkite" }, { "count": 1, - "name": "Vindicate" + "name": "Xander's Lounge" }, { "count": 1, - "name": "Void Rend" + "name": "Hidetsugu and Kairi" }, { "count": 1, - "name": "Watery Grave" + "name": "Horn of the Mark" }, { "count": 1, - "name": "Y'shtola, Night's Blessed" + "name": "Zhao, the Moon Slayer" + }, + { + "count": 1, + "name": "Dracogenesis" + }, + { + "count": 1, + "name": "Fable of the Mirror-Breaker" + }, + { + "count": 1, + "name": "Exotic Orchard" + }, + { + "count": 1, + "name": "Dragonlord's Servant" } ], "sideboard": [] }, { - "name": "Muldrotha, the Gravetide", + "name": "Y'shtola, Night's Blessed", "author": "MTGGoldfish", "colors": [ - "U", + "W", "B", - "G" + "U" ], "tags": [ "metagame" @@ -2921,23 +2861,59 @@ "main": [ { "count": 1, - "name": "Sakura-Tribe Elder" + "name": "Anguished Unmaking" }, { "count": 1, - "name": "Seal of Removal" + "name": "Arcane Sanctum" }, { "count": 1, - "name": "Solemn Simulacrum" + "name": "Arcane Signet" }, { "count": 1, - "name": "Nihil Spellbomb" + "name": "Archmage Emeritus" }, { "count": 1, - "name": "Lord of the Forsaken" + "name": "Ash Barrens" + }, + { + "count": 1, + "name": "Authority of the Consuls" + }, + { + "count": 1, + "name": "Avatar's Wrath" + }, + { + "count": 1, + "name": "Baleful Strix" + }, + { + "count": 1, + "name": "Baral, Chief of Compliance" + }, + { + "count": 1, + "name": "Bender's Waterskin" + }, + { + "count": 1, + "name": "Bolas's Citadel" + }, + { + "count": 1, + "name": "Choked Estuary" + }, + { + "count": 1, + "name": "Circle of Power" + }, + { + "count": 1, + "name": "Cleansing Nova" }, { "count": 1, @@ -2945,303 +2921,327 @@ }, { "count": 1, - "name": "Nyx Weaver" + "name": "Concealed Courtyard" }, { "count": 1, - "name": "Sinister Concoction" + "name": "Contaminated Aquifer" }, { "count": 1, - "name": "Commander's Sphere" + "name": "Crux of Fate" }, { "count": 1, - "name": "Syr Konrad, the Grim" + "name": "Curiosity" }, { "count": 1, - "name": "Kaya's Ghostform" + "name": "Cut a Deal" }, { "count": 1, - "name": "Sol Ring" + "name": "Darkwater Catacombs" }, { "count": 1, - "name": "Farseek" + "name": "Delney, Streetwise Lookout" }, { "count": 1, - "name": "Nevinyrral's Disk" + "name": "Desolate Mire" }, { "count": 1, - "name": "Tatyova, Benthic Druid" + "name": "Dig Through Time" }, { "count": 1, - "name": "Assassin's Trophy" + "name": "Disallow" }, { "count": 1, - "name": "Arcane Signet" + "name": "Dovin's Veto" }, { "count": 1, - "name": "Archaeomancer" + "name": "Drowned Catacomb" }, { "count": 1, - "name": "Wood Elves" + "name": "Emet-Selch of the Third Seat" }, { "count": 1, - "name": "Ravenous Chupacabra" + "name": "Evolving Wilds" }, { "count": 1, - "name": "Plaguecrafter" + "name": "Exsanguinate" }, { "count": 1, - "name": "Lightning Greaves" + "name": "Fandaniel, Telophoroi Ascian" }, { "count": 1, - "name": "Animate Dead" + "name": "Fell the Profane" }, { "count": 1, - "name": "Noxious Gearhulk" + "name": "Fetid Heath" }, { "count": 1, - "name": "Springbloom Druid" + "name": "Flooded Strand" }, { "count": 1, - "name": "Stitcher's Supplier" + "name": "Floodfarm Verge" }, { "count": 1, - "name": "Satyr Wayfinder" + "name": "Frantic Search" }, { "count": 1, - "name": "Spore Frog" + "name": "Ghostly Prison" }, { "count": 1, - "name": "World Shaper" + "name": "Glacial Fortress" }, { "count": 1, - "name": "Caustic Caterpillar" + "name": "Godless Shrine" }, { "count": 1, - "name": "Temple of the False God" + "name": "Grim Tutor" }, { "count": 1, - "name": "Executioner's Capsule" + "name": "Hallowed Fountain" }, { "count": 1, - "name": "Pernicious Deed" + "name": "Haughty Djinn" }, { "count": 1, - "name": "Seal of Primordium" + "name": "Helm of the Ghastlord" }, { "count": 1, - "name": "Mulldrifter" + "name": "Hermes, Overseer of Elpis" }, { "count": 1, - "name": "Underrealm Lich" + "name": "Hindering Light" }, { "count": 1, - "name": "Baleful Strix" + "name": "Hullbreaker Horror" }, { "count": 1, - "name": "Siren Stormtamer" + "name": "Hypnotic Sprite" }, { "count": 1, - "name": "Gravebreaker Lamia" + "name": "Irrigated Farmland" + }, + { + "count": 2, + "name": "Island" }, { "count": 1, - "name": "Muldrotha, the Gravetide" + "name": "Isolated Chapel" }, { "count": 1, - "name": "Disciple of Bolas" + "name": "Kambal, Consul of Allocation" }, { "count": 1, - "name": "The Gitrog Monster" + "name": "Krile Baldesion" }, { "count": 1, - "name": "Woodland Cemetery" + "name": "Lingering Souls" }, { "count": 1, - "name": "Vessel of Nascency" + "name": "Lotho, Corrupt Shirriff" }, { "count": 1, - "name": "Villainous Wealth" + "name": "Lyse Hext" }, { "count": 1, - "name": "Painful Truths" + "name": "Mockingbird" }, { "count": 1, - "name": "Grisly Salvage" + "name": "Murderous Rider" }, { "count": 1, - "name": "Avenger of Zendikar" + "name": "Ophidian Eye" }, { "count": 1, - "name": "Fleshbag Marauder" + "name": "Papalymo Totolymo" }, { "count": 1, - "name": "Shriekmaw" + "name": "Path of Ancestry" }, { "count": 1, - "name": "Jarad, Golgari Lich Lord" + "name": "Phyrexian Arena" + }, + { + "count": 3, + "name": "Plains" }, { "count": 1, - "name": "Daemogoth Woe-Eater" + "name": "Port Town" }, { "count": 1, - "name": "Sidisi, Brood Tyrant" + "name": "Prairie Stream" }, { "count": 1, - "name": "Woodfall Primus" + "name": "Precognition Field" }, { "count": 1, - "name": "Thragtusk" + "name": "Propaganda" }, { "count": 1, - "name": "Greenwarden of Murasa" + "name": "Relic of Legends" }, { "count": 1, - "name": "Terastodon" + "name": "Scavenger Grounds" }, { "count": 1, - "name": "Phyrexian Delver" + "name": "Shineshadow Snarl" }, { "count": 1, - "name": "Fierce Empath" + "name": "Skycloud Expanse" }, { "count": 1, - "name": "Decree of Pain" + "name": "Slaughter" }, { "count": 1, - "name": "Black Sun's Zenith" + "name": "Snuff Out" }, { "count": 1, - "name": "Rampant Growth" + "name": "Sol Ring" }, { "count": 1, - "name": "Sultai Ascendancy" + "name": "Spirit Water Revival" }, { "count": 1, - "name": "Seal of Doom" + "name": "Sublime Epiphany" }, { "count": 1, - "name": "Polluted Mire" + "name": "Sunken Hollow" }, { "count": 1, - "name": "Kheru Goldkeeper" + "name": "Sunken Ruins" + }, + { + "count": 2, + "name": "Swamp" }, { "count": 1, - "name": "River Kelpie" + "name": "Syphon Mind" }, { "count": 1, - "name": "Lord of Extinction" + "name": "Talisman of Dominance" }, { "count": 1, - "name": "Dread Return" + "name": "Talisman of Hierarchy" }, { "count": 1, - "name": "Dakmor Salvage" + "name": "Talisman of Progress" }, { "count": 1, - "name": "Opulent Palace" + "name": "Tandem Lookout" }, { "count": 1, - "name": "Evolving Wilds" + "name": "Teferi, Time Raveler" }, { "count": 1, - "name": "Terramorphic Expanse" + "name": "Temple of the False God" }, { "count": 1, - "name": "Hinterland Harbor" + "name": "The Mechanist, Aerial Artisan" }, { "count": 1, - "name": "Lonely Sandbar" + "name": "Thought Vessel" }, { "count": 1, - "name": "Tranquil Thicket" + "name": "Tome of Legends" }, { "count": 1, - "name": "Exotic Orchard" + "name": "Torment of Hailfire" }, { "count": 1, - "name": "Temple of Malady" + "name": "Toxic Deluge" }, { - "count": 6, - "name": "Island" + "count": 1, + "name": "Transpose" }, { - "count": 8, - "name": "Swamp" + "count": 1, + "name": "Underground River" }, { - "count": 8, - "name": "Forest" + "count": 1, + "name": "Vindicate" + }, + { + "count": 1, + "name": "Void Rend" + }, + { + "count": 1, + "name": "Watery Grave" + }, + { + "count": 1, + "name": "Y'shtola, Night's Blessed" } ], "sideboard": [] diff --git a/client/public/feeds/mtggoldfish-modern.json b/client/public/feeds/mtggoldfish-modern.json index 760c17356d..6bfea1590b 100644 --- a/client/public/feeds/mtggoldfish-modern.json +++ b/client/public/feeds/mtggoldfish-modern.json @@ -5,7 +5,7 @@ "icon": "G", "format": "modern", "version": 1, - "updated": "2026-07-28T00:00:00Z", + "updated": "2026-07-29T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/modern", "decks": [ { @@ -424,10 +424,11 @@ ] }, { - "name": "Eldrazi Tron", + "name": "Eldrazi", "author": "MTGGoldfish", "colors": [ - "B" + "G", + "R" ], "tags": [ "metagame" @@ -435,443 +436,442 @@ "main": [ { "count": 1, - "name": "Swamp" + "name": "Windswept Heath" + }, + { + "count": 3, + "name": "Forest" + }, + { + "count": 2, + "name": "Ugin's Labyrinth" + }, + { + "count": 2, + "name": "Thought-Knot Seer" + }, + { + "count": 2, + "name": "Blade of the Bloodchief" }, { "count": 1, - "name": "Wastes" + "name": "Misty Rainforest" }, { - "count": 4, - "name": "Urza's Mine" + "count": 1, + "name": "Dismember" }, { - "count": 4, - "name": "Urza's Power Plant" + "count": 1, + "name": "Stomping Ground" }, { - "count": 4, - "name": "Urza's Tower" + "count": 3, + "name": "Basking Broodscale" }, { - "count": 4, - "name": "Ugin's Labyrinth" + "count": 1, + "name": "Pithing Needle" }, { "count": 4, "name": "Eldrazi Temple" }, { - "count": 4, - "name": "Mind Stone" + "count": 2, + "name": "Writhing Chrysalis" }, { - "count": 2, - "name": "Everflowing Chalice" + "count": 1, + "name": "Verdant Catacombs" }, { - "count": 3, - "name": "Chalice of the Void" + "count": 1, + "name": "Thief of Existence" }, { - "count": 2, - "name": "Disruptor Flute" + "count": 1, + "name": "Springleaf Drum" }, { "count": 4, - "name": "Expedition Map" + "name": "Kozilek's Command" }, { "count": 4, - "name": "Thought-Knot Seer" + "name": "Grove of the Burnwillows" }, { - "count": 4, - "name": "Karn, the Great Creator" + "count": 2, + "name": "Ancient Stirrings" }, { "count": 4, - "name": "Kozilek's Command" + "name": "Malevolent Rumble" }, { "count": 4, - "name": "Devourer of Destiny" + "name": "Glaring Fleshraker" }, { - "count": 4, - "name": "Ugin, Eye of the Storms" + "count": 2, + "name": "Unholy Heat" + }, + { + "count": 1, + "name": "Commercial District" }, { "count": 2, + "name": "Vexing Bauble" + }, + { + "count": 1, + "name": "Haywire Mite" + }, + { + "count": 4, "name": "Emrakul, the Promised End" }, { "count": 2, - "name": "Dismember" + "name": "Boseiju, Who Endures" + }, + { + "count": 4, + "name": "Urza's Saga" } ], "sideboard": [ { "count": 1, - "name": "Walking Ballista" + "name": "Thought-Knot Seer" }, { "count": 2, - "name": "Trinisphere" + "name": "Nature's Claim" }, { "count": 2, - "name": "Torpor Orb" - }, - { - "count": 1, - "name": "Cityscape Leveler" + "name": "Firespout" }, { "count": 1, - "name": "Extinguisher Battleship" + "name": "Gemstone Caverns" }, { "count": 1, - "name": "Ensnaring Bridge" + "name": "Disruptor Flute" }, { "count": 1, - "name": "Ratchet Bomb" + "name": "Writhing Chrysalis" }, { "count": 1, - "name": "Tormod's Crypt" + "name": "Basking Broodscale" }, { "count": 1, - "name": "Disruptor Flute" + "name": "Thief of Existence" }, { - "count": 1, - "name": "The Stone Brain" + "count": 2, + "name": "Cavern of Souls" }, { "count": 1, - "name": "Planetarium of Wan Shi Tong" + "name": "Grafdigger's Cage" }, { "count": 1, - "name": "Skysovereign, Consul Flagship" + "name": "Soul-Guide Lantern" }, { "count": 1, - "name": "Liquimetal Coating" + "name": "Blade of the Bloodchief" } ] }, { - "name": "Ruby Storm", + "name": "Eldrazi Tron", "author": "MTGGoldfish", "colors": [ - "R", - "W" + "B" ], "tags": [ "metagame" ], "main": [ { - "count": 4, - "name": "Ruby Medallion" + "count": 1, + "name": "Swamp" }, { - "count": 2, - "name": "Artist's Talent" + "count": 1, + "name": "Wastes" }, { - "count": 3, - "name": "Bloodstained Mire" + "count": 4, + "name": "Urza's Mine" }, { - "count": 3, - "name": "Past in Flames" + "count": 4, + "name": "Urza's Power Plant" }, { "count": 4, - "name": "Mountain" + "name": "Urza's Tower" }, { - "count": 1, - "name": "Elegant Parlor" + "count": 4, + "name": "Ugin's Labyrinth" }, { "count": 4, - "name": "Hex Magic" + "name": "Eldrazi Temple" }, { "count": 4, - "name": "Manamorphose" + "name": "Mind Stone" }, { - "count": 1, - "name": "Grapeshot" + "count": 2, + "name": "Everflowing Chalice" }, { - "count": 1, - "name": "Gemstone Caverns" + "count": 3, + "name": "Chalice of the Void" + }, + { + "count": 2, + "name": "Disruptor Flute" }, { "count": 4, - "name": "Desperate Ritual" + "name": "Expedition Map" }, { "count": 4, - "name": "Pyretic Ritual" + "name": "Thought-Knot Seer" }, { "count": 4, - "name": "Ral, Monsoon Mage" + "name": "Karn, the Great Creator" }, { "count": 4, - "name": "Reckless Impulse" + "name": "Kozilek's Command" }, { - "count": 1, - "name": "Commercial District" + "count": 4, + "name": "Devourer of Destiny" }, { - "count": 1, - "name": "Sacred Foundry" + "count": 4, + "name": "Ugin, Eye of the Storms" }, { - "count": 1, - "name": "Arid Mesa" + "count": 2, + "name": "Emrakul, the Promised End" }, + { + "count": 2, + "name": "Dismember" + } + ], + "sideboard": [ { "count": 1, - "name": "Sunbaked Canyon" + "name": "Walking Ballista" }, { "count": 2, - "name": "Valakut Awakening" + "name": "Trinisphere" }, { "count": 2, - "name": "Wish" + "name": "Torpor Orb" }, { - "count": 3, - "name": "Wooded Foothills" + "count": 1, + "name": "Cityscape Leveler" }, { - "count": 4, - "name": "Wrenn's Resolve" + "count": 1, + "name": "Extinguisher Battleship" }, - { - "count": 2, - "name": "Scalding Tarn" - } - ], - "sideboard": [ { "count": 1, - "name": "Past in Flames" + "name": "Ensnaring Bridge" }, { - "count": 3, - "name": "Orim's Chant" + "count": 1, + "name": "Ratchet Bomb" }, { "count": 1, - "name": "Grapeshot" + "name": "Tormod's Crypt" }, { "count": 1, - "name": "Empty the Warrens" + "name": "Disruptor Flute" }, { - "count": 4, - "name": "Prismatic Ending" + "count": 1, + "name": "The Stone Brain" }, { - "count": 2, - "name": "Wear // Tear" + "count": 1, + "name": "Planetarium of Wan Shi Tong" }, { "count": 1, - "name": "Untimely Malfunction" + "name": "Skysovereign, Consul Flagship" }, { - "count": 2, - "name": "Brotherhood's End" + "count": 1, + "name": "Liquimetal Coating" } ] }, { - "name": "Eldrazi", + "name": "Ruby Storm", "author": "MTGGoldfish", "colors": [ - "G", - "R" + "R", + "W" ], "tags": [ "metagame" ], "main": [ { - "count": 1, - "name": "Windswept Heath" - }, - { - "count": 3, - "name": "Forest" - }, - { - "count": 2, - "name": "Ugin's Labyrinth" - }, - { - "count": 2, - "name": "Thought-Knot Seer" + "count": 4, + "name": "Ruby Medallion" }, { "count": 2, - "name": "Blade of the Bloodchief" - }, - { - "count": 1, - "name": "Misty Rainforest" + "name": "Artist's Talent" }, { - "count": 1, - "name": "Dismember" + "count": 3, + "name": "Bloodstained Mire" }, { - "count": 1, - "name": "Stomping Ground" + "count": 3, + "name": "Past in Flames" }, { - "count": 3, - "name": "Basking Broodscale" + "count": 4, + "name": "Mountain" }, { "count": 1, - "name": "Pithing Needle" + "name": "Elegant Parlor" }, { "count": 4, - "name": "Eldrazi Temple" - }, - { - "count": 2, - "name": "Writhing Chrysalis" + "name": "Hex Magic" }, { - "count": 1, - "name": "Verdant Catacombs" + "count": 4, + "name": "Manamorphose" }, { "count": 1, - "name": "Thief of Existence" + "name": "Grapeshot" }, { "count": 1, - "name": "Springleaf Drum" + "name": "Gemstone Caverns" }, { "count": 4, - "name": "Kozilek's Command" + "name": "Desperate Ritual" }, { "count": 4, - "name": "Grove of the Burnwillows" - }, - { - "count": 2, - "name": "Ancient Stirrings" + "name": "Pyretic Ritual" }, { "count": 4, - "name": "Malevolent Rumble" + "name": "Ral, Monsoon Mage" }, { "count": 4, - "name": "Glaring Fleshraker" + "name": "Reckless Impulse" }, { - "count": 2, - "name": "Unholy Heat" + "count": 1, + "name": "Commercial District" }, { "count": 1, - "name": "Commercial District" + "name": "Sacred Foundry" }, { - "count": 2, - "name": "Vexing Bauble" + "count": 1, + "name": "Arid Mesa" }, { "count": 1, - "name": "Haywire Mite" + "name": "Sunbaked Canyon" }, { - "count": 4, - "name": "Emrakul, the Promised End" + "count": 2, + "name": "Valakut Awakening" }, { "count": 2, - "name": "Boseiju, Who Endures" + "name": "Wish" }, { - "count": 4, - "name": "Urza's Saga" - } - ], - "sideboard": [ - { - "count": 1, - "name": "Thought-Knot Seer" + "count": 3, + "name": "Wooded Foothills" }, { - "count": 2, - "name": "Nature's Claim" + "count": 4, + "name": "Wrenn's Resolve" }, { "count": 2, - "name": "Firespout" - }, + "name": "Scalding Tarn" + } + ], + "sideboard": [ { "count": 1, - "name": "Gemstone Caverns" + "name": "Past in Flames" }, { - "count": 1, - "name": "Disruptor Flute" + "count": 3, + "name": "Orim's Chant" }, { "count": 1, - "name": "Writhing Chrysalis" + "name": "Grapeshot" }, { "count": 1, - "name": "Basking Broodscale" + "name": "Empty the Warrens" }, { - "count": 1, - "name": "Thief of Existence" + "count": 4, + "name": "Prismatic Ending" }, { "count": 2, - "name": "Cavern of Souls" - }, - { - "count": 1, - "name": "Grafdigger's Cage" + "name": "Wear // Tear" }, { "count": 1, - "name": "Soul-Guide Lantern" + "name": "Untimely Malfunction" }, { - "count": 1, - "name": "Blade of the Bloodchief" + "count": 2, + "name": "Brotherhood's End" } ] }, @@ -1149,255 +1149,255 @@ ] }, { - "name": "Boros Ponza", + "name": "Mono-Green Eldrazi", "author": "MTGGoldfish", "colors": [ - "R", - "W" + "G" ], "tags": [ "metagame" ], "main": [ { - "count": 4, - "name": "Erode" + "count": 1, + "name": "Gemstone Caverns" }, { - "count": 3, - "name": "High Noon" + "count": 2, + "name": "Ugin, Eye of the Storms" }, { "count": 4, - "name": "Cori Mountain Monastery" + "name": "Slumbering Trudge" }, { "count": 4, - "name": "Demolition Field" + "name": "Ugin's Labyrinth" }, { - "count": 4, - "name": "Price of Freedom" + "count": 2, + "name": "Ulamog, the Ceaseless Hunger" }, { - "count": 3, - "name": "Avengers Disassembled" + "count": 1, + "name": "Dryad Arbor" }, { - "count": 4, - "name": "Sunken Citadel" + "count": 5, + "name": "Forest" }, { - "count": 1, - "name": "Castle Ardenvale" + "count": 4, + "name": "Green Sun's Zenith" }, { "count": 4, - "name": "Field of Ruin" + "name": "Eldrazi Temple" }, { - "count": 2, - "name": "Mountain" + "count": 1, + "name": "Cavern of Souls" }, { - "count": 3, - "name": "Galvanic Discharge" + "count": 4, + "name": "Disciple of Freyalise" }, { "count": 4, - "name": "Cleansing Wildfire" + "name": "Kozilek's Command" }, { - "count": 3, - "name": "Sacred Foundry" + "count": 1, + "name": "Mosswort Bridge" }, { - "count": 1, - "name": "The Legend of Roku" + "count": 4, + "name": "Malevolent Rumble" }, { "count": 4, - "name": "Plains" + "name": "Sowing Mycospawn" }, { "count": 4, - "name": "Solitude" + "name": "Emrakul, the Promised End" + }, + { + "count": 2, + "name": "Drowner of Truth" + }, + { + "count": 1, + "name": "Boseiju, Who Endures" }, { "count": 4, - "name": "Wrath of the Skies" + "name": "Fanatic of Rhonas" }, { "count": 4, - "name": "Path to Exile" + "name": "Fight Rigging" } ], "sideboard": [ { - "count": 1, - "name": "High Noon" + "count": 2, + "name": "Endurance" }, { - "count": 1, - "name": "Surgical Extraction" + "count": 2, + "name": "Thought-Knot Seer" }, { "count": 1, - "name": "Vexing Bauble" + "name": "Gemstone Caverns" }, { - "count": 1, - "name": "Avengers Disassembled" + "count": 3, + "name": "Chalice of the Void" }, { "count": 2, - "name": "Orim's Chant" + "name": "Trinisphere" }, { "count": 3, - "name": "Wear // Tear" - }, - { - "count": 1, - "name": "The Legend of Roku" + "name": "Force of Vigor" }, { "count": 1, - "name": "Wrath of God" - }, - { - "count": 3, - "name": "Rest in Peace" + "name": "Dismember" }, { "count": 1, - "name": "Kaheera, the Orphanguard" + "name": "Elder Gargaroth" } ] }, { - "name": "Mono-Green Eldrazi", + "name": "Boros Ponza", "author": "MTGGoldfish", "colors": [ - "G" + "R", + "W" ], "tags": [ "metagame" ], "main": [ { - "count": 1, - "name": "Gemstone Caverns" + "count": 4, + "name": "Erode" }, { - "count": 2, - "name": "Ugin, Eye of the Storms" + "count": 3, + "name": "High Noon" }, { "count": 4, - "name": "Slumbering Trudge" + "name": "Cori Mountain Monastery" }, { "count": 4, - "name": "Ugin's Labyrinth" - }, - { - "count": 2, - "name": "Ulamog, the Ceaseless Hunger" - }, - { - "count": 1, - "name": "Dryad Arbor" + "name": "Demolition Field" }, { - "count": 5, - "name": "Forest" + "count": 4, + "name": "Price of Freedom" }, { - "count": 4, - "name": "Green Sun's Zenith" + "count": 3, + "name": "Avengers Disassembled" }, { "count": 4, - "name": "Eldrazi Temple" + "name": "Sunken Citadel" }, { "count": 1, - "name": "Cavern of Souls" + "name": "Castle Ardenvale" }, { "count": 4, - "name": "Disciple of Freyalise" + "name": "Field of Ruin" }, { - "count": 4, - "name": "Kozilek's Command" + "count": 2, + "name": "Mountain" }, { - "count": 1, - "name": "Mosswort Bridge" + "count": 3, + "name": "Galvanic Discharge" }, { "count": 4, - "name": "Malevolent Rumble" + "name": "Cleansing Wildfire" }, { - "count": 4, - "name": "Sowing Mycospawn" + "count": 3, + "name": "Sacred Foundry" }, { - "count": 4, - "name": "Emrakul, the Promised End" + "count": 1, + "name": "The Legend of Roku" }, { - "count": 2, - "name": "Drowner of Truth" + "count": 4, + "name": "Plains" }, { - "count": 1, - "name": "Boseiju, Who Endures" + "count": 4, + "name": "Solitude" }, { "count": 4, - "name": "Fanatic of Rhonas" + "name": "Wrath of the Skies" }, { "count": 4, - "name": "Fight Rigging" + "name": "Path to Exile" } ], "sideboard": [ { - "count": 2, - "name": "Endurance" + "count": 1, + "name": "High Noon" }, { - "count": 2, - "name": "Thought-Knot Seer" + "count": 1, + "name": "Surgical Extraction" }, { "count": 1, - "name": "Gemstone Caverns" + "name": "Vexing Bauble" }, { - "count": 3, - "name": "Chalice of the Void" + "count": 1, + "name": "Avengers Disassembled" }, { "count": 2, - "name": "Trinisphere" + "name": "Orim's Chant" }, { "count": 3, - "name": "Force of Vigor" + "name": "Wear // Tear" }, { "count": 1, - "name": "Dismember" + "name": "The Legend of Roku" }, { "count": 1, - "name": "Elder Gargaroth" + "name": "Wrath of God" + }, + { + "count": 3, + "name": "Rest in Peace" + }, + { + "count": 1, + "name": "Kaheera, the Orphanguard" } ] } diff --git a/client/public/feeds/mtggoldfish-pioneer.json b/client/public/feeds/mtggoldfish-pioneer.json index 1f1dda7402..82e28f87e8 100644 --- a/client/public/feeds/mtggoldfish-pioneer.json +++ b/client/public/feeds/mtggoldfish-pioneer.json @@ -5,7 +5,7 @@ "icon": "G", "format": "pioneer", "version": 1, - "updated": "2026-07-28T00:00:00Z", + "updated": "2026-07-29T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/pioneer", "decks": [ { @@ -154,77 +154,173 @@ "metagame" ], "main": [ + { + "count": 4, + "name": "Mutavault" + }, + { + "count": 2, + "name": "Culling Ritual" + }, + { + "count": 4, + "name": "Blooming Marsh" + }, + { + "count": 1, + "name": "Boseiju, Who Endures" + }, { "count": 4, "name": "Llanowar Wastes" }, + { + "count": 4, + "name": "Badgermole Cub" + }, { "count": 1, - "name": "Emeritus of Abundance" + "name": "Go for the Throat" }, { "count": 1, - "name": "Sentinel of the Nameless City" + "name": "Forest" }, { "count": 4, - "name": "Thoughtseize" + "name": "Fatal Push" }, { - "count": 1, - "name": "Wastewood Verge" + "count": 3, + "name": "Graveyard Trespasser" }, { "count": 4, - "name": "Unholy Annex // Ritual Chamber" + "name": "Overgrown Tomb" + }, + { + "count": 4, + "name": "Abrupt Decay" + }, + { + "count": 4, + "name": "Thoughtseize" + }, + { + "count": 3, + "name": "Professor Dellian Fel" }, { "count": 1, "name": "Restless Cottage" }, { - "count": 1, - "name": "Tear Asunder" + "count": 2, + "name": "Sentinel of the Nameless City" }, { "count": 3, "name": "Sheoldred, the Apocalypse" }, + { + "count": 2, + "name": "Swamp" + }, { "count": 1, "name": "Takenuma, Abandoned Mire" }, { "count": 1, - "name": "Boseiju, Who Endures" + "name": "Tear Asunder" + }, + { + "count": 4, + "name": "Unholy Annex // Ritual Chamber" }, { "count": 1, - "name": "Culling Ritual" + "name": "Urborg, Tomb of Yawgmoth" }, { "count": 1, - "name": "Forest" + "name": "Wastewood Verge" }, { "count": 1, - "name": "Overgrown Tomb" + "name": "Swamp" + } + ], + "sideboard": [ + { + "count": 1, + "name": "Culling Ritual" + }, + { + "count": 2, + "name": "Duress" + }, + { + "count": 3, + "name": "Go Blank" + }, + { + "count": 2, + "name": "Invoke Despair" + }, + { + "count": 2, + "name": "Necromentia" + }, + { + "count": 2, + "name": "Ray of Enfeeblement" + }, + { + "count": 2, + "name": "Unlicensed Hearse" }, { "count": 1, - "name": "Urborg, Tomb of Yawgmoth" + "name": "Duress" + } + ] + }, + { + "name": "Abzan Greasefang", + "author": "MTGGoldfish", + "colors": [ + "W", + "B", + "G" + ], + "tags": [ + "metagame" + ], + "main": [ + { + "count": 3, + "name": "Bitter Triumph" }, { "count": 4, - "name": "Mutavault" + "name": "Concealed Courtyard" }, { - "count": 3, - "name": "Abrupt Decay" + "count": 4, + "name": "Fatal Push" + }, + { + "count": 4, + "name": "Cache Grab" }, { "count": 1, - "name": "Go for the Throat" + "name": "Emptiness" + }, + { + "count": 4, + "name": "Esika's Chariot" }, { "count": 4, @@ -232,65 +328,105 @@ }, { "count": 4, - "name": "Fatal Push" + "name": "Formidable Speaker" }, { - "count": 3, - "name": "Graveyard Trespasser" + "count": 4, + "name": "Greasefang, Okiba Boss" }, { - "count": 3, + "count": 1, + "name": "Plains" + }, + { + "count": 1, "name": "Swamp" }, { - "count": 3, - "name": "Professor Dellian Fel" + "count": 1, + "name": "Overlord of the Balemurk" + }, + { + "count": 4, + "name": "Parhelion II" + }, + { + "count": 4, + "name": "Multiversal Passage" + }, + { + "count": 4, + "name": "Professor of Symbology" }, { "count": 1, - "name": "Sentinel of the Nameless City" + "name": "Takenuma, Abandoned Mire" + }, + { + "count": 1, + "name": "Forest" + }, + { + "count": 1, + "name": "Boseiju, Who Endures" }, { "count": 3, - "name": "Overgrown Tomb" + "name": "Thundering Broodwagon" }, { "count": 1, - "name": "Culling Ritual" + "name": "Yathan Roadwatcher" + }, + { + "count": 1, + "name": "Jennifer Walters" + }, + { + "count": 1, + "name": "Caves of Koilos" }, { "count": 4, - "name": "Badgermole Cub" + "name": "Temple Garden" } ], "sideboard": [ { - "count": 2, - "name": "Go Blank" + "count": 1, + "name": "Ral Zarek, Guest Lecturer" }, { - "count": 2, - "name": "Ray of Enfeeblement" + "count": 1, + "name": "Enter the Avatar State" + }, + { + "count": 1, + "name": "Loran of the Third Path" + }, + { + "count": 1, + "name": "Origin of Metalbending" }, { "count": 3, - "name": "Invoke Despair" + "name": "Abrupt Decay" }, { - "count": 2, - "name": "Unlicensed Hearse" + "count": 4, + "name": "Thoughtseize" }, { "count": 1, - "name": "Necromentia" + "name": "True Ancestry" }, { - "count": 3, - "name": "Duress" + "count": 1, + "name": "Professor Dellian Fel" }, { "count": 2, - "name": "Culling Ritual" + "name": "Pest Control" } ] }, @@ -429,12 +565,11 @@ ] }, { - "name": "Abzan Greasefang", + "name": "Azorius Control", "author": "MTGGoldfish", "colors": [ "W", - "B", - "G" + "U" ], "tags": [ "metagame" @@ -442,133 +577,161 @@ "main": [ { "count": 3, - "name": "Bitter Triumph" + "name": "Beza, the Bounding Spring" }, { "count": 4, - "name": "Concealed Courtyard" + "name": "Consult the Star Charts" }, { "count": 4, - "name": "Fatal Push" + "name": "Deserted Beach" }, { "count": 4, - "name": "Cache Grab" + "name": "Dovin's Veto" }, { "count": 1, - "name": "Emptiness" + "name": "Eiganjo, Seat of the Empire" }, { - "count": 4, - "name": "Esika's Chariot" + "count": 1, + "name": "Stock Up" }, { - "count": 4, - "name": "Blooming Marsh" + "count": 1, + "name": "Erode" }, { - "count": 4, - "name": "Formidable Speaker" + "count": 3, + "name": "Plains" }, { - "count": 4, - "name": "Greasefang, Okiba Boss" + "count": 3, + "name": "Island" }, { "count": 1, - "name": "Plains" + "name": "Hall of Storm Giants" }, { - "count": 1, - "name": "Swamp" + "count": 4, + "name": "Hallowed Fountain" }, { - "count": 1, - "name": "Overlord of the Balemurk" + "count": 4, + "name": "Hengegate Pathway" }, { - "count": 4, - "name": "Parhelion II" + "count": 2, + "name": "Island" }, { "count": 4, - "name": "Multiversal Passage" + "name": "March of Otherworldly Light" }, { "count": 4, - "name": "Professor of Symbology" + "name": "No More Lies" }, { - "count": 1, - "name": "Takenuma, Abandoned Mire" + "count": 4, + "name": "Omen of the Sea" }, { "count": 1, - "name": "Forest" + "name": "Otawara, Soaring City" + }, + { + "count": 3, + "name": "Petrified Hamlet" }, { "count": 1, - "name": "Boseiju, Who Endures" + "name": "Plains" }, { "count": 3, - "name": "Thundering Broodwagon" + "name": "Restless Anchorage" + }, + { + "count": 4, + "name": "Get Lost" + }, + { + "count": 4, + "name": "Floodfarm Verge" + }, + { + "count": 3, + "name": "Teferi, Hero of Dominaria" + }, + { + "count": 4, + "name": "Temporary Lockdown" + }, + { + "count": 4, + "name": "The Wandering Emperor" }, { "count": 1, - "name": "Yathan Roadwatcher" + "name": "Castle Ardenvale" }, { "count": 1, - "name": "Jennifer Walters" + "name": "Emeritus of Ideation" }, { "count": 1, - "name": "Caves of Koilos" + "name": "Plains" }, { - "count": 4, - "name": "Temple Garden" + "count": 1, + "name": "Spell Snare" + }, + { + "count": 2, + "name": "Supreme Verdict" } ], "sideboard": [ { "count": 1, - "name": "Ral Zarek, Guest Lecturer" + "name": "Beza, the Bounding Spring" }, { - "count": 1, - "name": "Enter the Avatar State" + "count": 2, + "name": "Change the Equation" }, { - "count": 1, - "name": "Loran of the Third Path" + "count": 2, + "name": "Kutzil's Flanker" }, { - "count": 1, - "name": "Origin of Metalbending" + "count": 2, + "name": "Knockout Blow" }, { "count": 3, - "name": "Abrupt Decay" + "name": "Rest in Peace" }, { - "count": 4, - "name": "Thoughtseize" + "count": 2, + "name": "Shark Typhoon" }, { "count": 1, - "name": "True Ancestry" + "name": "Farewell" }, { "count": 1, - "name": "Professor Dellian Fel" + "name": "Narset's Reversal" }, { - "count": 2, - "name": "Pest Control" + "count": 1, + "name": "Yorion, Sky Nomad" } ] }, @@ -703,153 +866,6 @@ } ] }, - { - "name": "Azorius Control", - "author": "MTGGoldfish", - "colors": [ - "U", - "W" - ], - "tags": [ - "metagame" - ], - "main": [ - { - "count": 4, - "name": "No More Lies" - }, - { - "count": 1, - "name": "Eiganjo, Seat of the Empire" - }, - { - "count": 1, - "name": "Emeritus of Ideation" - }, - { - "count": 2, - "name": "Island" - }, - { - "count": 3, - "name": "The Wandering Emperor" - }, - { - "count": 1, - "name": "Hall of Storm Giants" - }, - { - "count": 3, - "name": "Plains" - }, - { - "count": 2, - "name": "Supreme Verdict" - }, - { - "count": 4, - "name": "March of Otherworldly Light" - }, - { - "count": 2, - "name": "Fountainport" - }, - { - "count": 1, - "name": "Otawara, Soaring City" - }, - { - "count": 1, - "name": "Petrified Hamlet" - }, - { - "count": 2, - "name": "Restless Anchorage" - }, - { - "count": 2, - "name": "Teferi, Hero of Dominaria" - }, - { - "count": 2, - "name": "Dovin's Veto" - }, - { - "count": 1, - "name": "Three Steps Ahead" - }, - { - "count": 4, - "name": "Floodfarm Verge" - }, - { - "count": 3, - "name": "Deserted Beach" - }, - { - "count": 4, - "name": "Hallowed Fountain" - }, - { - "count": 1, - "name": "Farewell" - }, - { - "count": 3, - "name": "Beza, the Bounding Spring" - }, - { - "count": 4, - "name": "Get Lost" - }, - { - "count": 2, - "name": "Meticulous Archive" - }, - { - "count": 1, - "name": "Change the Equation" - }, - { - "count": 4, - "name": "Consult the Star Charts" - }, - { - "count": 2, - "name": "Narset, Parter of Veils" - } - ], - "sideboard": [ - { - "count": 2, - "name": "Dovin's Veto" - }, - { - "count": 2, - "name": "Mystical Dispute" - }, - { - "count": 2, - "name": "Rest in Peace" - }, - { - "count": 3, - "name": "Kutzil's Flanker" - }, - { - "count": 2, - "name": "Knockout Blow" - }, - { - "count": 2, - "name": "Get Out" - }, - { - "count": 2, - "name": "Temporary Lockdown" - } - ] - }, { "name": "Orzhov Greasefang", "author": "MTGGoldfish", @@ -875,220 +891,89 @@ }, { "count": 4, - "name": "Fatal Push" - }, - { - "count": 4, - "name": "Fleeting Spirit" - }, - { - "count": 1, - "name": "Swamp" - }, - { - "count": 4, - "name": "Greasefang, Okiba Boss" - }, - { - "count": 4, - "name": "Guardian of New Benalia" - }, - { - "count": 4, - "name": "Iron-Shield Elf" - }, - { - "count": 1, - "name": "Lively Dirge" - }, - { - "count": 4, - "name": "Monument to Endurance" - }, - { - "count": 4, - "name": "Parhelion II" - }, - { - "count": 1, - "name": "Plains" - }, - { - "count": 4, - "name": "Sheltered by Ghosts" - }, - { - "count": 1, - "name": "Plains" - }, - { - "count": 1, - "name": "Takenuma, Abandoned Mire" - }, - { - "count": 4, - "name": "The Mycosynth Gardens" - }, - { - "count": 4, - "name": "Godless Shrine" - }, - { - "count": 4, - "name": "Concealed Courtyard" - }, - { - "count": 1, - "name": "Urborg, Tomb of Yawgmoth" - } - ], - "sideboard": [ - { - "count": 4, - "name": "Doorkeeper Thrull" - }, - { - "count": 4, - "name": "Soul-Guide Lantern" - }, - { - "count": 4, - "name": "Vanishing Verse" - }, - { - "count": 3, - "name": "Pest Control" - } - ] - }, - { - "name": "Izzet Phoenix", - "author": "MTGGoldfish", - "colors": [ - "U", - "R" - ], - "tags": [ - "metagame" - ], - "main": [ - { - "count": 4, - "name": "Arclight Phoenix" - }, - { - "count": 4, - "name": "Artist's Talent" - }, - { - "count": 4, - "name": "Consider" - }, - { - "count": 3, - "name": "Demilich" - }, - { - "count": 3, - "name": "Fiery Impulse" - }, - { - "count": 3, - "name": "Into the Flood Maw" - }, - { - "count": 1, - "name": "Jwari Disruption" - }, - { - "count": 4, - "name": "Sleight of Hand" - }, - { - "count": 4, - "name": "Opt" - }, - { - "count": 3, - "name": "Island" + "name": "Fatal Push" }, { "count": 4, - "name": "Picklock Prankster" + "name": "Fleeting Spirit" }, { "count": 1, - "name": "Proft's Eidetic Memory" + "name": "Swamp" }, { "count": 4, - "name": "Riverglide Pathway" + "name": "Greasefang, Okiba Boss" }, { - "count": 2, - "name": "Shivan Reef" + "count": 4, + "name": "Guardian of New Benalia" }, { - "count": 1, - "name": "Hall of Storm Giants" + "count": 4, + "name": "Iron-Shield Elf" }, { - "count": 3, - "name": "Lightning Axe" + "count": 1, + "name": "Lively Dirge" }, { "count": 4, - "name": "Treasure Cruise" + "name": "Monument to Endurance" }, { "count": 4, - "name": "Spirebluff Canal" + "name": "Parhelion II" }, - { - "count": 4, - "name": "Steam Vents" - } - ], - "sideboard": [ { "count": 1, - "name": "Flowstone Infusion" + "name": "Plains" + }, + { + "count": 4, + "name": "Sheltered by Ghosts" }, { "count": 1, - "name": "Thassa's Oracle" + "name": "Plains" }, { "count": 1, - "name": "Jace, Wielder of Mysteries" + "name": "Takenuma, Abandoned Mire" }, { - "count": 2, - "name": "Mystical Dispute" + "count": 4, + "name": "The Mycosynth Gardens" }, { - "count": 2, - "name": "Annul" + "count": 4, + "name": "Godless Shrine" }, { - "count": 3, - "name": "Spell Pierce" + "count": 4, + "name": "Concealed Courtyard" }, { "count": 1, - "name": "Pyroclasm" + "name": "Urborg, Tomb of Yawgmoth" + } + ], + "sideboard": [ + { + "count": 4, + "name": "Doorkeeper Thrull" }, { - "count": 1, - "name": "Into the Flood Maw" + "count": 4, + "name": "Soul-Guide Lantern" }, { - "count": 2, - "name": "Chandra's Defeat" + "count": 4, + "name": "Vanishing Verse" }, { - "count": 1, - "name": "Negate" + "count": 3, + "name": "Pest Control" } ] }, @@ -1244,10 +1129,11 @@ ] }, { - "name": "Mono-Black Midrange", + "name": "Izzet Phoenix", "author": "MTGGoldfish", "colors": [ - "B" + "U", + "R" ], "tags": [ "metagame" @@ -1255,149 +1141,256 @@ "main": [ { "count": 4, - "name": "Unholy Annex // Ritual Chamber" + "name": "Arclight Phoenix" + }, + { + "count": 4, + "name": "Artist's Talent" + }, + { + "count": 4, + "name": "Consider" }, { "count": 3, - "name": "Castle Locthwain" + "name": "Demilich" }, { - "count": 1, - "name": "Cecil, Dark Knight" + "count": 3, + "name": "Fiery Impulse" }, { - "count": 7, - "name": "Swamp" + "count": 3, + "name": "Into the Flood Maw" }, { "count": 1, - "name": "End of the Hunt" + "name": "Jwari Disruption" }, { "count": 4, - "name": "Fatal Push" + "name": "Sleight of Hand" }, { - "count": 2, - "name": "Field of Ruin" + "count": 4, + "name": "Opt" + }, + { + "count": 3, + "name": "Island" }, { "count": 4, - "name": "Gifted Aetherborn" + "name": "Picklock Prankster" }, { "count": 1, - "name": "Go Blank" + "name": "Proft's Eidetic Memory" }, { - "count": 7, - "name": "Swamp" + "count": 4, + "name": "Riverglide Pathway" }, { - "count": 3, - "name": "Graveyard Trespasser" + "count": 2, + "name": "Shivan Reef" }, { "count": 1, - "name": "Invoke Despair" + "name": "Hall of Storm Giants" + }, + { + "count": 3, + "name": "Lightning Axe" }, { "count": 4, - "name": "Mutavault" + "name": "Treasure Cruise" }, + { + "count": 4, + "name": "Spirebluff Canal" + }, + { + "count": 4, + "name": "Steam Vents" + } + ], + "sideboard": [ { "count": 1, - "name": "Morlun, Devourer of Spiders" + "name": "Flowstone Infusion" }, { "count": 1, - "name": "Liliana of the Veil" + "name": "Thassa's Oracle" }, { "count": 1, - "name": "Preacher of the Schism" + "name": "Jace, Wielder of Mysteries" }, { "count": 2, - "name": "Sheoldred, the Apocalypse" + "name": "Mystical Dispute" }, { - "count": 1, - "name": "Go for the Throat" + "count": 2, + "name": "Annul" }, { - "count": 1, - "name": "Takenuma, Abandoned Mire" + "count": 3, + "name": "Spell Pierce" }, { "count": 1, - "name": "The Meathook Massacre" + "name": "Pyroclasm" }, { "count": 1, - "name": "The Soul Stone" + "name": "Into the Flood Maw" }, { "count": 2, - "name": "Duress" + "name": "Chandra's Defeat" }, { "count": 1, - "name": "Urborg, Tomb of Yawgmoth" + "name": "Negate" + } + ] + }, + { + "name": "Dimir Self-Bounce", + "author": "MTGGoldfish", + "colors": [ + "U", + "B" + ], + "tags": [ + "metagame" + ], + "main": [ + { + "count": 4, + "name": "Boomerang Basics" }, { "count": 4, - "name": "Thoughtseize" + "name": "Clearwater Pathway" }, { - "count": 2, - "name": "Bitterbloom Bearer" - } - ], - "sideboard": [ + "count": 4, + "name": "Cryogen Relic" + }, { - "count": 1, - "name": "Duress" + "count": 4, + "name": "Narset, Parter of Veils" }, { - "count": 1, - "name": "Damping Sphere" + "count": 4, + "name": "Watery Grave" }, { - "count": 1, - "name": "End of the Hunt" + "count": 4, + "name": "Fear of Isolation" }, { - "count": 1, - "name": "Extinction Event" + "count": 4, + "name": "Gloomlake Verge" + }, + { + "count": 4, + "name": "Hopeless Nightmare" + }, + { + "count": 4, + "name": "Momentum Breaker" + }, + { + "count": 4, + "name": "Nowhere to Run" }, { "count": 1, - "name": "Go Blank" + "name": "Otawara, Soaring City" + }, + { + "count": 4, + "name": "Shipwreck Marsh" + }, + { + "count": 4, + "name": "Stormchaser's Talent" }, { "count": 2, - "name": "Invoke Despair" + "name": "Swamp" }, { "count": 2, - "name": "Necromentia" + "name": "Stock Up" + }, + { + "count": 4, + "name": "This Town Ain't Big Enough" }, { "count": 2, - "name": "Ray of Enfeeblement" + "name": "Underground River" }, { - "count": 1, - "name": "Strategic Betrayal" + "count": 4, + "name": "Darkslick Shores" + }, + { + "count": 2, + "name": "Island" + }, + { + "count": 2, + "name": "Urborg, Tomb of Yawgmoth" + }, + { + "count": 4, + "name": "Petrified Hamlet" + }, + { + "count": 4, + "name": "Fatal Push" }, { "count": 1, - "name": "The Meathook Massacre" + "name": "Takenuma, Abandoned Mire" }, + { + "count": 4, + "name": "Thoughtseize" + } + ], + "sideboard": [ { "count": 2, + "name": "The Meathook Massacre" + }, + { + "count": 4, + "name": "Change the Equation" + }, + { + "count": 1, + "name": "Yorion, Sky Nomad" + }, + { + "count": 3, "name": "Unlicensed Hearse" + }, + { + "count": 3, + "name": "Invoke Despair" + }, + { + "count": 2, + "name": "Bitter Triumph" } ] } diff --git a/client/public/feeds/mtggoldfish-standard.json b/client/public/feeds/mtggoldfish-standard.json index 24f2bb2830..10232f121e 100644 --- a/client/public/feeds/mtggoldfish-standard.json +++ b/client/public/feeds/mtggoldfish-standard.json @@ -5,7 +5,7 @@ "icon": "G", "format": "standard", "version": 1, - "updated": "2026-07-28T00:00:00Z", + "updated": "2026-07-29T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/standard", "decks": [ { @@ -23,36 +23,64 @@ "count": 3, "name": "Abandoned Air Temple" }, + { + "count": 1, + "name": "Ba Sing Se" + }, + { + "count": 1, + "name": "Forest" + }, + { + "count": 2, + "name": "Brightglass Gearhulk" + }, { "count": 1, "name": "Bushwhack" }, { - "count": 6, + "count": 1, "name": "Forest" }, + { + "count": 4, + "name": "Hushwood Verge" + }, + { + "count": 2, + "name": "Ouroboroid" + }, { "count": 4, "name": "Leatherhead, Swamp Stalker" }, + { + "count": 2, + "name": "Llanowar Elves" + }, { "count": 1, "name": "Meltstrider's Resolve" }, { - "count": 2, - "name": "Multiversal Passage" + "count": 1, + "name": "Forest" }, { "count": 2, - "name": "Ouroboroid" + "name": "Pawpatch Recruit" }, { - "count": 4, + "count": 2, "name": "Pawpatch Recruit" }, { "count": 4, + "name": "Badgermole Cub" + }, + { + "count": 2, "name": "Practiced Offense" }, { @@ -60,50 +88,82 @@ "name": "Seam Rip" }, { - "count": 3, + "count": 1, "name": "Surrak, Elusive Hunter" }, { - "count": 3, - "name": "Spider Manifestation" + "count": 1, + "name": "Temple Garden" }, { "count": 1, - "name": "Ba Sing Se" + "name": "Forest" + }, + { + "count": 1, + "name": "Forest" + }, + { + "count": 1, + "name": "Forest" }, { "count": 2, + "name": "Llanowar Elves" + }, + { + "count": 2, + "name": "Multiversal Passage" + }, + { + "count": 1, "name": "Plains" }, { - "count": 4, - "name": "Temple Garden" + "count": 1, + "name": "Plains" }, { - "count": 4, - "name": "Hushwood Verge" + "count": 2, + "name": "Surrak, Elusive Hunter" }, { "count": 2, - "name": "Jennifer Walters" + "name": "Temple Garden" }, { - "count": 4, - "name": "Llanowar Elves" + "count": 1, + "name": "Temple Garden" }, { - "count": 4, - "name": "Badgermole Cub" + "count": 1, + "name": "Brightglass Gearhulk" }, { - "count": 4, + "count": 1, "name": "Brightglass Gearhulk" + }, + { + "count": 2, + "name": "Jennifer Walters" + }, + { + "count": 2, + "name": "Practiced Offense" + }, + { + "count": 3, + "name": "Spider Manifestation" } ], "sideboard": [ { - "count": 1, - "name": "Soul-Guide Lantern" + "count": 2, + "name": "Dawn's Truce" + }, + { + "count": 2, + "name": "Rest in Peace" }, { "count": 2, @@ -114,27 +174,32 @@ "name": "Meltstrider's Resolve" }, { - "count": 3, - "name": "Dawn's Truce" + "count": 2, + "name": "Elspeth, Storm Slayer" }, { "count": 3, "name": "Sheltered by Ghosts" }, { - "count": 3, + "count": 1, + "name": "Dawn's Truce" + }, + { + "count": 1, "name": "Rest in Peace" }, { - "count": 2, - "name": "Elspeth, Storm Slayer" + "count": 1, + "name": "Soul-Guide Lantern" } ] }, { - "name": "Izzet Prowess", + "name": "Jeskai Lessons", "author": "MTGGoldfish", "colors": [ + "W", "U", "R" ], @@ -142,69 +207,73 @@ "metagame" ], "main": [ + { + "count": 1, + "name": "Mistrise Village" + }, { "count": 4, - "name": "Boomerang Basics" + "name": "Gran-Gran" }, { "count": 1, - "name": "Bounce Off" + "name": "Three Steps Ahead" }, { - "count": 4, - "name": "Burst Lightning" + "count": 1, + "name": "Flashback" }, { - "count": 2, - "name": "Colorstorm Stallion" + "count": 4, + "name": "Great Hall of the Biblioplex" }, { "count": 3, - "name": "Eddymurk Crab" + "name": "Hallowed Fountain" }, { "count": 4, - "name": "Flow State" + "name": "Jeskai Revelation" }, { - "count": 1, - "name": "Impractical Joke" + "count": 2, + "name": "Sundown Pass" }, { - "count": 7, + "count": 4, + "name": "Combustion Technique" + }, + { + "count": 3, "name": "Island" }, { "count": 2, - "name": "Multiversal Passage" + "name": "Iroh's Demonstration" }, { "count": 4, - "name": "Opt" + "name": "Firebending Lesson" }, { "count": 4, - "name": "Riverpyre Verge" - }, - { - "count": 1, - "name": "Roaring Furnace // Steaming Sauna" + "name": "Accumulate Wisdom" }, { - "count": 4, - "name": "Sleight of Hand" + "count": 3, + "name": "Stock Up" }, { "count": 4, - "name": "Slickshot Show-Off" + "name": "Abandon Attachments" }, { "count": 2, - "name": "Spell Pierce" + "name": "It'll Quench Ya!" }, { - "count": 4, - "name": "Spirebluff Canal" + "count": 3, + "name": "Riverpyre Verge" }, { "count": 4, @@ -212,69 +281,56 @@ }, { "count": 4, - "name": "Stormchaser's Talent" + "name": "Tablet of Discovery" }, { - "count": 1, - "name": "Vibrant Outburst" + "count": 3, + "name": "Spirebluff Canal" } ], "sideboard": [ { "count": 1, - "name": "Annul" - }, - { - "count": 1, - "name": "Broadside Barrage" - }, - { - "count": 1, - "name": "Colorstorm Stallion" - }, - { - "count": 1, - "name": "Eddymurk Crab" + "name": "Disdainful Stroke" }, { "count": 1, "name": "Flashfreeze" }, { - "count": 1, - "name": "Get Out" + "count": 4, + "name": "Price of Freedom" }, { "count": 1, - "name": "Negate" + "name": "Avengers Disassembled" }, { "count": 1, - "name": "Pyroclasm" + "name": "Ghost Vacuum" }, { "count": 2, - "name": "Ral, Crackling Wit" + "name": "Day of Judgment" }, { - "count": 1, - "name": "Sear" + "count": 2, + "name": "Spell Pierce" }, { "count": 2, - "name": "Slagstorm" + "name": "Emeritus of Ideation" }, { - "count": 2, - "name": "Soul-Guide Lantern" + "count": 1, + "name": "Ral, Crackling Wit" } ] }, { - "name": "Jeskai Lessons", + "name": "Izzet Prowess", "author": "MTGGoldfish", "colors": [ - "W", "U", "R" ], @@ -282,73 +338,69 @@ "metagame" ], "main": [ - { - "count": 1, - "name": "Mistrise Village" - }, { "count": 4, - "name": "Gran-Gran" + "name": "Boomerang Basics" }, { "count": 1, - "name": "Three Steps Ahead" + "name": "Bounce Off" }, { - "count": 1, - "name": "Flashback" + "count": 4, + "name": "Burst Lightning" }, { - "count": 4, - "name": "Great Hall of the Biblioplex" + "count": 2, + "name": "Colorstorm Stallion" }, { "count": 3, - "name": "Hallowed Fountain" + "name": "Eddymurk Crab" }, { "count": 4, - "name": "Jeskai Revelation" - }, - { - "count": 2, - "name": "Sundown Pass" + "name": "Flow State" }, { - "count": 4, - "name": "Combustion Technique" + "count": 1, + "name": "Impractical Joke" }, { - "count": 3, + "count": 7, "name": "Island" }, { "count": 2, - "name": "Iroh's Demonstration" + "name": "Multiversal Passage" }, { "count": 4, - "name": "Firebending Lesson" + "name": "Opt" }, { "count": 4, - "name": "Accumulate Wisdom" + "name": "Riverpyre Verge" }, { - "count": 3, - "name": "Stock Up" + "count": 1, + "name": "Roaring Furnace // Steaming Sauna" }, { "count": 4, - "name": "Abandon Attachments" + "name": "Sleight of Hand" + }, + { + "count": 4, + "name": "Slickshot Show-Off" }, { "count": 2, - "name": "It'll Quench Ya!" + "name": "Spell Pierce" }, { - "count": 3, - "name": "Riverpyre Verge" + "count": 4, + "name": "Spirebluff Canal" }, { "count": 4, @@ -356,49 +408,61 @@ }, { "count": 4, - "name": "Tablet of Discovery" + "name": "Stormchaser's Talent" }, { - "count": 3, - "name": "Spirebluff Canal" + "count": 1, + "name": "Vibrant Outburst" } ], "sideboard": [ { "count": 1, - "name": "Disdainful Stroke" + "name": "Annul" + }, + { + "count": 1, + "name": "Broadside Barrage" + }, + { + "count": 1, + "name": "Colorstorm Stallion" + }, + { + "count": 1, + "name": "Eddymurk Crab" }, { "count": 1, "name": "Flashfreeze" }, { - "count": 4, - "name": "Price of Freedom" + "count": 1, + "name": "Get Out" }, { "count": 1, - "name": "Avengers Disassembled" + "name": "Negate" }, { "count": 1, - "name": "Ghost Vacuum" + "name": "Pyroclasm" }, { "count": 2, - "name": "Day of Judgment" + "name": "Ral, Crackling Wit" }, { - "count": 2, - "name": "Spell Pierce" + "count": 1, + "name": "Sear" }, { "count": 2, - "name": "Emeritus of Ideation" + "name": "Slagstorm" }, { - "count": 1, - "name": "Ral, Crackling Wit" + "count": 2, + "name": "Soul-Guide Lantern" } ] }, From 3920a0f81aa3ca8dbbb42a40e7cc80d4da9c6058 Mon Sep 17 00:00:00 2001 From: minion1227 Date: Wed, 29 Jul 2026 02:42:47 -0700 Subject: [PATCH 04/63] feat(phase-ai): add cost-reduction deck-feature axis + CostReductionPolicy (#6743) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(phase-ai): add cost-reduction deck-feature axis + CostReductionPolicy `features::mana_ramp` explicitly deferred this shape — "`StaticMode::ModifyCost` is deliberately out of scope — cost reducers are a follow-up feature". This is that follow-up. A Goblin Electromancer / Baral / Foundry Inspector / Medallion effect is acceleration that never taps for mana: every later spell costs less for as long as the permanent survives (CR 601.2f). The engine already applies the discount at cast time, so the AI is never overcharged — but nothing valued *deploying* the reducer. `mana_ramp` only sees effects that add mana, so a deck whose whole acceleration plan is cost reduction read as having no ramp at all. Feature (`features/cost_reduction.rs`), structural over `CardFace` AST: - `reducer_count` — cards carrying a board-wide `ModifyCost { Reduce }` that applies to spells YOU cast. Mirrors the eligibility tests `casting::collect_cost_modifiers` applies at cast time, so deck-time classification and the resolver agree by construction: `Reduce` only (`Raise` is Thalia, `Minimum` is Trinisphere), never a `SelfRef` self-cost reduction (CR 113.6), never opponent-scoped. - `total_discount` — summed generic discount. CR 118.7a: a generic reduction affects only the generic component, so the magnitude is `generic`, not the amount's full mana value. - `discounted_count` / coverage — deck cards a reducer's `spell_filter` actually admits, delegating every type leaf to the engine's CR 205 authority (`matches_type_filter_against_face`, widened to `pub`) rather than re-deriving type semantics. An unverifiable filter reports no coverage, so the axis fails OFF rather than claiming a discount the deck may not get. - `commitment` — geometric mean over (reducer density, coverage): both pillars mandatory. Reducers that discount nothing are blanks; spells with no reducer are just spells. Policy (`policies/cost_reduction.rs`, `CastSpell`): - `cost_reduction_deploy_engine` — credit for deploying a reducer, scaled by capped saved mana across the remaining grip. - `cost_reduction_defer_to_engine` — nudge against casting past an unplayed, cheaper reducer (the `RampTimingPolicy::defer_to_ramp` shape). - The card-local static check runs first, so every non-reducer candidate is rejected after reading one card's AST; only a confirmed reducer pays for the hand walk. No battlefield sweep, no `find_legal_targets`, no affordability query in the search inner loop. Both scoring scalars land in `UNTUNED_POLICY_PENALTY_FIELDS` pending a paired-seed calibration. 33 tests: per-axis detection, both calibration anchors, every exclusion (`Raise`/`Minimum`/`SelfRef`/opponent-scope/zero-generic/unverifiable-filter), format-size neutrality, the bounded-score ceiling, and a registry-routed regression covering registration + `CastSpell` routing. Co-Authored-By: Claude Opus 5 (1M context) * docs(phase-ai): correct the cited cost-modifier authority and record two elided assumptions Self-review of the cost-reduction axis before final submission: - The doc comments named `casting::collect_cost_modifiers`; the actual engine authority is `casting::collect_battlefield_cost_modifiers` (crates/engine/src/game/casting.rs:7179). A citation that does not resolve is worse than none — it claims a verification that never happened. - `your_spell_discount` silently ignored `dynamic_count`. Record that this is deliberate: a scaling reducer's multiplier is unknowable at deck-analysis time, so the per-unit `generic` is reported, understating rather than inventing a value. - `castable_cards_in_hand` counts remaining casts without narrowing to the reducer's `spell_filter`. Record why that is correct rather than sloppy: the narrowing is applied once per game through `commitment`, which `activation` multiplies in, instead of per candidate in the search inner loop. Documentation only — no behavior change; the 33 tests are untouched and green. Co-Authored-By: Claude Opus 5 (1M context) * fix(phase-ai): make the casting authority own cost-modifier eligibility Addresses the review on #6743. Every MED shared one root cause — phase-ai MIRRORED `collect_battlefield_cost_modifiers`'s eligibility instead of sharing it — so this fixes the ownership rather than patching each symptom. Engine (new single authority): - `StaticDefinition::board_wide_cost_modifier()` + `CostModifierCasterScope` (types/ability.rs) is now the one structural definition of "is this a board-wide cost modifier and what are its terms": rejects `Minimum` and `SelfRef` (CR 113.6), and names the caster scope so a caller WITH a PlayerId (the casting pipeline) and one WITHOUT (deck analysis) ask the same question. `collect_battlefield_cost_modifiers` now consumes it, so the two paths cannot drift again. - `matches_target_filter_against_face_scoped` + `context_free_prop_matches_face` (game/filter.rs) extend the CR 205 face authority to every context-free `FilterProp` — mana value (CR 202.3), color (CR 105.2), keyword, supertype, token-ness — and return `Option` so a live-only property is *unanswerable* and fails closed by construction rather than by a caller remembering to. `FaceControllerScope` names the previously-implicit controller assumption. phase-ai (consumes, no longer mirrors): - `your_spell_discount` reads the engine authority; the hand-rolled type-axis matcher is deleted. Color-, mana-value- and keyword-scoped reducers now produce coverage instead of silently reading as discounting nothing. - New `live_your_spell_discounts` resolves what a structural read cannot: `condition` fails off (a card in hand has no truthful "as long as" answer), and `dynamic_count` is resolved through the engine's `resolve_quantity`, so a multiplier of zero earns no credit and a multiplier above one scales it. - The policy counts and penalizes only spells a reducer actually discounts, matched through the engine's live `matches_target_filter` with the reducer as filter source. - `PolicyId::DrawPayoff` gets its CR 121.1 doc comment back; `CostReduction` gets its own verified CR 601.2f one. Tests 33 -> 44. Six new regressions, each proven RED by breaking the specific invariant it guards: color/mana-value/negated-color coverage, live-only property failing closed, controller-scoped filter admitted, filter-narrowed deploy credit and defer penalty, conditional reducer earning nothing, and zero vs positive dynamic multipliers. Co-Authored-By: Claude Opus 5 (1M context) * fix(engine): annotate the context-free face keyword reads for the authority gate CI's `Engine authority gate` (scripts/check-engine-authorities.sh) rejects raw `obj.keywords.contains(..)` in new engine code, because a raw query silently misses off-zone keyword grants that only `keywords::object_has_effective_keyword_kind(state, id, kind)` can see. `context_free_prop_matches_face` is the structural exception the gate documents. Its whole contract is that NO `GameObject` exists — it evaluates a bare `CardFace` outside the game — so there is no zone, no grant, and no `off_zone_characteristics` to consult, and the printed keyword line is the complete truth. The keyword authorities all require a `GameObject` this function by definition cannot have; a caller holding an `ObjectId` is already directed to the object-based `matches_target_filter` family by the module docs. Annotated with `allow-raw-authority:` and the reason, per the gate's own instructions, rather than reshaping the call to defeat the check. Verified: `./scripts/check-engine-authorities.sh upstream/main` exits 0, as do `cargo fmt --all -- --check`, `check-interaction-bindings.sh --check`, `check-prelowered-ratchet.sh`, `check-skill-doc.sh` and `check-resolution-frame-boundaries.sh` — the other gates in the same CI job. Co-Authored-By: Claude Opus 5 (1M context) * fix(engine): three-valued OR for FilterProp::AnyOf against a bare face Addresses the round-3 blocker on #6743. `context_free_prop_matches_face` returns `Option` where `None` means "this property needs live game state and has no face-level answer". The `AnyOf` arm folded with `try_fold`, which short-circuits on the first `None` — so a disjunction mixing a definite match with an unknowable alternative collapsed to unknown in BOTH orders: [Some(true), None] -> None (the true was already found, then discarded) [None, Some(true)] -> None (stopped before reaching the true) `matches_target_filter_against_face_scoped` admits only `Some(true)`, so a spell filter with a definitely-matching alternative was treated as non-matching purely because a sibling alternative referenced live state. Replaced with a proper CR 608.2b Kleene OR: short-circuit on the first definite `true`; return `None` only when nothing is true AND something is unknown; otherwise `false`. Negation already had the correct behavior — `None.map(!)` is `None` — and now has a regression pinning it. Eight tests covering the full truth table, including both orderings the review called out and an end-to-end case through the typed-filter caller (the production entry point that admits only `Some(true)`). Three are proven RED against the old `try_fold`: `any_of_true_then_unknown_is_true`, `any_of_unknown_then_true_is_true`, and `tri_state_or_reaches_the_typed_filter_caller`. The other five pin the rest of the table and pass either way by design. Verified: engine `game::filter` 149 passed, phase-ai cost_reduction 44 passed, `cargo clippy -p engine -p phase-ai --all-targets --features proptest -- -D warnings` exit 0, `check-engine-authorities.sh` exit 0. Co-Authored-By: Claude Opus 5 (1M context) * fix(engine): keep "unknown" unknown through face-filter negation Addresses the two round-4 blockers on #6743. [HIGH] `TargetFilter::Not` turned an unanswerable predicate into a match. The `Typed` arm collapsed every non-`Some(true)` property to `false` before returning, so an outer `Not` inverted it: a bare face evaluated against `Not(Tapped)` — whose only predicate needs a battlefield object — was ACCEPTED, admitting the wrong card class through a filter that should have failed closed. The same inversion applied to an unanswerable controller scope, and to the `_ => false` arm covering `SelfRef` and the player-scoped variants. Fixed by threading the third state through the whole walk instead of collapsing early: `target_filter_face_state` returns `Option` across `Typed`/`Or`/`And`/`Not`, combining with `kleene_and` / `kleene_or`, and `matches_target_filter_against_face_scoped` collapses to a bool exactly once at the public boundary. Unanswerable filter variants now yield `None` rather than a definite `false`, so negating them cannot manufacture a match. One asymmetry is deliberate: under `FaceControllerScope::AssumeOwn` the caller has asserted the face is the querying player's own card, so `ControllerRef::Opponent` is definitely FALSE (not unknown) and its negation is a legitimate definite match. Encoding that as `Some(false)` keeps the fix from over-correcting into "every controller scope is unknowable". [MED] Removed the fabricated `CR 608.2b` citations from the Kleene-OR comment and its test heading. Verified against docs/MagicCompRules.txt:2789: 608.2b governs checking target legality as a spell or ability resolves, and has nothing to do with Boolean disjunction. Three-valued logic here is an implementation property of the `Option` answer, not a rules behavior, so it carries no CR annotation now. The four other 608.2b references in this file are pre-existing and untouched. Eight production-entry regressions through `matches_target_filter_against_face_scoped`, covering `Not` around: a live-only property, an unknown controller scope, an unanswerable filter variant, a definite false, and a definite true; plus opponent-scope-under-AssumeOwn, AND with an unknown branch, and OR with a definite true beside an unknown. Proven RED against the old collapsed form: `not_around_live_only_property`, `not_around_unknown_controller_scope`, and `not_around_an_unanswerable_filter_variant` all fail when `Not` is restored to negating the boolean; the two definite-answer cases correctly stay green. Verified: engine `game::filter` 157 passed, phase-ai cost_reduction 44 passed, `cargo clippy -p engine -p phase-ai --all-targets --features proptest -- -D warnings` exit 0, `check-engine-authorities.sh` exit 0. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Matt Evans <1388610+matthewevans@users.noreply.github.com> --- crates/engine/src/game/casting.rs | 62 +- crates/engine/src/game/filter.rs | 557 ++++++++++++++- crates/engine/src/types/ability.rs | 106 +++ crates/phase-ai/src/config.rs | 25 + .../phase-ai/src/features/cost_reduction.rs | 324 +++++++++ crates/phase-ai/src/features/mana_ramp.rs | 6 +- crates/phase-ai/src/features/mod.rs | 6 + .../src/features/tests/cost_reduction.rs | 500 ++++++++++++++ crates/phase-ai/src/features/tests/mod.rs | 1 + .../phase-ai/src/policies/cost_reduction.rs | 217 ++++++ crates/phase-ai/src/policies/mod.rs | 1 + crates/phase-ai/src/policies/registry.rs | 4 + .../src/policies/tests/cost_reduction.rs | 651 ++++++++++++++++++ crates/phase-ai/src/policies/tests/mod.rs | 1 + 14 files changed, 2404 insertions(+), 57 deletions(-) create mode 100644 crates/phase-ai/src/features/cost_reduction.rs create mode 100644 crates/phase-ai/src/features/tests/cost_reduction.rs create mode 100644 crates/phase-ai/src/policies/cost_reduction.rs create mode 100644 crates/phase-ai/src/policies/tests/cost_reduction.rs diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 46b566b43e..3d19b3e94e 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -1,8 +1,8 @@ use crate::types::ability::{ is_variable_remove_counter_cost_count, AbilityBlockKind, AbilityBlockReason, AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, AbilityTag, ActivationManaPaymentRestriction, - AdditionalCost, CardPlayMode, CardSelectionMode, CastTimingPermission, CastingPermission, - ChoiceType, ContinuousModification, CostObjectCount, CostPaidObjectSnapshot, + AdditionalCost, BoardWideCostModifier, CardPlayMode, CardSelectionMode, CastTimingPermission, + CastingPermission, ChoiceType, ContinuousModification, CostObjectCount, CostPaidObjectSnapshot, CounterCostSelection, Duration, Effect, EffectKind, FilterProp, GameRestriction, ModalSelectionCondition, ObjectScope, PlayerFilter, PlayerScope, ProhibitedActivity, QuantityExpr, QuantityRef, ResolvedAbility, RestrictionExpiry, RestrictionPlayerScope, @@ -7192,8 +7192,6 @@ fn collect_battlefield_cost_modifiers( target_sensitive_only: bool, casting_variant: Option, ) -> Vec { - use crate::types::ability::ControllerRef; - // CR 202.3d + CR 702.102b: a pre-payment `CastingVariant::Fuse` cast presents // the COMBINED characteristics of both halves to a `ModifyCost` static's // `spell_filter`. The `fused_split_spell` marker is not yet set at this seam. @@ -7227,25 +7225,27 @@ fn collect_battlefield_cost_modifiers( let source_controller = src_obj.controller; { - let (amount, spell_filter, dynamic_count, is_raise) = match &def.mode { - StaticMode::ModifyCost { - mode: CostModifyMode::Reduce, - amount, - spell_filter, - dynamic_count, - } => (amount, spell_filter, dynamic_count, false), - StaticMode::ModifyCost { - mode: CostModifyMode::Raise, - amount, - spell_filter, - dynamic_count, - } => (amount, spell_filter, dynamic_count, true), - _ => continue, + // CR 601.2f + CR 113.6: single structural authority for "is this a + // board-wide cost modifier, and what are its terms" — shared with + // deck-time analysis (`phase-ai`'s `features::cost_reduction`) so the + // two cannot drift. It rejects `Minimum` and `SelfRef` (the latter is + // self-cost-reduction, handled by `apply_self_spell_cost_modifiers` + // for the spell being cast and never applied from a battlefield + // permanent to other spells). + let Some(modifier) = def.board_wide_cost_modifier() else { + continue; }; + let BoardWideCostModifier { + mode, + amount, + spell_filter, + dynamic_count, + caster_scope, + condition: _, + } = modifier; + let is_raise = matches!(mode, CostModifyMode::Raise); - let has_target_filter = spell_filter - .as_ref() - .is_some_and(cost_filter_has_target_ref); + let has_target_filter = spell_filter.is_some_and(cost_filter_has_target_ref); if target_sensitive_only && !has_target_filter { continue; } @@ -7253,14 +7253,6 @@ fn collect_battlefield_cost_modifiers( continue; } - // CR 113.6: SelfRef statics are self-cost-reduction ("this spell costs - // {N} less") — handled by apply_self_spell_cost_modifiers for the spell - // being cast. They must never apply from a battlefield permanent to - // other spells. - if matches!(def.affected, Some(TargetFilter::SelfRef)) { - continue; - } - // CR 113.6 + CR 113.6b: A static functions only in its declared // zones. Empty `active_zones` means battlefield default; non-empty // means restrict to the listed zones. Eminence statics list both @@ -7275,12 +7267,8 @@ fn collect_battlefield_cost_modifiers( // CR 601.2f: Check player scope — does this modifier apply to spells the caster casts? // Must run before condition check so QuantityComparison resolves against the caster. - if let Some(TargetFilter::Typed(ref tf)) = def.affected { - match tf.controller { - Some(ControllerRef::You) if caster != source_controller => continue, - Some(ControllerRef::Opponent) if caster == source_controller => continue, - _ => {} // No controller restriction or matches - } + if !caster_scope.admits(caster, source_controller) { + continue; } // CR 601.2f: Check static condition — "as long as" / "during your turn" @@ -7299,7 +7287,7 @@ fn collect_battlefield_cost_modifiers( } // CR 601.2f: Check spell type filter — does the spell match? - if let Some(ref filter) = spell_filter { + if let Some(filter) = spell_filter { let matches = if let Some(ability) = selected_ability { spell_matches_cost_filter_with_selected_targets_for( state, caster, spell_id, filter, bf_id, ability, fused, @@ -7314,7 +7302,7 @@ fn collect_battlefield_cost_modifiers( // CR 601.2f: Calculate the modification amount. let base_amount = amount.clone(); - let multiplier = if let Some(ref qty_ref) = dynamic_count { + let multiplier = if let Some(qty_ref) = dynamic_count { let qty_expr = crate::types::ability::QuantityExpr::Ref { qty: qty_ref.clone(), }; diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index 840c774939..f79c8a8fbc 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -1269,35 +1269,258 @@ pub fn matches_target_filter_including_phased_out( /// face and yields `false`. Use the object-based `matches_target_filter` family /// instead whenever an `ObjectId` exists. pub(crate) fn matches_target_filter_against_face(face: &CardFace, filter: &TargetFilter) -> bool { + matches_target_filter_against_face_scoped(face, filter, FaceControllerScope::Reject) +} + +/// CR 109.5: how a bare-`CardFace` match treats a filter's controller axis. +/// +/// A face has no controller, so a controller-scoped filter is normally +/// unanswerable. Some callers do know the answer out of band — deck analysis +/// asks only about the analyzing player's own list, and a cost modifier's +/// "spells YOU cast" scope is carried on `StaticDefinition.affected` and settled +/// before the spell filter is consulted (see +/// [`crate::types::ability::cost_modifier_caster_scope`]). This names which of +/// those two situations the caller is in instead of leaving it implicit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FaceControllerScope { + /// The controller is genuinely unknown: any controller-scoped filter fails. + Reject, + /// The face is known to be the querying player's own card, so + /// `ControllerRef::You` is satisfied and `ControllerRef::Opponent` is not. + /// Any other `ControllerRef` remains unanswerable and fails. + AssumeOwn, +} + +/// CR 205: Evaluate a `TargetFilter`'s STATIC characteristics against a bare +/// `CardFace`, resolving the controller axis per `scope`. +/// +/// `pub` so deck-time analysis outside this crate (`phase-ai`'s +/// `features::cost_reduction`) can match a static's `spell_filter` against a +/// bare face through this authority rather than re-deriving CR 205 semantics. +pub fn matches_target_filter_against_face_scoped( + face: &CardFace, + filter: &TargetFilter, + scope: FaceControllerScope, +) -> bool { + // Only a DEFINITE match admits the face. `None` (unknown) collapses to + // `false` here and nowhere earlier — collapsing it inside the recursion is + // what let an outer `Not` invert "unanswerable" into "matches". + target_filter_face_state(face, filter, scope) == Some(true) +} + +/// Three-state evaluation of a `TargetFilter` against a bare `CardFace`. +/// +/// `Some(true)`/`Some(false)` = definitely matches / definitely does not. +/// `None` = the filter's answer depends on a live object that does not exist +/// here, so there IS no answer. +/// +/// The distinction is load-bearing under negation. Collapsing unknown to `false` +/// before recursing means `Not()` reads as `true`, so a face would be +/// admitted by a filter whose only predicate needs a live object. Threading the +/// third state through `Typed`/`Or`/`And`/`Not` and collapsing once, at the +/// boolean boundary, keeps the fail-closed contract intact at every depth. +fn target_filter_face_state( + face: &CardFace, + filter: &TargetFilter, + scope: FaceControllerScope, +) -> Option { match filter { - TargetFilter::Any => true, - TargetFilter::None => false, + TargetFilter::Any => Some(true), + TargetFilter::None => Some(false), TargetFilter::Typed(typed) => { - typed.controller.is_none() - && typed + let mut terms = vec![controller_ref_face_state(typed.controller.as_ref(), scope)]; + terms.extend( + typed .type_filters .iter() - .all(|type_filter| matches_type_filter_against_face(face, type_filter)) - && typed.properties.iter().all(|property| match property { - FilterProp::HasSupertype { value } => face.card_type.supertypes.contains(value), - _ => false, - }) + // CR 205: the printed type line is always readable from a face. + .map(|tf| Some(matches_type_filter_against_face(face, tf))), + ); + terms.extend( + typed + .properties + .iter() + .map(|property| context_free_prop_matches_face(face, property)), + ); + kleene_and(terms) } - TargetFilter::Or { filters } => filters - .iter() - .any(|inner| matches_target_filter_against_face(face, inner)), - TargetFilter::And { filters } => filters - .iter() - .all(|inner| matches_target_filter_against_face(face, inner)), - TargetFilter::Not { filter } => !matches_target_filter_against_face(face, filter), - _ => false, + TargetFilter::Or { filters } => kleene_or( + filters + .iter() + .map(|inner| target_filter_face_state(face, inner, scope)), + ), + TargetFilter::And { filters } => kleene_and( + filters + .iter() + .map(|inner| target_filter_face_state(face, inner, scope)), + ), + // Negating an unknown stays unknown — never `true`. + TargetFilter::Not { filter } => { + target_filter_face_state(face, filter, scope).map(|matched| !matched) + } + // Every remaining variant (`SelfRef`, player-scoped forms, event/LKI + // references) needs a live object or resolution context, so a bare face + // yields no answer rather than a definite `false` — otherwise an outer + // `Not` would turn "cannot tell" into "matches". + _ => None, + } +} + +/// Three-valued AND: any definite `false` wins; otherwise unknown poisons. +fn kleene_and(terms: impl IntoIterator>) -> Option { + let mut saw_unknown = false; + for term in terms { + match term { + Some(false) => return Some(false), + Some(true) => {} + None => saw_unknown = true, + } + } + (!saw_unknown).then_some(true) +} + +/// Three-valued OR: any definite `true` wins; otherwise unknown poisons. +fn kleene_or(terms: impl IntoIterator>) -> Option { + let mut saw_unknown = false; + for term in terms { + match term { + Some(true) => return Some(true), + Some(false) => {} + None => saw_unknown = true, + } + } + (!saw_unknown).then_some(false) +} + +/// CR 109.5: how a filter's controller scope reads against a bare face. +/// +/// Unscoped is a definite match. Under `AssumeOwn` the caller has told us the +/// face is the querying player's own card, so `You` is definitely satisfied and +/// `Opponent` is definitely not. Everything else — a controller scope with no +/// declared answer, or any scope at all under `Reject` — is genuinely unknown, +/// NOT false, so that a surrounding `Not` cannot invert it into a match. +fn controller_ref_face_state( + controller: Option<&ControllerRef>, + scope: FaceControllerScope, +) -> Option { + match (controller, scope) { + (None, _) => Some(true), + (Some(ControllerRef::You), FaceControllerScope::AssumeOwn) => Some(true), + (Some(ControllerRef::Opponent), FaceControllerScope::AssumeOwn) => Some(false), + _ => None, + } +} + +/// CR 205 + CR 202: Evaluate one `FilterProp` against a bare `CardFace`. +/// +/// `Some(true)` / `Some(false)` = the property has a context-free reading and +/// this is it. `None` = the property needs a live object (battlefield state, +/// counters, combat, zone, a value chosen earlier in a resolution) and therefore +/// has NO answer for a face — callers must fail closed rather than guess. +/// +/// Kept as an explicit allowlist, not a wildcard `_ => true`: a newly added +/// `FilterProp` lands in the `None` arm and fails closed until someone decides +/// whether it is context-free, which is the safe direction. +pub fn context_free_prop_matches_face(face: &CardFace, prop: &FilterProp) -> Option { + match prop { + // CR 205.4a: printed supertype line. + FilterProp::HasSupertype { value } => Some(face.card_type.supertypes.contains(value)), + FilterProp::NotSupertype { value } => Some(!face.card_type.supertypes.contains(value)), + // CR 202.3: mana value is printed on the face. Only a fixed comparison + // is context-free — a dynamic quantity needs game state. + FilterProp::Cmc { + comparator, + value: QuantityExpr::Fixed { value }, + } => Some(comparator.evaluate( + i32::try_from(face.mana_cost.mana_value()).unwrap_or(i32::MAX), + *value, + )), + // A dynamic mana-value comparison needs game state to resolve. + FilterProp::Cmc { .. } => None, + // CR 105.2 + CR 202.2: printed color, via the face's own color authority. + FilterProp::HasColor { color } => Some(face_colors(face).contains(color)), + FilterProp::NotColor { color } => Some(!face_colors(face).contains(color)), + FilterProp::ColorCount { comparator, count } => Some(comparator.evaluate( + i32::try_from(face_colors(face).len()).unwrap_or(i32::MAX), + i32::from(*count), + )), + // CR 702: printed keyword line. The keyword authorities in + // `game/keywords.rs` all take a `GameObject`; this function's entire + // contract is that NO object exists (a bare `CardFace` outside the + // game), so there is no zone, no grant and no `off_zone_characteristics` + // to consult — the printed line IS the complete truth here. A caller + // holding an `ObjectId` must use the object-based `matches_target_filter` + // family instead, as the module doc says. + // allow-raw-authority: bare CardFace has no object, so no keyword grant can exist to miss + FilterProp::WithKeyword { value } => Some(face.keywords.contains(value)), + // allow-raw-authority: bare CardFace has no object, so no keyword grant can exist to miss + FilterProp::WithoutKeyword { value } => Some(!face.keywords.contains(value)), + // CR 111.1 + CR 108.2: a bare face is a card definition, never a token. + FilterProp::Token => Some(false), + FilterProp::NonToken | FilterProp::RepresentedByCard => Some(true), + // Recursive combinators inherit their operands' answerability. Negation + // of an unknown stays unknown. + FilterProp::Not { prop } => context_free_prop_matches_face(face, prop).map(|m| !m), + // Three-valued (Kleene) OR — ordinary Boolean semantics over the + // `Option` answer, not a rules behavior, so no CR governs it. + // A definite `true` in ANY branch makes the disjunction true even when a + // sibling branch is unknowable from a face, so this must NOT + // short-circuit on the first `None`: the caller admits only `Some(true)`, + // and collapsing `[true, unknown]` to unknown would reject a spell filter + // that definitely matches. `None` is returned only when nothing is true + // AND something is unknown. + FilterProp::AnyOf { props } => { + let mut saw_unknown = false; + for inner in props { + match context_free_prop_matches_face(face, inner) { + Some(true) => return Some(true), + Some(false) => {} + None => saw_unknown = true, + } + } + (!saw_unknown).then_some(false) + } + // Everything else reads live state and has no face-level answer. + _ => None, } } +/// CR 105.2 + CR 202.2: the colors of a bare face — an explicit color-defining +/// override when present (CR 604.3), otherwise the printed mana cost. +fn face_colors(face: &CardFace) -> Vec { + if let Some(colors) = &face.color_override { + return colors.clone(); + } + [ + ManaColor::White, + ManaColor::Blue, + ManaColor::Black, + ManaColor::Red, + ManaColor::Green, + ] + .into_iter() + .filter(|color| match &face.mana_cost { + ManaCost::Cost { shards, .. } => shards.iter().any(|shard| shard.contributes_to(*color)), + // CR 202.1: no printed mana cost, so no color from one. The `Self*` + // forms are cost REFERENCES resolved against a live object (CR 202.3b), + // not a printed cost, so a bare face has no colors to read from them. + ManaCost::NoCost + | ManaCost::SelfManaCost + | ManaCost::SelfManaValue + | ManaCost::SelfManaCostReduced { .. } => false, + }) + .collect() +} + /// CR 205: Evaluate a single `TypeFilter` against a bare `CardFace`'s printed /// card type line (core types, subtypes, supertypes). Context-free counterpart /// to the object-based type checks in `filter_inner_for_object`. -pub(crate) fn matches_type_filter_against_face(face: &CardFace, filter: &TypeFilter) -> bool { +/// +/// `pub` because deck-time analysis outside this crate (`phase-ai`'s +/// `features::cost_reduction`) classifies bare `CardFace`s against a static's +/// `spell_filter` before any `GameObject` exists, and must use this authority +/// for the type axis rather than re-deriving CR 205 type semantics. +pub fn matches_type_filter_against_face(face: &CardFace, filter: &TypeFilter) -> bool { match filter { TypeFilter::Creature => face.card_type.core_types.contains(&CoreType::Creature), TypeFilter::Land => face.card_type.core_types.contains(&CoreType::Land), @@ -12620,4 +12843,302 @@ mod tests { "ControllerMatches must round-trip through serde" ); } + + // ─── three-valued OR in `context_free_prop_matches_face` ───────────────── + // + // Review #6743: `try_fold` short-circuited on the first `None`, so a + // definite `Some(true)` alternative was collapsed to unknown and the typed + // caller (which admits only `Some(true)`) rejected a filter that matches. + + /// A face with a printed white mana cost, so color props are answerable. + fn tri_state_face() -> CardFace { + CardFace { + name: "Tri State Probe".to_string(), + card_type: crate::types::card_type::CardType { + supertypes: Vec::new(), + core_types: vec![CoreType::Instant], + subtypes: Vec::new(), + }, + mana_cost: crate::types::mana::ManaCost::Cost { + shards: vec![crate::types::mana::ManaCostShard::White], + generic: 1, + }, + ..Default::default() + } + } + + /// Context-free and TRUE for `tri_state_face`. + fn known_true() -> FilterProp { + FilterProp::HasColor { + color: crate::types::mana::ManaColor::White, + } + } + + /// Context-free and FALSE for `tri_state_face`. + fn known_false() -> FilterProp { + FilterProp::HasColor { + color: crate::types::mana::ManaColor::Red, + } + } + + /// Live-state-only: unanswerable from a bare face. + fn unknown() -> FilterProp { + FilterProp::Tapped + } + + #[test] + fn any_of_true_then_unknown_is_true() { + let prop = FilterProp::AnyOf { + props: vec![known_true(), unknown()], + }; + assert_eq!( + context_free_prop_matches_face(&tri_state_face(), &prop), + Some(true), + "a definite match must survive a later unknowable alternative" + ); + } + + #[test] + fn any_of_unknown_then_true_is_true() { + // Order-sensitive twin: the old `try_fold` stopped at the leading `None`. + let prop = FilterProp::AnyOf { + props: vec![unknown(), known_true()], + }; + assert_eq!( + context_free_prop_matches_face(&tri_state_face(), &prop), + Some(true), + "a leading unknown must not mask a definite later match" + ); + } + + #[test] + fn any_of_all_unknown_is_unknown() { + let prop = FilterProp::AnyOf { + props: vec![unknown(), unknown()], + }; + assert_eq!( + context_free_prop_matches_face(&tri_state_face(), &prop), + None, + "nothing true and something unknown ⇒ unknown" + ); + } + + #[test] + fn any_of_false_plus_unknown_is_unknown() { + let prop = FilterProp::AnyOf { + props: vec![known_false(), unknown()], + }; + assert_eq!( + context_free_prop_matches_face(&tri_state_face(), &prop), + None, + "a definite false does not resolve the unknown alternative" + ); + } + + #[test] + fn any_of_all_false_is_false() { + let prop = FilterProp::AnyOf { + props: vec![known_false(), known_false()], + }; + assert_eq!( + context_free_prop_matches_face(&tri_state_face(), &prop), + Some(false), + "every alternative definitely false ⇒ definitely false" + ); + } + + #[test] + fn any_of_true_plus_false_is_true() { + let prop = FilterProp::AnyOf { + props: vec![known_false(), known_true()], + }; + assert_eq!( + context_free_prop_matches_face(&tri_state_face(), &prop), + Some(true) + ); + } + + #[test] + fn tri_state_or_reaches_the_typed_filter_caller() { + // End-to-end through the production entry point: the typed caller admits + // only `Some(true)`, so the tri-state fix is what makes this match. + let filter = TargetFilter::Typed(TypedFilter { + type_filters: vec![crate::types::ability::TypeFilter::Instant], + properties: vec![FilterProp::AnyOf { + props: vec![known_true(), unknown()], + }], + ..Default::default() + }); + assert!( + matches_target_filter_against_face_scoped( + &tri_state_face(), + &filter, + FaceControllerScope::AssumeOwn + ), + "a definitely-matching alternative must admit the face" + ); + } + + #[test] + fn negation_of_unknown_stays_unknown() { + let prop = FilterProp::Not { + prop: Box::new(unknown()), + }; + assert_eq!( + context_free_prop_matches_face(&tri_state_face(), &prop), + None, + "negating an unknowable property cannot invent an answer" + ); + } + + // ─── unknown must survive outer negation (fail-closed at every depth) ──── + // + // Review #6743: the recursion collapsed unknown to `false` inside `Typed`, + // so an outer `Not` inverted "cannot tell" into "matches" and admitted the + // wrong card class. These drive the production entry point. + + fn typed_with(props: Vec) -> TargetFilter { + TargetFilter::Typed(TypedFilter { + type_filters: vec![crate::types::ability::TypeFilter::Instant], + properties: props, + ..Default::default() + }) + } + + #[test] + fn not_around_live_only_property_does_not_admit_a_face() { + // `Tapped` needs a battlefield object; `Not(Tapped)` must NOT be a match. + let filter = TargetFilter::Not { + filter: Box::new(typed_with(vec![unknown()])), + }; + assert!( + !matches_target_filter_against_face_scoped( + &tri_state_face(), + &filter, + FaceControllerScope::AssumeOwn + ), + "negating an unanswerable property must not admit the face" + ); + } + + #[test] + fn not_around_unknown_controller_scope_does_not_admit_a_face() { + // Under `Reject` the controller is unknown, so `Not(controller-scoped)` + // is unknown too — not a match. + let filter = TargetFilter::Not { + filter: Box::new(TargetFilter::Typed(TypedFilter { + type_filters: vec![crate::types::ability::TypeFilter::Instant], + controller: Some(ControllerRef::You), + ..Default::default() + })), + }; + assert!( + !matches_target_filter_against_face_scoped( + &tri_state_face(), + &filter, + FaceControllerScope::Reject + ), + "negating an unanswerable controller scope must not admit the face" + ); + } + + #[test] + fn not_around_a_definite_false_still_admits() { + // The fix must not over-correct: a DEFINITELY false inner filter still + // negates to a match. + let filter = TargetFilter::Not { + filter: Box::new(typed_with(vec![known_false()])), + }; + assert!( + matches_target_filter_against_face_scoped( + &tri_state_face(), + &filter, + FaceControllerScope::AssumeOwn + ), + "negating a definite non-match must still admit the face" + ); + } + + #[test] + fn not_around_a_definite_true_rejects() { + let filter = TargetFilter::Not { + filter: Box::new(typed_with(vec![known_true()])), + }; + assert!(!matches_target_filter_against_face_scoped( + &tri_state_face(), + &filter, + FaceControllerScope::AssumeOwn + )); + } + + #[test] + fn not_around_an_unanswerable_filter_variant_does_not_admit() { + // `SelfRef` needs a resolution context; unknown, so `Not` is unknown. + let filter = TargetFilter::Not { + filter: Box::new(TargetFilter::SelfRef), + }; + assert!( + !matches_target_filter_against_face_scoped( + &tri_state_face(), + &filter, + FaceControllerScope::AssumeOwn + ), + "an unanswerable filter variant must stay unknown under negation" + ); + } + + #[test] + fn opponent_scope_under_assume_own_is_definitely_false_not_unknown() { + // `AssumeOwn` states the face is the querying player's own card, so + // "controlled by an opponent" is definitely FALSE — and its negation is + // therefore a definite match. + let opponent = TargetFilter::Typed(TypedFilter { + type_filters: vec![crate::types::ability::TypeFilter::Instant], + controller: Some(ControllerRef::Opponent), + ..Default::default() + }); + assert!(!matches_target_filter_against_face_scoped( + &tri_state_face(), + &opponent, + FaceControllerScope::AssumeOwn + )); + assert!( + matches_target_filter_against_face_scoped( + &tri_state_face(), + &TargetFilter::Not { + filter: Box::new(opponent) + }, + FaceControllerScope::AssumeOwn + ), + "own-face knowledge makes the opponent scope definitely false, so \ + its negation is a definite match" + ); + } + + #[test] + fn and_with_an_unknown_branch_does_not_admit() { + let filter = TargetFilter::And { + filters: vec![typed_with(vec![known_true()]), typed_with(vec![unknown()])], + }; + assert!(!matches_target_filter_against_face_scoped( + &tri_state_face(), + &filter, + FaceControllerScope::AssumeOwn + )); + } + + #[test] + fn or_with_a_definite_true_admits_despite_an_unknown_branch() { + let filter = TargetFilter::Or { + filters: vec![typed_with(vec![unknown()]), typed_with(vec![known_true()])], + }; + assert!( + matches_target_filter_against_face_scoped( + &tri_state_face(), + &filter, + FaceControllerScope::AssumeOwn + ), + "a definitely-matching alternative admits even beside an unknown" + ); + } } diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 2c8995a3b6..22a671cb91 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -20885,6 +20885,112 @@ pub enum ProtectionDoesNotRemove { ControlledAttachmentsAlreadyAttached, } +/// CR 601.2f: whose spells a cost modifier applies to, read off +/// `StaticDefinition.affected`. +/// +/// The stored form is a `TargetFilter` whose `TypedFilter.controller` carries +/// the scope, so every consumer would otherwise re-destructure it. Naming the +/// three outcomes keeps the decision in one place and lets a caller that has no +/// `PlayerId` (deck-time analysis) ask the same question as one that does +/// (the casting pipeline). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CostModifierCasterScope { + /// "Spells you cast" — applies only when the caster controls the source. + You, + /// "Spells your opponents cast" — applies only when the caster does not. + Opponent, + /// Unscoped — applies to every caster. + Any, +} + +/// CR 601.2f: read the caster scope off a cost modifier's `affected` filter. +pub fn cost_modifier_caster_scope(affected: Option<&TargetFilter>) -> CostModifierCasterScope { + match affected { + Some(TargetFilter::Typed(typed)) => match typed.controller { + Some(ControllerRef::You) => CostModifierCasterScope::You, + Some(ControllerRef::Opponent) => CostModifierCasterScope::Opponent, + _ => CostModifierCasterScope::Any, + }, + _ => CostModifierCasterScope::Any, + } +} + +/// CR 601.2f: the structural facts of a BOARD-WIDE cost modifier — one that a +/// permanent applies to other spells, as opposed to a `SelfRef` "this spell +/// costs {N} less" self-reduction (CR 113.6), which the casting pipeline +/// resolves through a different path entirely. +/// +/// This is the single structural authority for "is this static a cost modifier, +/// and what are its terms". It deliberately answers only the questions that need +/// NO game state, so that deck-time analysis and the live casting pipeline share +/// one definition of eligibility instead of maintaining parallel copies that +/// drift. State-dependent gates — the functioning zone, the `condition`, the +/// `dynamic_count` multiplier, and the spell filter itself — stay with the +/// caller that has the state to evaluate them. +#[derive(Debug, Clone, Copy)] +pub struct BoardWideCostModifier<'a> { + pub mode: crate::types::statics::CostModifyMode, + pub amount: &'a crate::types::mana::ManaCost, + pub spell_filter: Option<&'a TargetFilter>, + pub dynamic_count: Option<&'a QuantityRef>, + pub caster_scope: CostModifierCasterScope, + /// The gate a live caller must still evaluate before applying this modifier + /// (CR 601.2f "as long as" / "during your turn" clauses). `None` is + /// unconditional. + pub condition: Option<&'a StaticCondition>, +} + +impl StaticDefinition { + /// CR 601.2f: view this static as a board-wide cost modifier, or `None` when + /// it is not one. + /// + /// Rejects `Minimum` (a CR 601.2f last-step floor, not a per-spell + /// adjustment) and `SelfRef` self-cost reductions (CR 113.6). + pub fn board_wide_cost_modifier(&self) -> Option> { + let StaticMode::ModifyCost { + mode, + amount, + spell_filter, + dynamic_count, + } = &self.mode + else { + return None; + }; + if matches!(mode, crate::types::statics::CostModifyMode::Minimum) { + return None; + } + if matches!(self.affected, Some(TargetFilter::SelfRef)) { + return None; + } + Some(BoardWideCostModifier { + mode: *mode, + amount, + spell_filter: spell_filter.as_ref(), + dynamic_count: dynamic_count.as_ref(), + caster_scope: cost_modifier_caster_scope(self.affected.as_ref()), + condition: self.condition.as_ref(), + }) + } +} + +impl CostModifierCasterScope { + /// CR 601.2f: does this scope admit `caster`, given who controls the source? + pub fn admits(self, caster: PlayerId, source_controller: PlayerId) -> bool { + match self { + CostModifierCasterScope::You => caster == source_controller, + CostModifierCasterScope::Opponent => caster != source_controller, + CostModifierCasterScope::Any => true, + } + } + + /// CR 601.2f: does this scope admit the source's own controller? The + /// `PlayerId`-free question deck analysis asks — "would this discount the + /// spells of the player who controls it?" + pub fn admits_own_controller(self) -> bool { + !matches!(self, CostModifierCasterScope::Opponent) + } +} + impl StaticDefinition { pub fn new(mode: StaticMode) -> Self { Self { diff --git a/crates/phase-ai/src/config.rs b/crates/phase-ai/src/config.rs index 5c1690e2a5..13f99c6ae0 100644 --- a/crates/phase-ai/src/config.rs +++ b/crates/phase-ai/src/config.rs @@ -533,6 +533,14 @@ pub struct PolicyPenalties { /// draw" engine (preference band, per engine). #[serde(default = "default_draw_payoff_bonus")] pub draw_payoff_bonus: f64, + /// CR 601.2f: card-equivalent value of ONE generic mana saved by deploying a + /// cost reducer, multiplied by the capped saved-mana total. + #[serde(default = "default_cost_reduction_deploy_bonus")] + pub cost_reduction_deploy_bonus: f64, + /// CR 601.2f: nudge-band penalty for casting past an unplayed, cheaper cost + /// reducer — the discount should be deployed first. + #[serde(default = "default_cost_reduction_defer_penalty")] + pub cost_reduction_defer_penalty: f64, } impl Default for PolicyPenalties { @@ -608,6 +616,8 @@ impl Default for PolicyPenalties { devotion_pip_progress: default_devotion_pip_progress(), devotion_god_activation: default_devotion_god_activation(), draw_payoff_bonus: default_draw_payoff_bonus(), + cost_reduction_deploy_bonus: default_cost_reduction_deploy_bonus(), + cost_reduction_defer_penalty: default_cost_reduction_defer_penalty(), } } } @@ -736,6 +746,12 @@ fn default_devotion_god_activation() -> f64 { fn default_draw_payoff_bonus() -> f64 { 0.6 } +fn default_cost_reduction_deploy_bonus() -> f64 { + 0.2 +} +fn default_cost_reduction_defer_penalty() -> f64 { + -0.25 +} fn default_sacrifice_token_cost() -> f64 { 0.5 } @@ -884,6 +900,15 @@ pub const UNTUNED_POLICY_PENALTY_FIELDS: &[(&str, &str)] = &[ "draw_payoff_bonus", "CR 121.1 per-engine draw-payoff weight — awaiting a paired-seed ai-gate calibration.", ), + ( + "cost_reduction_deploy_bonus", + "CR 601.2f per-saved-mana deployment weight — awaiting a paired-seed ai-gate calibration.", + ), + ( + "cost_reduction_defer_penalty", + "CR 601.2f sequencing nudge for casting past a cheaper unplayed reducer — \ + awaiting a paired-seed ai-gate calibration.", + ), ( "poison_clock_pressure", "CR 104.3d win-detector weight — a critical-band term whose magnitude is \ diff --git a/crates/phase-ai/src/features/cost_reduction.rs b/crates/phase-ai/src/features/cost_reduction.rs new file mode 100644 index 0000000000..5f7db34b8f --- /dev/null +++ b/crates/phase-ai/src/features/cost_reduction.rs @@ -0,0 +1,324 @@ +//! Cost-reduction feature — structural detection of a deck that discounts its +//! own spells (CR 601.2f). +//! +//! Parser AST verification — VERIFIED against engine source: +//! - `StaticMode::ModifyCost { mode, amount, spell_filter, dynamic_count }` at +//! `crates/engine/src/types/statics.rs:1017` — the reducer itself. +//! - `CostModifyMode::{Reduce, Raise, Minimum}` at `statics.rs:698`; only +//! `Reduce` discounts (CR 601.2f), `Raise`/`Minimum` are the Thalia / +//! Trinisphere tax shapes and are NOT this axis. +//! - `StaticDefinition.affected: Option` at +//! `crates/engine/src/types/ability.rs:20740` — carries the caster scope +//! (`TypedFilter.controller`), exactly as `collect_battlefield_cost_modifiers` reads it. +//! - `CardFace.static_abilities: Vec` at `card.rs:162`; +//! the runtime counterpart is `GameObject.static_definitions`. +//! - `ManaCost::Cost { generic, shards }` at +//! `crates/engine/src/types/mana.rs:1714`. +//! +//! No parser remediation required — every axis is expressible over existing +//! typed AST. `features::mana_ramp` explicitly deferred this shape +//! ("`StaticMode::ModifyCost` is deliberately out of scope — cost reducers are +//! a follow-up feature"); this module is that follow-up. +//! +//! ## Why this axis exists +//! +//! A Goblin Electromancer / Baral / Foundry Inspector / Medallion effect is +//! acceleration that never taps for mana: every subsequent spell costs less for +//! as long as the permanent survives (CR 601.2f). The engine already *applies* +//! the discount when the AI casts, so the AI is never overcharged — but nothing +//! makes it *value deploying the reducer first*. `mana_ramp` only sees effects +//! that add mana (`Effect::Mana`, land-fetch, extra land drops), so a deck whose +//! entire acceleration plan is cost reduction reads as having no ramp at all. +//! This axis lets a policy see that plan. +//! +//! ## Boundary with `mana_ramp` +//! +//! `mana_ramp` measures mana *added* to the pool; this axis measures cost +//! *removed* from spells. The two are disjoint at the AST level (`Effect::Mana` +//! vs `StaticMode::ModifyCost`) and a card is never counted by both. A deck can +//! read high on both — Sol Ring plus Medallions is a real shell — and the axes +//! stay independent. + +use engine::game::filter::{matches_target_filter_against_face_scoped, FaceControllerScope}; +use engine::game::quantity::resolve_quantity; +use engine::game::DeckEntry; +use engine::types::ability::{QuantityExpr, StaticDefinition, TargetFilter}; +use engine::types::card::CardFace; +use engine::types::card_type::CoreType; +use engine::types::game_state::GameState; +use engine::types::identifiers::ObjectId; +use engine::types::mana::ManaCost; +use engine::types::player::PlayerId; +use engine::types::statics::CostModifyMode; + +use crate::features::commitment; + +/// Commitment at or above which discounting your own spells is a real plan for +/// this deck rather than one incidental Medallion. Gates +/// `CostReductionPolicy::activation`. +/// +/// Calibrated so a shell with four two-mana reducers over ~36 nonland cards +/// (commitment ≈ 0.61) activates while a deck running two (≈ 0.43) does not — +/// see [`compute_commitment`]. +pub const COST_REDUCTION_FLOOR: f32 = 0.45; + +/// Reducer density (per 60 nonland) at which the engine pillar saturates. Ten +/// discount permanents per 60 nonland is a fully-committed cost-reduction base. +const REDUCER_SATURATION_PER_60: f32 = 10.0; + +/// CR 601.2f: per-deck cost-reduction classification. +/// +/// Populated once per game from `DeckEntry` data. Detection is structural over +/// `CardFace.static_abilities` — never by card name. +#[derive(Debug, Clone, Default)] +pub struct CostReductionFeature { + /// Cards carrying a board-wide CR 601.2f reducer that applies to spells YOU + /// cast — the engines. Excludes self-cost reductions ("this spell costs {1} + /// less") and opponent-scoped taxes. + pub reducer_count: u32, + /// Summed generic-mana discount those reducers deliver per application. + /// + /// CR 118.7a: a generic cost reduction affects only the generic component of + /// a cost, so the magnitude is the reduction's generic amount — not its full + /// mana value. + pub total_discount: u32, + /// Nonland deck cards that at least one of those reducers actually discounts + /// (its `spell_filter` admits them) — the spells the engines pay off on. + pub discounted_count: u32, + /// `0.0..=1.0` — how central discounting your own spells is to this deck. + /// Consumed by `CostReductionPolicy::activation` as the single scaling knob. + pub commitment: f32, +} + +/// Structural detection over each `DeckEntry`'s `CardFace` AST. +pub fn detect(deck: &[DeckEntry]) -> CostReductionFeature { + if deck.is_empty() { + return CostReductionFeature::default(); + } + + let mut reducer_count = 0u32; + let mut total_discount = 0u32; + let mut total_nonland = 0u32; + // One entry per reducing static, so coverage is measured against every + // discount the deck can put on the battlefield. + let mut spell_filters: Vec> = Vec::new(); + + for entry in deck { + let face = &entry.card; + if !face.card_type.core_types.contains(&CoreType::Land) { + total_nonland = total_nonland.saturating_add(entry.count); + } + + let discount = your_spell_discount_parts(&face.static_abilities); + if discount == 0 { + continue; + } + reducer_count = reducer_count.saturating_add(entry.count); + total_discount = total_discount.saturating_add(discount.saturating_mul(entry.count)); + // Hoisted out of any per-copy loop: the filter set is about WHAT the + // deck discounts, so each unique face contributes its filters once. + for def in &face.static_abilities { + if let Some(filter) = your_spell_discount_filter(def) { + spell_filters.push(filter); + } + } + } + + // Second pass: how much of the deck do those filters actually admit? A + // reducer whose filter matches nothing this deck plays is not an engine. + let mut discounted_count = 0u32; + if !spell_filters.is_empty() { + for entry in deck { + let face = &entry.card; + if face.card_type.core_types.contains(&CoreType::Land) { + continue; + } + if spell_filters + .iter() + .any(|filter| filter_admits_face(filter.as_ref(), face)) + { + discounted_count = discounted_count.saturating_add(entry.count); + } + } + } + + let commitment = compute_commitment(reducer_count, discounted_count, total_nonland); + + CostReductionFeature { + reducer_count, + total_discount, + discounted_count, + commitment, + } +} + +/// CR 601.2f: total per-application generic discount these statics give to +/// spells YOU cast. `0` means "not a cost-reduction engine". +/// +/// Parts-based so it classifies both a deck-time `CardFace.static_abilities` +/// slice and a live `GameObject.static_definitions` slice — the two carry the +/// same `StaticDefinition` shape under different field names. +pub(crate) fn your_spell_discount_parts<'a>( + statics: impl IntoIterator, +) -> u32 { + statics + .into_iter() + .filter_map(your_spell_discount) + .fold(0u32, u32::saturating_add) +} + +/// CR 601.2f: the generic discount this one static gives to spells you cast, or +/// `None` when it is not a board-wide reduction of your own spells. +/// +/// Eligibility is NOT re-derived here. `StaticDefinition::board_wide_cost_modifier` +/// is the engine's single structural authority — the same one +/// `casting::collect_battlefield_cost_modifiers` consumes at cast time — so +/// `Minimum`, `SelfRef` (CR 113.6) and the caster scope are settled in one place +/// and cannot drift between deck analysis and the resolver. +fn your_spell_discount(def: &StaticDefinition) -> Option { + let modifier = def.board_wide_cost_modifier()?; + + // CR 601.2f: `Raise` (Thalia) is a tax, not a discount. `Minimum` + // (Trinisphere) never reaches here — the authority rejects it. + if !matches!(modifier.mode, CostModifyMode::Reduce) { + return None; + } + + // CR 601.2f: a modifier scoped to opponents' spells is a discount handed to + // the other side, not this deck's engine. + if !modifier.caster_scope.admits_own_controller() { + return None; + } + + generic_discount(modifier.amount) +} + +/// CR 118.7a: only the generic component of a cost can be reduced by a generic +/// reduction, so the magnitude is `generic`. A purely colored `amount` moves no +/// generic cost and is not an engine. +fn generic_discount(amount: &ManaCost) -> Option { + let ManaCost::Cost { generic, .. } = amount else { + return None; + }; + (*generic > 0).then_some(*generic) +} + +/// CR 601.2f: one discount a static would currently apply to its controller's +/// spells, paired with the spells it applies to. +pub(crate) struct LiveDiscount { + /// Generic mana removed per application, `dynamic_count` already resolved. + pub generic: u32, + /// `None` discounts every spell its controller casts. + pub spell_filter: Option, +} + +/// CR 601.2f: the discounts `statics` would apply to their controller's spells +/// **right now**, with every state-dependent term of the casting authority's +/// eligibility resolved rather than assumed. +/// +/// The deck-time [`your_spell_discount`] deliberately answers only the +/// structural half, because a deck list has no game state. This is the live +/// half, and it exists because the casting authority gates each modifier on two +/// things a structural read cannot see (`casting::collect_battlefield_cost_modifiers`): +/// +/// * `condition` — an "as long as" / "during your turn" gate. A candidate still +/// in hand has not entered the battlefield, so a source-relative condition has +/// no truthful answer yet; per CR 601.2f the modifier simply would not apply +/// when it is false. This **fails off**: a conditional reducer earns no +/// deployment credit rather than credit the AI cannot bank on. +/// * `dynamic_count` — "for each [thing]" multiplier, resolved through the +/// engine's `resolve_quantity` authority so this agrees with the resolver by +/// construction. A multiplier of zero means the reducer currently discounts +/// nothing and is skipped entirely. +pub(crate) fn live_your_spell_discounts<'a>( + state: &GameState, + source: ObjectId, + controller: PlayerId, + statics: impl IntoIterator, +) -> Vec { + statics + .into_iter() + .filter_map(|def| { + let per_application = your_spell_discount(def)?; + let modifier = def.board_wide_cost_modifier()?; + // CR 601.2f: an unevaluable gate fails off (see above). + if modifier.condition.is_some() { + return None; + } + let multiplier = match modifier.dynamic_count { + None => 1, + Some(qty) => { + let expr = QuantityExpr::Ref { qty: qty.clone() }; + u32::try_from(resolve_quantity(state, &expr, controller, source).max(0)) + .unwrap_or(0) + } + }; + let generic = per_application.saturating_mul(multiplier); + (generic > 0).then(|| LiveDiscount { + generic, + spell_filter: modifier.spell_filter.cloned(), + }) + }) + .collect() +} + +/// The `spell_filter` of a qualifying reducer — `Some(None)` for "discounts +/// every spell you cast", `None` when this static is not a qualifying reducer. +/// +/// Separate from [`your_spell_discount`] because coverage is keyed on WHAT is +/// discounted while magnitude is keyed on HOW MUCH; folding both into one +/// return type would force every caller to destructure a tuple it half-ignores. +fn your_spell_discount_filter(def: &StaticDefinition) -> Option> { + your_spell_discount(def)?; + Some(def.board_wide_cost_modifier()?.spell_filter.cloned()) +} + +/// CR 601.2f: would this reducer's `spell_filter` admit `face` as a discounted +/// spell? `None` is an unfiltered reducer — it discounts everything you cast. +/// +/// Delegates wholly to the engine's context-free `CardFace` authority +/// (`matches_target_filter_against_face_scoped`), which owns CR 205 type +/// semantics AND every context-free `FilterProp` (mana value, color, keyword, +/// supertype) — so a color-, mana-value- or keyword-scoped reducer is matched +/// rather than silently discarded, and a property that needs live state fails +/// closed inside that authority instead of here. +/// +/// `FaceControllerScope::AssumeOwn` because the caster axis was already settled +/// by `CostModifierCasterScope` in [`your_spell_discount`] — deck analysis asks +/// only about the analyzing player's own list, which is exactly how +/// `casting::collect_battlefield_cost_modifiers` splits the two checks. +fn filter_admits_face(filter: Option<&TargetFilter>, face: &CardFace) -> bool { + match filter { + None => true, + Some(filter) => { + matches_target_filter_against_face_scoped(face, filter, FaceControllerScope::AssumeOwn) + } + } +} + +/// Calibration: an Izzet spells shell with four two-mana reducers (Goblin +/// Electromancer / Baral, "instant and sorcery spells you cast cost {1} less") +/// over ~36 nonland cards, ~20 of which are instants or sorceries → +/// reducer density 6.67/60 → 0.667, coverage 20/36 → 0.556, commitment ≈ 0.61. +/// A full artifact shell (eight reducers, ~29 of 36 nonland discounted) → ≈ 0.94. +/// +/// Anti-calibration: two reducers over the same 36 nonland → ≈ 0.43, below +/// [`COST_REDUCTION_FLOOR`]; a deck with no CR 601.2f reducer → 0.0; a reducer +/// whose filter admits nothing the deck plays (a lone Semblance Anvil in a +/// creature-less shell) → coverage 0.0 → 0.0. +/// +/// Geometric mean over (reducer, coverage): BOTH pillars are mandatory. Reducers +/// that discount nothing this deck casts are blanks, and spells with no reducer +/// are just spells — neither alone is a cost-reduction plan. +fn compute_commitment(reducer_count: u32, discounted_count: u32, total_nonland: u32) -> f32 { + let reducer_density = (commitment::density_per_60(reducer_count, total_nonland) + / REDUCER_SATURATION_PER_60) + .min(1.0); + // Coverage is already a fraction of the deck, so it is its own density. + let coverage = if total_nonland == 0 { + 0.0 + } else { + (discounted_count as f32 / total_nonland as f32).min(1.0) + }; + commitment::geometric_mean(&[reducer_density, coverage]) +} diff --git a/crates/phase-ai/src/features/mana_ramp.rs b/crates/phase-ai/src/features/mana_ramp.rs index 16dbafd90a..7896ad0f56 100644 --- a/crates/phase-ai/src/features/mana_ramp.rs +++ b/crates/phase-ai/src/features/mana_ramp.rs @@ -19,8 +19,10 @@ //! - Controller scoping: `TypedFilter.controller: Option` at //! `ability.rs:815-818`. //! -//! `StaticMode::ModifyCost` is deliberately out of scope — cost reducers are a -//! follow-up feature. +//! `StaticMode::ModifyCost` is deliberately out of scope here — this axis +//! measures mana *added* to the pool. Cost reducers (cost *removed* from spells, +//! CR 601.2f) are the disjoint `features::cost_reduction` axis; a card is never +//! counted by both. use engine::game::DeckEntry; use engine::types::ability::{ diff --git a/crates/phase-ai/src/features/mod.rs b/crates/phase-ai/src/features/mod.rs index 98dc265fd2..41c0a1414f 100644 --- a/crates/phase-ai/src/features/mod.rs +++ b/crates/phase-ai/src/features/mod.rs @@ -12,6 +12,7 @@ pub mod artifacts; pub mod blink; pub mod commitment; pub mod control; +pub mod cost_reduction; pub mod devotion; pub mod draw_matters; pub mod enchantments; @@ -37,6 +38,7 @@ pub use aristocrats::AristocratsFeature; pub use artifacts::ArtifactsFeature; pub use blink::BlinkFeature; pub use control::ControlFeature; +pub use cost_reduction::CostReductionFeature; pub use devotion::DevotionFeature; pub use draw_matters::DrawMattersFeature; pub use enchantments::EnchantmentsFeature; @@ -74,6 +76,9 @@ pub struct DeckFeatures { pub mana_ramp: ManaRampFeature, pub tribal: TribalFeature, pub control: ControlFeature, + /// CR 601.2f: cost-reduction density ("spells you cast cost less") — the + /// acceleration axis `mana_ramp` explicitly defers. + pub cost_reduction: CostReductionFeature, pub enchantments: EnchantmentsFeature, pub equipment: EquipmentFeature, pub blink: BlinkFeature, @@ -125,6 +130,7 @@ impl DeckFeatures { mana_ramp: mana_ramp::detect(deck), tribal: tribal::detect(deck), control: control::detect(deck), + cost_reduction: cost_reduction::detect(deck), enchantments: enchantments::detect(deck), equipment: equipment::detect(deck), blink: blink::detect(deck), diff --git a/crates/phase-ai/src/features/tests/cost_reduction.rs b/crates/phase-ai/src/features/tests/cost_reduction.rs new file mode 100644 index 0000000000..8959037b25 --- /dev/null +++ b/crates/phase-ai/src/features/tests/cost_reduction.rs @@ -0,0 +1,500 @@ +//! Unit tests for `features::cost_reduction` — CR 601.2f "spells you cast cost +//! less" detection. No `#[cfg(test)]` in SOURCE files; tests live here. + +use engine::game::DeckEntry; +use engine::types::ability::{ + Comparator, ControllerRef, FilterProp, QuantityExpr, StaticDefinition, TargetFilter, + TypeFilter, TypedFilter, +}; +use engine::types::card::CardFace; +use engine::types::card_type::{CardType, CoreType}; +use engine::types::mana::{ManaColor, ManaCost, ManaCostShard}; +use engine::types::statics::{CostModifyMode, StaticMode}; + +use crate::features::cost_reduction::*; + +fn face(name: &str, core: CoreType) -> CardFace { + CardFace { + name: name.to_string(), + card_type: CardType { + supertypes: Vec::new(), + core_types: vec![core], + subtypes: Vec::new(), + }, + ..Default::default() + } +} + +fn entry(card: CardFace, count: u32) -> DeckEntry { + DeckEntry { card, count } +} + +fn generic(amount: u32) -> ManaCost { + ManaCost::Cost { + shards: Vec::new(), + generic: amount, + } +} + +/// A CR 601.2f board-wide reducer: "spells you cast cost {amount} less", +/// narrowed by `spell_filter`. +fn reducer( + name: &str, + amount: u32, + mode: CostModifyMode, + spell_filter: Option, +) -> CardFace { + let mut f = face(name, CoreType::Creature); + let mut def = StaticDefinition::new(StaticMode::ModifyCost { + mode, + amount: generic(amount), + spell_filter, + dynamic_count: None, + }); + def.affected = Some(TargetFilter::Typed(TypedFilter { + controller: Some(ControllerRef::You), + ..Default::default() + })); + f.static_abilities = vec![def]; + f +} + +/// The Goblin Electromancer shape: instant/sorcery spells you cast cost {1} less. +fn spell_reducer(name: &str) -> CardFace { + reducer( + name, + 1, + CostModifyMode::Reduce, + Some(TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::AnyOf(vec![ + TypeFilter::Instant, + TypeFilter::Sorcery, + ])], + controller: Some(ControllerRef::You), + ..Default::default() + })), + ) +} + +/// Filler the reducers above actually discount. +fn discounted_spell(name: &str) -> CardFace { + face(name, CoreType::Instant) +} + +/// Filler no instant/sorcery-scoped reducer discounts. +fn undiscounted_spell(name: &str) -> CardFace { + face(name, CoreType::Creature) +} + +/// A deck of `reducers` copies of `reducer_face` plus `discounted` discounted +/// spells and `other` undiscounted ones, padded to `nonland` nonland cards. +fn deck(reducer_face: CardFace, reducers: u32, discounted: u32, other: u32) -> Vec { + vec![ + entry(reducer_face, reducers), + entry(discounted_spell("Discounted"), discounted), + entry(undiscounted_spell("Other"), other), + ] +} + +#[test] +fn empty_deck_produces_defaults() { + let f = detect(&[]); + assert_eq!(f.reducer_count, 0); + assert_eq!(f.total_discount, 0); + assert_eq!(f.discounted_count, 0); + assert_eq!(f.commitment, 0.0); +} + +#[test] +fn vanilla_creature_not_registered() { + let f = detect(&[entry(undiscounted_spell("Bear"), 4)]); + assert_eq!(f.reducer_count, 0); + assert_eq!(f.commitment, 0.0); +} + +#[test] +fn detects_board_wide_reducer() { + // 4 reducers, 20 discounted instants, 12 other → 36 nonland. + let f = detect(&deck(spell_reducer("Electromancer"), 4, 20, 12)); + assert_eq!(f.reducer_count, 4); + assert_eq!(f.total_discount, 4); + // The reducer itself is a creature, so only the 20 instants are discounted. + assert_eq!(f.discounted_count, 20); +} + +#[test] +fn unfiltered_reducer_discounts_every_nonland_card() { + // `spell_filter: None` — "spells you cast cost {1} less" (Aang / Medallion + // shape) admits every nonland card in the deck, itself included. + let f = detect(&deck( + reducer("Unfiltered", 1, CostModifyMode::Reduce, None), + 4, + 20, + 12, + )); + assert_eq!(f.discounted_count, 36); +} + +#[test] +fn discount_magnitude_is_the_generic_component() { + // CR 118.7a: only the generic component of a cost is reduced. + let f = detect(&deck( + reducer("Big", 2, CostModifyMode::Reduce, None), + 3, + 20, + 13, + )); + assert_eq!(f.reducer_count, 3); + assert_eq!(f.total_discount, 6); +} + +#[test] +fn raise_mode_does_not_count() { + // Thalia, Guardian of Thraben taxes — it is not a discount engine. + let f = detect(&deck( + reducer("Thalia", 1, CostModifyMode::Raise, None), + 4, + 20, + 12, + )); + assert_eq!(f.reducer_count, 0); + assert_eq!(f.commitment, 0.0); +} + +#[test] +fn minimum_mode_does_not_count() { + // Trinisphere floors a cost (CR 601.2f last step); it discounts nothing. + let f = detect(&deck( + reducer("Trinisphere", 3, CostModifyMode::Minimum, None), + 4, + 20, + 12, + )); + assert_eq!(f.reducer_count, 0); +} + +#[test] +fn self_cost_reduction_does_not_count() { + // CR 113.6: "this spell costs {1} less" is a property of one card, resolved + // by `apply_self_spell_cost_modifiers` — never a board-wide engine. + let mut f_card = face("Affinity Thing", CoreType::Artifact); + let mut def = StaticDefinition::new(StaticMode::ModifyCost { + mode: CostModifyMode::Reduce, + amount: generic(1), + spell_filter: None, + dynamic_count: None, + }); + def.affected = Some(TargetFilter::SelfRef); + f_card.static_abilities = vec![def]; + + let f = detect(&deck(f_card, 4, 20, 12)); + assert_eq!(f.reducer_count, 0); + assert_eq!(f.commitment, 0.0); +} + +#[test] +fn opponent_scoped_reducer_does_not_count() { + // A discount handed to the other side is not your engine. + let mut f_card = face("Opponent Helper", CoreType::Enchantment); + let mut def = StaticDefinition::new(StaticMode::ModifyCost { + mode: CostModifyMode::Reduce, + amount: generic(1), + spell_filter: None, + dynamic_count: None, + }); + def.affected = Some(TargetFilter::Typed(TypedFilter { + controller: Some(ControllerRef::Opponent), + ..Default::default() + })); + f_card.static_abilities = vec![def]; + + let f = detect(&deck(f_card, 4, 20, 12)); + assert_eq!(f.reducer_count, 0); +} + +#[test] +fn zero_generic_reduction_does_not_count() { + // A purely colored `amount` moves no generic cost (CR 118.7a). + let f = detect(&deck( + reducer("Colorless Only", 0, CostModifyMode::Reduce, None), + 4, + 20, + 12, + )); + assert_eq!(f.reducer_count, 0); +} + +#[test] +fn unverifiable_filter_property_yields_no_coverage() { + // A `properties` predicate needs live game state, so coverage fails OFF + // rather than claiming a discount the deck may not get. + let f = detect(&deck( + reducer( + "Stateful", + 1, + CostModifyMode::Reduce, + Some(TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Instant], + properties: vec![FilterProp::Tapped], + ..Default::default() + })), + ), + 4, + 20, + 12, + )); + assert_eq!(f.reducer_count, 4); + assert_eq!(f.discounted_count, 0); + assert_eq!(f.commitment, 0.0); +} + +#[test] +fn reducer_matching_nothing_collapses_commitment() { + // The lone-Semblance-Anvil case: a real reducer whose filter admits nothing + // this deck plays. Geometric mean collapses on the zero coverage pillar. + let f = detect(&[ + entry(spell_reducer("Electromancer"), 4), + entry(undiscounted_spell("Creature"), 32), + ]); + assert_eq!(f.reducer_count, 4); + assert_eq!(f.discounted_count, 0); + assert_eq!(f.commitment, 0.0); +} + +#[test] +fn spells_without_a_reducer_collapse_commitment() { + let f = detect(&[entry(discounted_spell("Bolt"), 36)]); + assert_eq!(f.reducer_count, 0); + assert_eq!(f.discounted_count, 0); + assert_eq!(f.commitment, 0.0); +} + +#[test] +fn izzet_spells_shell_hits_calibration_anchor() { + // Docstring anchor: 4 two-mana reducers, 20 of 36 nonland discounted → ≈0.61. + let f = detect(&deck(spell_reducer("Electromancer"), 4, 20, 12)); + assert!( + f.commitment > 0.58 && f.commitment < 0.64, + "expected ≈0.61, got {}", + f.commitment + ); + assert!(f.commitment >= COST_REDUCTION_FLOOR); +} + +#[test] +fn two_reducers_stay_below_floor() { + // Anti-calibration: two reducers over the same 36 nonland → ≈0.43. + let f = detect(&deck(spell_reducer("Electromancer"), 2, 20, 14)); + assert!( + f.commitment < COST_REDUCTION_FLOOR, + "expected below floor, got {}", + f.commitment + ); +} + +#[test] +fn commitment_is_format_size_neutral() { + // Same density over a 99-card Commander shell reads the same as over 60. + let sixty = detect(&deck(spell_reducer("Electromancer"), 4, 20, 12)); + let commander = detect(&deck(spell_reducer("Electromancer"), 7, 35, 21)); + assert!( + (sixty.commitment - commander.commitment).abs() < 0.03, + "{} vs {}", + sixty.commitment, + commander.commitment + ); +} + +#[test] +fn commitment_clamps_to_one() { + // Every nonland card is both a reducer and discounted by it. + let f = detect(&[entry( + reducer("Everything", 3, CostModifyMode::Reduce, None), + 40, + )]); + assert!(f.commitment <= 1.0); + assert!( + f.commitment > 0.99, + "expected saturation, got {}", + f.commitment + ); +} + +#[test] +fn lands_are_excluded_from_coverage() { + // CR 305.1: playing a land is not casting a spell, so lands never count as + // discounted cards nor toward the nonland denominator. + let with_lands = detect(&[ + entry(spell_reducer("Electromancer"), 4), + entry(discounted_spell("Discounted"), 20), + entry(undiscounted_spell("Other"), 12), + entry(face("Island", CoreType::Land), 24), + ]); + let without_lands = detect(&deck(spell_reducer("Electromancer"), 4, 20, 12)); + assert_eq!(with_lands.commitment, without_lands.commitment); + assert_eq!(with_lands.discounted_count, 20); +} + +#[test] +fn parts_predicate_sums_multiple_reducing_statics() { + // One face carrying two reducers reports their combined discount. + let mut f_card = face("Double", CoreType::Artifact); + let mut first = StaticDefinition::new(StaticMode::ModifyCost { + mode: CostModifyMode::Reduce, + amount: generic(1), + spell_filter: None, + dynamic_count: None, + }); + first.affected = Some(TargetFilter::Typed(TypedFilter { + controller: Some(ControllerRef::You), + ..Default::default() + })); + let second = StaticDefinition::new(StaticMode::ModifyCost { + mode: CostModifyMode::Reduce, + amount: generic(2), + spell_filter: None, + dynamic_count: None, + }); + f_card.static_abilities = vec![first, second]; + + assert_eq!(your_spell_discount_parts(&f_card.static_abilities), 3); +} + +#[test] +fn parts_predicate_reports_zero_for_non_reducer() { + let f_card = undiscounted_spell("Bear"); + assert_eq!(your_spell_discount_parts(&f_card.static_abilities), 0); +} + +// ─── review #6743: context-free `TypedFilter.properties` are now honored ────── +// +// Previously any nonempty `properties` list was discarded, so color-, mana-value- +// and keyword-scoped reducers silently discounted nothing. + +/// A face with a real printed mana cost, so color and mana-value props resolve. +fn costed_face(name: &str, core: CoreType, shards: Vec, generic: u32) -> CardFace { + let mut f = face(name, core); + f.mana_cost = ManaCost::Cost { shards, generic }; + f +} + +fn prop_reducer(name: &str, props: Vec) -> CardFace { + reducer( + name, + 1, + CostModifyMode::Reduce, + Some(TargetFilter::Typed(TypedFilter { + properties: props, + controller: Some(ControllerRef::You), + ..Default::default() + })), + ) +} + +#[test] +fn color_scoped_reducer_covers_matching_spells() { + // "White spells you cast cost {1} less" — CR 105.2 printed color. + let f = detect(&[ + entry( + prop_reducer( + "Medallion", + vec![FilterProp::HasColor { + color: ManaColor::White, + }], + ), + 4, + ), + entry( + costed_face( + "White Spell", + CoreType::Instant, + vec![ManaCostShard::White], + 1, + ), + 20, + ), + entry( + costed_face("Red Spell", CoreType::Instant, vec![ManaCostShard::Red], 1), + 12, + ), + ]); + assert_eq!(f.reducer_count, 4); + // Only the 20 white spells — the reducer itself is colorless here. + assert_eq!(f.discounted_count, 20); + assert!( + f.commitment > 0.0, + "color-scoped reducer must produce coverage" + ); +} + +#[test] +fn mana_value_scoped_reducer_covers_matching_spells() { + // "Spells you cast with mana value 3 or greater cost {1} less" — CR 202.3. + let f = detect(&[ + entry( + prop_reducer( + "Big Discount", + vec![FilterProp::Cmc { + comparator: Comparator::GE, + value: QuantityExpr::Fixed { value: 3 }, + }], + ), + 4, + ), + entry(costed_face("Expensive", CoreType::Sorcery, vec![], 4), 20), + entry(costed_face("Cheap", CoreType::Sorcery, vec![], 1), 12), + ]); + assert_eq!(f.reducer_count, 4); + assert_eq!(f.discounted_count, 20); +} + +#[test] +fn negated_color_prop_is_honored() { + let f = detect(&[ + entry( + prop_reducer( + "Anti-White", + vec![FilterProp::NotColor { + color: ManaColor::White, + }], + ), + 4, + ), + entry( + costed_face( + "White Spell", + CoreType::Instant, + vec![ManaCostShard::White], + 1, + ), + 20, + ), + entry( + costed_face("Red Spell", CoreType::Instant, vec![ManaCostShard::Red], 1), + 12, + ), + ]); + // The 12 red spells plus the 4 colorless reducers themselves. + assert_eq!(f.discounted_count, 16); +} + +#[test] +fn live_only_property_still_fails_closed() { + // A property with no context-free reading must NOT be assumed satisfied — + // it yields no coverage, so commitment collapses rather than over-claiming. + let f = detect(&[ + entry(prop_reducer("Stateful", vec![FilterProp::Tapped]), 4), + entry(costed_face("Spell", CoreType::Instant, vec![], 2), 32), + ]); + assert_eq!(f.reducer_count, 4); + assert_eq!(f.discounted_count, 0); + assert_eq!(f.commitment, 0.0); +} + +#[test] +fn controller_scoped_spell_filter_is_admitted_for_own_deck() { + // Regression for the root defect: a `ControllerRef::You` spell filter — the + // shape nearly every real reducer uses — must not be rejected outright. + let f = detect(&deck(spell_reducer("Electromancer"), 4, 20, 12)); + assert_eq!(f.discounted_count, 20); +} diff --git a/crates/phase-ai/src/features/tests/mod.rs b/crates/phase-ai/src/features/tests/mod.rs index da91977ba0..00b8926771 100644 --- a/crates/phase-ai/src/features/tests/mod.rs +++ b/crates/phase-ai/src/features/tests/mod.rs @@ -3,6 +3,7 @@ pub mod artifacts; pub mod blink; +pub mod cost_reduction; pub mod devotion; pub mod draw_matters; pub mod enchantments; diff --git a/crates/phase-ai/src/policies/cost_reduction.rs b/crates/phase-ai/src/policies/cost_reduction.rs new file mode 100644 index 0000000000..1c8eb1c562 --- /dev/null +++ b/crates/phase-ai/src/policies/cost_reduction.rs @@ -0,0 +1,217 @@ +//! `CostReductionPolicy` — makes a cost-reduction permanent a reason the AI can +//! see to deploy the discount BEFORE the spells it discounts. +//! +//! ## The gap this closes +//! +//! CR 601.2f: Goblin Electromancer, Baral, Foundry Inspector and the Medallion +//! cycle are acceleration that never taps for mana — every later spell costs +//! less for as long as the permanent survives. The engine already applies the +//! discount at cast time (`casting::collect_battlefield_cost_modifiers`), so the AI is never +//! overcharged; what it lacks is any reason to *sequence the reducer first*. +//! `RampTimingPolicy` supplies exactly that signal for permanents that add mana +//! (`Effect::Mana`, land fetch, extra land drops) and structurally cannot see a +//! cost reducer, so a deck whose entire acceleration plan is cost reduction gets +//! no sequencing guidance at all. This policy adds it. +//! +//! ## Performance +//! +//! `verdict()` runs per candidate per search node. The card-local check — do +//! this candidate's OWN statics carry a board-wide reduction of your spells — +//! runs FIRST and rejects every non-reducer candidate after reading one card's +//! AST. Only a confirmed reducer pays for the hand walk, which is bounded by +//! hand size and touches no battlefield sweep, no `find_legal_targets`, and no +//! affordability query. + +use engine::game::filter::{matches_target_filter, FilterContext}; +use engine::types::ability::TargetFilter; +use engine::types::card_type::CoreType; +use engine::types::game_state::GameState; +use engine::types::identifiers::ObjectId; +use engine::types::player::PlayerId; + +use crate::cast_facts::CastFacts; +use crate::features::cost_reduction::{ + live_your_spell_discounts, LiveDiscount, COST_REDUCTION_FLOOR, +}; +use crate::features::DeckFeatures; + +use super::context::PolicyContext; +use super::registry::{DecisionKind, PolicyId, PolicyReason, PolicyVerdict, TacticalPolicy}; + +pub struct CostReductionPolicy; + +/// Cap on how many future casts one deployment is credited for, so a full grip +/// cannot push a single reducer out of the intended band. +/// +/// `pub(crate)` so the bounded-score regression asserts against this constant +/// rather than a copied literal — raising the cap must move the test with it. +pub(crate) const MAX_REWARDED_FUTURE_CASTS: u32 = 4; + +/// Cap on the per-application generic discount credited, so a misparsed or +/// unusually large `amount` cannot dominate the candidate's prior. +pub(crate) const MAX_REWARDED_DISCOUNT: u32 = 3; + +impl TacticalPolicy for CostReductionPolicy { + fn id(&self) -> PolicyId { + PolicyId::CostReduction + } + + fn decision_kinds(&self) -> &'static [DecisionKind] { + &[DecisionKind::CastSpell] + } + + fn activation( + &self, + features: &DeckFeatures, + _state: &GameState, + _player: PlayerId, + ) -> Option { + if features.cost_reduction.commitment < COST_REDUCTION_FLOOR { + None + } else { + Some(features.cost_reduction.commitment) + } + } + + fn verdict(&self, ctx: &PolicyContext<'_>) -> PolicyVerdict { + let Some(facts) = ctx.cast_facts() else { + // CR 601.2 cast-shaped siblings (madness, miracle, foretell, copies) + // do not populate `cast_facts`, so there is no AST to classify. + return PolicyVerdict::neutral(PolicyReason::new("cost_reduction_na")); + }; + + // Card-local first: does THIS candidate carry a discount engine that + // would actually be applying right now (condition and dynamic multiplier + // resolved, not assumed)? + let discounts = live_your_spell_discounts( + ctx.state, + facts.object.id, + ctx.ai_player, + facts.object.static_definitions.iter_unchecked(), + ); + if !discounts.is_empty() { + // A discount only pays off on the spells it actually reduces, so + // count the grip through each reducer's own `spell_filter` rather + // than crediting every card in hand. + let discount: u32 = discounts.iter().map(|d| d.generic).sum(); + let future_casts = discountable_cards_in_hand(ctx, &discounts, facts.object.id); + if future_casts == 0 { + return PolicyVerdict::neutral(PolicyReason::new("cost_reduction_no_future_casts")); + } + let rewarded_casts = future_casts.min(MAX_REWARDED_FUTURE_CASTS); + let rewarded_discount = discount.min(MAX_REWARDED_DISCOUNT); + // CR 601.2f: each future cast saves `discount` generic mana; the + // configured weight converts saved mana into card-equivalents. + let saved_mana = f64::from(rewarded_discount * rewarded_casts); + return PolicyVerdict::score( + ctx.config.policy_penalties.cost_reduction_deploy_bonus * saved_mana, + PolicyReason::new("cost_reduction_deploy_engine") + .with_fact("discount", i64::from(discount)) + .with_fact("future_casts", i64::from(future_casts)), + ); + } + + // Otherwise: are we casting past an unplayed, cheaper reducer that would + // actually have discounted THIS spell? Deploying the discount first is + // strictly better sequencing — the same shape as + // `RampTimingPolicy::defer_to_ramp`. Both the mana-value gate and the + // spell-filter match are required, so a narrow reducer never penalizes a + // spell it cannot reduce. + if hand_holds_cheaper_reducer(ctx, &facts) { + return PolicyVerdict::score( + ctx.config.policy_penalties.cost_reduction_defer_penalty, + PolicyReason::new("cost_reduction_defer_to_engine") + .with_fact("mana_value", i64::from(facts.mana_value)), + ); + } + + PolicyVerdict::neutral(PolicyReason::new("cost_reduction_na")) + } +} + +/// CR 601.2f: does `filter` (a reducer's `spell_filter`; `None` = unfiltered) +/// admit the object `id` as a spell it discounts? +/// +/// Delegates to the engine's live object authority `matches_target_filter`, with +/// the reducer as the filter source so a `ControllerRef::You` scope resolves +/// against the reducer's controller exactly as it does at cast time. +fn filter_admits_object( + ctx: &PolicyContext<'_>, + filter: Option<&TargetFilter>, + source: ObjectId, + id: ObjectId, +) -> bool { + match filter { + None => true, + Some(filter) => matches_target_filter( + ctx.state, + id, + filter, + &FilterContext::from_source(ctx.state, source), + ), + } +} + +/// Cards in the AI's hand that at least one of `discounts` would actually +/// reduce, excluding `exclude` (the candidate itself, which is being spent now). +/// +/// Lands are excluded first because CR 305.1 land plays are not spells and are +/// never discounted by a CR 601.2f cost reducer — and that check is far cheaper +/// than a filter evaluation, so it runs before one. +fn discountable_cards_in_hand( + ctx: &PolicyContext<'_>, + discounts: &[LiveDiscount], + exclude: ObjectId, +) -> u32 { + let Some(player) = ctx.state.players.get(ctx.ai_player.0 as usize) else { + return 0; + }; + player + .hand + .iter() + .filter(|id| **id != exclude) + .filter(|id| { + ctx.state + .objects + .get(id) + .is_some_and(|obj| !obj.card_types.core_types.contains(&CoreType::Land)) + }) + .filter(|id| { + discounts.iter().any(|discount| { + filter_admits_object(ctx, discount.spell_filter.as_ref(), exclude, **id) + }) + }) + .count() + .try_into() + .unwrap_or(u32::MAX) +} + +/// True when the AI's hand still holds a live cost-reduction permanent that is +/// cheaper than this spell AND would actually discount it — i.e. the engine +/// could have been deployed first, to this very spell's benefit. +fn hand_holds_cheaper_reducer(ctx: &PolicyContext<'_>, facts: &CastFacts<'_>) -> bool { + let Some(player) = ctx.state.players.get(ctx.ai_player.0 as usize) else { + return false; + }; + player.hand.iter().any(|id| { + *id != facts.object.id + && ctx.state.objects.get(id).is_some_and(|obj| { + obj.effective_mana_value() < facts.mana_value + && live_your_spell_discounts( + ctx.state, + *id, + ctx.ai_player, + obj.static_definitions.iter_unchecked(), + ) + .iter() + .any(|discount| { + filter_admits_object( + ctx, + discount.spell_filter.as_ref(), + *id, + facts.object.id, + ) + }) + }) + }) +} diff --git a/crates/phase-ai/src/policies/mod.rs b/crates/phase-ai/src/policies/mod.rs index 0981759498..ca085f93de 100644 --- a/crates/phase-ai/src/policies/mod.rs +++ b/crates/phase-ai/src/policies/mod.rs @@ -14,6 +14,7 @@ mod condition_gated_activation; pub(crate) mod context; mod control_change_awareness; pub(crate) mod copy_value; +mod cost_reduction; mod crew_timing; mod cycling_discipline; mod devotion; diff --git a/crates/phase-ai/src/policies/registry.rs b/crates/phase-ai/src/policies/registry.rs index e6f5a18ba2..cfcdca48f3 100644 --- a/crates/phase-ai/src/policies/registry.rs +++ b/crates/phase-ai/src/policies/registry.rs @@ -151,6 +151,9 @@ pub enum PolicyId { CombatWithdrawal, /// CR 608.2c: "return a land you control" self-bounce target choice. SelfBounceTarget, + /// CR 601.2f: deploy a "spells you cast cost less" engine before the spells + /// it discounts. + CostReduction, /// CR 121.1: reward drawing into an on-battlefield "whenever you draw" engine. DrawPayoff, } @@ -409,6 +412,7 @@ impl Default for PolicyRegistry { Box::new(PayoffPolicy::new(&BLINK_PAYOFF)), Box::new(LoopShortcutPolicy), Box::new(super::self_bounce_target::SelfBounceTargetPolicy), + Box::new(super::cost_reduction::CostReductionPolicy), Box::new(super::draw_payoff::DrawPayoffPolicy), ]; let mut by_kind: HashMap> = HashMap::new(); diff --git a/crates/phase-ai/src/policies/tests/cost_reduction.rs b/crates/phase-ai/src/policies/tests/cost_reduction.rs new file mode 100644 index 0000000000..7c5b2dc53d --- /dev/null +++ b/crates/phase-ai/src/policies/tests/cost_reduction.rs @@ -0,0 +1,651 @@ +//! Unit tests for `policies::cost_reduction` — CR 601.2f cost-reduction +//! deployment policy. No `#[cfg(test)]` in SOURCE files; tests live here. +//! +//! Direct-`verdict` tests cover each branch; a registry-routed regression +//! exercises the production seam (registration + `CastSpell` routing). + +use std::sync::Arc; + +use engine::ai_support::{ActionMetadata, AiDecisionContext, CandidateAction, TacticalClass}; +use engine::game::zones::create_object; +use engine::types::ability::{ + ControllerRef, PlayerScope, QuantityRef, StaticCondition, StaticDefinition, TargetFilter, + TypeFilter, TypedFilter, +}; +use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; +use engine::types::format::FormatConfig; +use engine::types::game_state::{CastPaymentMode, GameState, WaitingFor}; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::mana::ManaCost; +use engine::types::player::PlayerId; +use engine::types::statics::{CostModifyMode, StaticMode}; +use engine::types::zones::Zone; + +use crate::config::AiConfig; +use crate::context::AiContext; +use crate::features::cost_reduction::{CostReductionFeature, COST_REDUCTION_FLOOR}; +use crate::features::DeckFeatures; +use crate::policies::context::{PolicyContext, SearchDepth}; +use crate::policies::cost_reduction::*; +use crate::policies::registry::{ + PolicyId, PolicyReason, PolicyRegistry, PolicyVerdict, TacticalPolicy, +}; +use crate::session::AiSession; + +const AI: PlayerId = PlayerId(0); + +fn state() -> GameState { + GameState::new(FormatConfig::standard(), 2, 42) +} + +fn generic(amount: u32) -> ManaCost { + ManaCost::Cost { + shards: Vec::new(), + generic: amount, + } +} + +/// A CR 601.2f board-wide reducer static scoped to spells YOU cast. +fn reduce_your_spells(amount: u32, mode: CostModifyMode) -> StaticDefinition { + let mut def = StaticDefinition::new(StaticMode::ModifyCost { + mode, + amount: generic(amount), + spell_filter: None, + dynamic_count: None, + }); + def.affected = Some(TargetFilter::Typed(TypedFilter { + controller: Some(ControllerRef::You), + ..Default::default() + })); + def +} + +/// A card in the AI's hand. `statics` are attached as live `static_definitions`, +/// which is what the policy classifies at decision time. +fn hand_card( + state: &mut GameState, + name: &str, + core: CoreType, + mana_value: u32, + statics: Vec, +) -> (ObjectId, CardId) { + let card_id = CardId(state.next_object_id); + let id = create_object(state, card_id, AI, name.to_string(), Zone::Hand); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(core); + obj.mana_cost = generic(mana_value); + for def in statics { + obj.static_definitions.push(def); + } + state.players[AI.0 as usize].hand.push_back(id); + (id, card_id) +} + +/// The Goblin Electromancer shape: a two-mana creature that discounts your spells. +fn reducer_in_hand(state: &mut GameState, mana_value: u32) -> (ObjectId, CardId) { + hand_card( + state, + "Cost Reducer", + CoreType::Creature, + mana_value, + vec![reduce_your_spells(1, CostModifyMode::Reduce)], + ) +} + +fn plain_spell_in_hand(state: &mut GameState, mana_value: u32) -> (ObjectId, CardId) { + hand_card( + state, + "Plain Spell", + CoreType::Sorcery, + mana_value, + Vec::new(), + ) +} + +fn land_in_hand(state: &mut GameState) -> (ObjectId, CardId) { + hand_card(state, "Island", CoreType::Land, 0, Vec::new()) +} + +fn feature(commitment: f32) -> CostReductionFeature { + CostReductionFeature { + reducer_count: 4, + total_discount: 4, + discounted_count: 20, + commitment, + } +} + +fn session(commitment: f32) -> AiSession { + let features = DeckFeatures { + cost_reduction: feature(commitment), + ..Default::default() + }; + let mut session = AiSession::empty(); + session.features.insert(AI, features); + session +} + +fn context(config: &AiConfig, session: AiSession) -> AiContext { + let mut context = AiContext::empty(&config.weights); + context.session = Arc::new(session); + context.player = AI; + context +} + +fn cast(object_id: ObjectId, card_id: CardId) -> CandidateAction { + CandidateAction { + action: GameAction::CastSpell { + object_id, + card_id, + targets: Vec::new(), + payment_mode: CastPaymentMode::default(), + }, + metadata: ActionMetadata::for_actor(Some(AI), TacticalClass::Spell), + } +} + +fn ctx<'a>( + state: &'a GameState, + candidate: &'a CandidateAction, + decision: &'a AiDecisionContext, + context: &'a AiContext, + config: &'a AiConfig, +) -> PolicyContext<'a> { + PolicyContext { + state, + decision, + candidate, + ai_player: AI, + config, + context, + cast_facts: None, + search_depth: SearchDepth::Root, + } +} + +fn priority_decision(candidate: &CandidateAction) -> AiDecisionContext { + AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + } +} + +fn score_of(verdict: PolicyVerdict) -> (f64, PolicyReason) { + match verdict { + PolicyVerdict::Score { delta, reason } => (delta, reason), + PolicyVerdict::Reject { reason } => panic!("unexpected Reject: {reason:?}"), + } +} + +// ─── activation ────────────────────────────────────────────────────────────── + +#[test] +fn activation_opts_out_below_floor() { + let features = DeckFeatures { + cost_reduction: feature(COST_REDUCTION_FLOOR - 0.01), + ..Default::default() + }; + assert!(CostReductionPolicy + .activation(&features, &state(), AI) + .is_none()); +} + +#[test] +fn activation_opts_in_above_floor() { + let features = DeckFeatures { + cost_reduction: feature(0.8), + ..Default::default() + }; + let activation = CostReductionPolicy.activation(&features, &state(), AI); + assert_eq!(activation, Some(0.8)); +} + +// ─── verdict: deploy the engine ────────────────────────────────────────────── + +#[test] +fn deploying_reducer_with_spells_in_hand_scores_positive() { + let mut st = state(); + let (reducer, card) = reducer_in_hand(&mut st, 2); + plain_spell_in_hand(&mut st, 3); + plain_spell_in_hand(&mut st, 4); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(reducer, card); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(CostReductionPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "cost_reduction_deploy_engine"); + assert!( + delta > 0.0, + "expected a positive deployment credit, got {delta}" + ); +} + +#[test] +fn deploying_reducer_with_empty_grip_is_neutral() { + let mut st = state(); + let (reducer, card) = reducer_in_hand(&mut st, 2); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(reducer, card); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(CostReductionPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "cost_reduction_no_future_casts"); + assert_eq!(delta, 0.0); +} + +#[test] +fn lands_in_hand_are_not_future_casts() { + // CR 305.1: playing a land is not casting a spell, so a grip of lands gives + // the reducer nothing to discount. + let mut st = state(); + let (reducer, card) = reducer_in_hand(&mut st, 2); + land_in_hand(&mut st); + land_in_hand(&mut st); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(reducer, card); + let decision = priority_decision(&candidate); + let (_, reason) = + score_of(CostReductionPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "cost_reduction_no_future_casts"); +} + +#[test] +fn deployment_credit_is_bounded_by_the_caps() { + // A huge grip and a huge discount must stay within the constants' product, + // so a stacked hand cannot push one deployment into the critical band. + let mut st = state(); + let (reducer, card) = hand_card( + &mut st, + "Big Reducer", + CoreType::Creature, + 2, + vec![reduce_your_spells(9, CostModifyMode::Reduce)], + ); + for _ in 0..12 { + plain_spell_in_hand(&mut st, 3); + } + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(reducer, card); + let decision = priority_decision(&candidate); + let (delta, _) = + score_of(CostReductionPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + let ceiling = config.policy_penalties.cost_reduction_deploy_bonus + * f64::from(MAX_REWARDED_DISCOUNT * MAX_REWARDED_FUTURE_CASTS); + assert!( + delta <= ceiling + f64::EPSILON, + "delta {delta} exceeded ceiling {ceiling}" + ); +} + +#[test] +fn taxing_permanent_is_not_a_deployment_engine() { + // Thalia raises costs (CR 601.2f); deploying her is not a discount plan. + let mut st = state(); + let (thalia, card) = hand_card( + &mut st, + "Thalia", + CoreType::Creature, + 2, + vec![reduce_your_spells(1, CostModifyMode::Raise)], + ); + plain_spell_in_hand(&mut st, 3); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(thalia, card); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(CostReductionPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "cost_reduction_na"); + assert_eq!(delta, 0.0); +} + +#[test] +fn self_cost_reduction_is_not_a_deployment_engine() { + // CR 113.6: "this spell costs {1} less" discounts only itself. + let mut st = state(); + let mut def = StaticDefinition::new(StaticMode::ModifyCost { + mode: CostModifyMode::Reduce, + amount: generic(1), + spell_filter: None, + dynamic_count: None, + }); + def.affected = Some(TargetFilter::SelfRef); + let (obj, card) = hand_card(&mut st, "Self Discount", CoreType::Artifact, 4, vec![def]); + plain_spell_in_hand(&mut st, 3); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(obj, card); + let decision = priority_decision(&candidate); + let (_, reason) = + score_of(CostReductionPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "cost_reduction_na"); +} + +// ─── verdict: defer to the engine ──────────────────────────────────────────── + +#[test] +fn casting_past_a_cheaper_reducer_is_penalized() { + let mut st = state(); + reducer_in_hand(&mut st, 2); + let (spell, card) = plain_spell_in_hand(&mut st, 4); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(spell, card); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(CostReductionPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "cost_reduction_defer_to_engine"); + assert!(delta < 0.0, "expected a sequencing penalty, got {delta}"); +} + +#[test] +fn casting_past_a_more_expensive_reducer_is_neutral() { + // The reducer does not fit ahead of this spell, so there is nothing to defer. + let mut st = state(); + reducer_in_hand(&mut st, 5); + let (spell, card) = plain_spell_in_hand(&mut st, 2); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(spell, card); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(CostReductionPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "cost_reduction_na"); + assert_eq!(delta, 0.0); +} + +#[test] +fn casting_with_no_reducer_in_hand_is_neutral() { + let mut st = state(); + let (spell, card) = plain_spell_in_hand(&mut st, 4); + plain_spell_in_hand(&mut st, 2); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(spell, card); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(CostReductionPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "cost_reduction_na"); + assert_eq!(delta, 0.0); +} + +// ─── production seam ───────────────────────────────────────────────────────── + +#[test] +fn registry_routes_cast_spell_to_this_policy() { + // Guards the wiring, not just the classifier: registration in + // `PolicyRegistry::default` plus `CastSpell` routing must reach `verdict`. + let mut st = state(); + let (reducer, card) = reducer_in_hand(&mut st, 2); + plain_spell_in_hand(&mut st, 3); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(reducer, card); + let decision = priority_decision(&candidate); + let verdicts = + PolicyRegistry::default().verdicts(&ctx(&st, &candidate, &decision, &context, &config)); + + let found = verdicts + .iter() + .find(|(id, _)| *id == PolicyId::CostReduction) + .map(|(_, verdict)| verdict.clone()) + .expect("CostReductionPolicy must be registered and routed for CastSpell"); + let (delta, reason) = score_of(found); + assert_eq!(reason.kind, "cost_reduction_deploy_engine"); + assert!(delta > 0.0); +} + +#[test] +fn registry_stays_silent_below_the_activation_floor() { + let mut st = state(); + let (reducer, card) = reducer_in_hand(&mut st, 2); + plain_spell_in_hand(&mut st, 3); + + let config = AiConfig::default(); + let context = context(&config, session(COST_REDUCTION_FLOOR - 0.01)); + let candidate = cast(reducer, card); + let decision = priority_decision(&candidate); + let verdicts = + PolicyRegistry::default().verdicts(&ctx(&st, &candidate, &decision, &context, &config)); + + assert!( + !verdicts + .iter() + .any(|(id, _)| *id == PolicyId::CostReduction), + "policy must not contribute below its activation floor" + ); +} + +// ─── review #6743: live gating + spell_filter narrowing ────────────────────── + +/// A reducer whose `spell_filter` restricts it to one core type. +fn typed_reducer_in_hand( + state: &mut GameState, + mana_value: u32, + only: TypeFilter, +) -> (ObjectId, CardId) { + let mut def = StaticDefinition::new(StaticMode::ModifyCost { + mode: CostModifyMode::Reduce, + amount: generic(1), + spell_filter: Some(TargetFilter::Typed(TypedFilter { + type_filters: vec![only], + controller: Some(ControllerRef::You), + ..Default::default() + })), + dynamic_count: None, + }); + def.affected = Some(TargetFilter::Typed(TypedFilter { + controller: Some(ControllerRef::You), + ..Default::default() + })); + hand_card( + state, + "Typed Reducer", + CoreType::Creature, + mana_value, + vec![def], + ) +} + +#[test] +fn deploy_credit_counts_only_spells_the_reducer_discounts() { + // An artifact-only reducer with a grip of sorceries discounts nothing. + let mut st = state(); + let (reducer, card) = typed_reducer_in_hand(&mut st, 2, TypeFilter::Artifact); + plain_spell_in_hand(&mut st, 3); + plain_spell_in_hand(&mut st, 4); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(reducer, card); + let decision = priority_decision(&candidate); + let (_, reason) = + score_of(CostReductionPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "cost_reduction_no_future_casts"); +} + +#[test] +fn deploy_credit_counts_matching_spells() { + // Same reducer, but the grip is artifacts — now it pays off. + let mut st = state(); + let (reducer, card) = typed_reducer_in_hand(&mut st, 2, TypeFilter::Artifact); + hand_card(&mut st, "Artifact A", CoreType::Artifact, 3, Vec::new()); + hand_card(&mut st, "Artifact B", CoreType::Artifact, 4, Vec::new()); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(reducer, card); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(CostReductionPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "cost_reduction_deploy_engine"); + assert!(delta > 0.0); +} + +#[test] +fn defer_penalty_does_not_fire_for_a_spell_the_reducer_cannot_reduce() { + // An artifact-only reducer must not penalize casting a sorcery. + let mut st = state(); + typed_reducer_in_hand(&mut st, 2, TypeFilter::Artifact); + let (spell, card) = plain_spell_in_hand(&mut st, 4); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(spell, card); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(CostReductionPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "cost_reduction_na"); + assert_eq!(delta, 0.0); +} + +#[test] +fn conditional_reducer_earns_no_deploy_credit() { + // CR 601.2f: an "as long as" gate has no truthful answer for a card still in + // hand, so the policy fails off rather than banking a discount it cannot + // guarantee. Mirrors the casting authority's condition gate. + let mut st = state(); + let mut def = reduce_your_spells(1, CostModifyMode::Reduce); + def.condition = Some(StaticCondition::IsPresent { + filter: Some(TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Artifact], + controller: Some(ControllerRef::You), + ..Default::default() + })), + }); + let (reducer, card) = hand_card(&mut st, "Conditional", CoreType::Creature, 2, vec![def]); + plain_spell_in_hand(&mut st, 3); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(reducer, card); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(CostReductionPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "cost_reduction_na"); + assert_eq!(delta, 0.0); +} + +#[test] +fn zero_dynamic_multiplier_earns_no_deploy_credit() { + // "costs {1} less for each card in your hand" with the multiplier resolving + // to zero discounts nothing, so it must not be credited. + let mut st = state(); + let mut def = reduce_your_spells(1, CostModifyMode::Reduce); + let StaticMode::ModifyCost { + ref mut dynamic_count, + .. + } = def.mode + else { + unreachable!("constructed as ModifyCost") + }; + *dynamic_count = Some(QuantityRef::GraveyardSize { + player: PlayerScope::Controller, + }); + let (reducer, card) = hand_card(&mut st, "Scaling", CoreType::Creature, 2, vec![def]); + plain_spell_in_hand(&mut st, 3); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(reducer, card); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(CostReductionPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + // Empty graveyard → multiplier 0 → no live discount at all. + assert_eq!(reason.kind, "cost_reduction_na"); + assert_eq!(delta, 0.0); +} + +#[test] +fn positive_dynamic_multiplier_scales_the_deploy_credit() { + // The same scaling reducer with a stocked graveyard credits more than the + // per-unit amount, matching the multiplier the resolver would apply. + let mut flat = state(); + let (flat_reducer, flat_card) = reducer_in_hand(&mut flat, 2); + plain_spell_in_hand(&mut flat, 3); + + let mut scaled = state(); + let mut def = reduce_your_spells(1, CostModifyMode::Reduce); + let StaticMode::ModifyCost { + ref mut dynamic_count, + .. + } = def.mode + else { + unreachable!("constructed as ModifyCost") + }; + *dynamic_count = Some(QuantityRef::GraveyardSize { + player: PlayerScope::Controller, + }); + let (scaled_reducer, scaled_card) = + hand_card(&mut scaled, "Scaling", CoreType::Creature, 2, vec![def]); + plain_spell_in_hand(&mut scaled, 3); + for _ in 0..3 { + let cid = CardId(scaled.next_object_id); + let id = create_object( + &mut scaled, + cid, + AI, + "Dead Card".to_string(), + Zone::Graveyard, + ); + scaled.players[AI.0 as usize].graveyard.push_back(id); + } + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + + let flat_candidate = cast(flat_reducer, flat_card); + let flat_decision = priority_decision(&flat_candidate); + let (flat_delta, _) = score_of(CostReductionPolicy.verdict(&ctx( + &flat, + &flat_candidate, + &flat_decision, + &context, + &config, + ))); + + let scaled_candidate = cast(scaled_reducer, scaled_card); + let scaled_decision = priority_decision(&scaled_candidate); + let (scaled_delta, reason) = score_of(CostReductionPolicy.verdict(&ctx( + &scaled, + &scaled_candidate, + &scaled_decision, + &context, + &config, + ))); + + assert_eq!(reason.kind, "cost_reduction_deploy_engine"); + assert!( + scaled_delta > flat_delta, + "multiplier>1 must out-score the flat reducer: {scaled_delta} vs {flat_delta}" + ); +} diff --git a/crates/phase-ai/src/policies/tests/mod.rs b/crates/phase-ai/src/policies/tests/mod.rs index e4356bd447..64b74f2e9d 100644 --- a/crates/phase-ai/src/policies/tests/mod.rs +++ b/crates/phase-ai/src/policies/tests/mod.rs @@ -3,6 +3,7 @@ pub mod activation_marker_lint; pub mod artifact_synergy; pub mod blink_payoff; +pub mod cost_reduction; pub mod devotion; pub mod draw_payoff; pub mod effect_classify_snapshot; From 899e50905e3509f1f7ff8f72d84102d84f807e44 Mon Sep 17 00:00:00 2001 From: Full Stack Developer <30417830+jsdevninja@users.noreply.github.com> Date: Wed, 29 Jul 2026 05:43:24 -0500 Subject: [PATCH 05/63] fix(engine): Fight Rigging exiles the targeted creature instead of countering it (#6437) (#6782) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(engine): Fight Rigging exiles the targeted creature instead of countering it (#6437) Root cause: the default sequential-sibling target-propagation arm in resolve_chain_body (has_independent_target_slot) treats any effect whose target filter reports no dedicated slot as "inherit the parent's chosen object". TargetFilter::ExiledBySource is a context-ref with no slot of its own (it resolves independently via exile_links), so a chain like "put a +1/+1 counter on target creature you control. Then ... you may play the exiled card" copied the counter's chosen creature into the CastFromZone sub's targets — treating the targeted creature as the card to license and exiling it instead of countering it. Same fix applied to should_propagate_parent_targets (the sibling authority used by the declined-optional-branch dispatcher), refined so a composed filter that ALSO references ParentTarget (Jodah's "put the rest ... except the hit" cleanup) still propagates as before. A second, sibling bug blocked the fix from being observable on Fight Rigging specifically: capture_linked_exile_snapshot (the leaves-the- battlefield ExiledBySource lookup used whenever a TRIGGERED ability resolves ExiledBySource) filtered to ExileLinkKind::TrackedBySource only, dropping Hideaway's own ExileLinkKind::HideawayLookable links even though Hideaway's own doc comment says the kind is meant to be found by the kind-agnostic ExiledBySource lookup, same as the live (non-snapshot) path. Also affects Collector's Cage, which carries the identical counter-then-exiled-card shape on an activated ability. Adds add_enchantment_from_oracle to the scenario test harness (mirrors add_land_from_oracle) so a permanent's own triggered ability can be driven without going through its cast/ETB pipeline. * test(engine): witness capture_linked_exile_snapshot on the real LTB path (#6437) Address review: the kind-widening fix in capture_linked_exile_snapshot (TrackedBySource-only -> every ExileLinkKind) had no test that actually drives a source through the real leaves-the-battlefield zone pipeline — the two existing Fight Rigging tests keep the source on the battlefield throughout, exercising a different (still-present) call path. Adds a regression test for Watcher for Tomorrow's real LTB trigger ("When this creature leaves the battlefield, put the exiled card into its owner's hand", Effect::ChangeZoneAll targeting ExiledBySource): casts a real removal spell to destroy it through the actual casting/zone pipeline (zones::move_to_zone), then asserts the HideawayLookable-linked hidden card reaches its owner's hand. Verified as a genuine discriminator by temporarily reverting the kind filter back to TrackedBySource-only and confirming the new test fails (hidden card stranded in exile) before re-applying the fix. --- crates/engine/src/game/effects/mod.rs | 62 +++- crates/engine/src/game/scenario.rs | 37 +++ crates/engine/src/game/zones.rs | 20 +- ...e_6437_fight_rigging_exiled_card_target.rs | 287 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 5 files changed, 398 insertions(+), 9 deletions(-) create mode 100644 crates/engine/tests/integration/issue_6437_fight_rigging_exiled_card_target.rs diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 607e8a05bb..c1f269c912 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -2450,12 +2450,41 @@ fn apply_parent_chain_context( /// NOT inherit an earlier instruction's already-chosen recipient, or every /// replicated instruction in a chain collapses onto whichever single object /// the first one picked (Kathril, Aspect Warper, issue #6321 / PR #6533). +/// +/// CR 406.6 + CR 607.2a (issue #6437, Fight Rigging / Collector's Cage): a sub +/// whose own effect targets a BARE `TargetFilter::ExiledBySource` resolves +/// that reference itself, at its own resolution, against this source's +/// durable `exile_links` (`cast_from_zone::resolve`'s no-target +/// `ExiledBySource` branch) — it never needs a pre-chosen object. "Put a +/// +1/+1 counter on target creature you control. Then ... you may play the +/// exiled card" chains a targeted clause before the linked-exile clause in +/// the SAME resolution; without this guard the counter's targeted creature +/// propagates into the exiled-card sub as if IT were the card to play, and +/// `CastFromZone` proceeds to grant a cast permission (and, for a +/// battlefield object, an exile-delivery move) on the targeted creature +/// instead of the hidden card. +/// +/// Gated on `!effect_refs_parent_target`: a COMPOSED filter like Jodah's +/// cleanup rider (`And { ExiledBySource, Typed(DistinctFrom { ParentTarget }) +/// }`, sweeping the "misses" while excluding the cast hit) also references +/// `ExiledBySource`, but STILL needs the propagated parent target so +/// `ParentTarget` can resolve to the hit and exclude it from the sweep — the +/// declined-branch dispatcher (this function's `Chaos-Wand cleanup` caller) +/// already ANDs this predicate with its own `effect_refs_parent_target` +/// check for exactly that reason, so excluding the composed case here too +/// would strand the exclusion and sweep the hit itself onto the library +/// bottom alongside the misses. /// Every other sub keeps today's behavior: parent targets propagate when the /// sub declares none of its own. fn should_propagate_parent_targets(ability: &ResolvedAbility, sub: &ResolvedAbility) -> bool { sub.targets.is_empty() && !ability.targets.is_empty() && sub.target_choice_timing != TargetChoiceTiming::Resolution + && !(sub + .effect + .target_filter() + .is_some_and(TargetFilter::references_exiled_by_source) + && !effect_refs_parent_target(&sub.effect)) } fn waits_for_resolution_choice(waiting_for: &WaitingFor) -> bool { @@ -10425,10 +10454,39 @@ fn resolve_chain_body( // gated subs ("When you discard a card this way, put a counter on target // Faerie") keep inheriting their selected target through the parent // chain; their condition decides whether the sub fires. + // + // CR 406.6 + CR 607.2a (issue #6437, Fight Rigging / Collector's + // Cage): a sub targeting a BARE `TargetFilter::ExiledBySource` + // ("the exiled card") is ALSO independent — it resolves its own + // object at its own resolution against this source's durable + // `exile_links` (`cast_from_zone::resolve`'s no-target + // `ExiledBySource` branch), never from an inherited object target. + // `extract_target_filter_from_effect` returns `None` for it (its + // `is_context_ref` guard), so without this arm it fell through to + // the default "no independent slot" case and inherited whatever + // object the PARENT clause targeted ("put a +1/+1 counter on + // target creature you control. Then ... you may play the exiled + // card") — treating the targeted creature as the card to license, + // which `grant_lingering_permissions` then routed through the + // exile-delivery batch instead of the hidden card. + // + // Gated on `!effect_refs_parent_target`: a COMPOSED filter like + // Jodah's cleanup rider (`And { ExiledBySource, DistinctFrom { + // ParentTarget } }`, sweeping the "misses" while excluding the + // cast hit) also references `ExiledBySource` but STILL needs the + // propagated parent target so `ParentTarget` can resolve to the + // hit and exclude it — treating it as independent here stranded + // that exclusion, sweeping the hit itself to the library bottom + // alongside the misses. let has_independent_target_slot = - crate::game::triggers::extract_target_filter_from_effect(&sub.effect).is_some() + (crate::game::triggers::extract_target_filter_from_effect(&sub.effect).is_some() && !effect_refs_parent_target(&sub.effect) - && !sub_ability_target_belongs_to_reflexive_context(sub); + && !sub_ability_target_belongs_to_reflexive_context(sub)) + || (sub + .effect + .target_filter() + .is_some_and(TargetFilter::references_exiled_by_source) + && !effect_refs_parent_target(&sub.effect)); sub_with_targets.targets = ability .targets .iter() diff --git a/crates/engine/src/game/scenario.rs b/crates/engine/src/game/scenario.rs index 676747b78d..819c29200b 100644 --- a/crates/engine/src/game/scenario.rs +++ b/crates/engine/src/game/scenario.rs @@ -644,6 +644,43 @@ impl GameScenario { builder } + /// Add a nonland, noncreature permanent (e.g. an enchantment) to the + /// battlefield with abilities parsed from Oracle text. Mirrors + /// `add_land_from_oracle`; needed for permanents whose own triggered/ + /// static abilities (not a cast) are under test — e.g. a Hideaway + /// enchantment's beginning-of-combat trigger. + pub fn add_enchantment_from_oracle( + &mut self, + player: PlayerId, + name: &str, + oracle_text: &str, + ) -> CardBuilder<'_> { + let card_id = CardId(self.state.next_object_id); + let id = create_object( + &mut self.state, + card_id, + player, + name.to_string(), + Zone::Battlefield, + ); + let ts = self.state.next_timestamp(); + let obj = self.state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Enchantment); + obj.base_card_types = obj.card_types.clone(); + obj.timestamp = ts; + // CR 302.6 note: summoning sickness only gates creatures, but the + // builder models a pre-existing permanent (entered on a prior turn), + // matching `add_land_from_oracle`'s override. + obj.summoning_sick = false; + + let mut builder = CardBuilder { + state: &mut self.state, + id, + }; + builder.from_oracle_text(oracle_text); + builder + } + /// Add a creature to hand with abilities parsed from Oracle text. pub fn add_creature_to_hand_from_oracle( &mut self, diff --git a/crates/engine/src/game/zones.rs b/crates/engine/src/game/zones.rs index 1ea1e3fa11..ca14353ef4 100644 --- a/crates/engine/src/game/zones.rs +++ b/crates/engine/src/game/zones.rs @@ -1421,6 +1421,18 @@ pub fn stamp_simultaneous_from_slice(state: &GameState, slice: &mut [GameEvent]) mark_simultaneous_departures(slice, &departed); } +/// CR 406.6 + CR 607.2a (issue #6437): Snapshot `source_id`'s linked exiles at +/// the moment it leaves the battlefield, for a leaves-the-battlefield +/// trigger's later `ExiledBySource` lookup (`filter.rs`'s `trigger_source. +/// is_some()` branch). Every `ExileLinkKind` is kind-agnostically readable via +/// `ExiledBySource` (`HideawayLookable`'s and `CraftMaterial`'s own doc +/// comments say so explicitly) and the LIVE lookup +/// (`players::linked_exile_cards_for_source`) does not filter by kind either — +/// this snapshot must match that surface exactly, or a card whose "play the +/// exiled card" clause resolves via a TRIGGERED ability (Fight Rigging's +/// begin-of-combat trigger, as opposed to Windbrisk Heights' activated +/// ability) silently finds nothing: Hideaway's link is `HideawayLookable`, and +/// a `TrackedBySource`-only filter here dropped it before the previous fix. pub(crate) fn capture_linked_exile_snapshot( state: &GameState, source_id: ObjectId, @@ -1433,13 +1445,7 @@ pub(crate) fn capture_linked_exile_snapshot( state .exile_links .iter() - .filter(|link| { - link.source_id == source_id - && matches!( - link.kind, - crate::types::game_state::ExileLinkKind::TrackedBySource - ) - }) + .filter(|link| link.source_id == source_id) .filter_map(|link| { state.objects.get(&link.exiled_id).and_then(|obj| { (obj.zone == Zone::Exile).then(|| crate::types::game_state::LinkedExileSnapshot { diff --git a/crates/engine/tests/integration/issue_6437_fight_rigging_exiled_card_target.rs b/crates/engine/tests/integration/issue_6437_fight_rigging_exiled_card_target.rs new file mode 100644 index 0000000000..a6186ace32 --- /dev/null +++ b/crates/engine/tests/integration/issue_6437_fight_rigging_exiled_card_target.rs @@ -0,0 +1,287 @@ +//! Issue #6437 — Fight Rigging: "Exiles the targeted creature instead of +//! putting a counter on it." +//! +//! Fight Rigging: "At the beginning of combat on your turn, put a +1/+1 +//! counter on target creature you control. Then if you control a creature +//! with power 7 or greater, you may play the exiled card without paying its +//! mana cost." Collector's Cage carries the identical shape on an activated +//! ability instead of a trigger. Both chain a TARGETED clause (`PutCounter`) +//! before a linked-exile clause (`CastFromZone { ExiledBySource }`) in the +//! SAME resolution. +//! +//! Root cause: the default target-propagation arm in `resolve_chain_body` +//! (`game/effects/mod.rs`, guarded by a `has_independent_target_slot` check) +//! copies a parent's already-chosen object targets into a following +//! sequential sibling whenever the sibling's own effect carries no +//! recognized "independent" target slot. `TargetFilter::ExiledBySource` is a +//! context-ref (`is_context_ref()`), so `extract_target_filter_from_effect` +//! reports no slot for it, and pre-fix that read as "not independent" — +//! copying the counter's chosen creature into the `CastFromZone` sub's empty +//! `targets`. `cast_from_zone::resolve` then found a non-empty `target_ids` +//! and never reached its own `ExiledBySource` → `exile_links` fallback, +//! instead processing the TARGETED CREATURE as the card to license: since the +//! creature sits on the battlefield (not Hand/Graveyard/Exile), +//! `grant_lingering_permissions` routed it through the exile-delivery batch, +//! moving the targeted creature into exile instead of giving it a counter. +//! `should_propagate_parent_targets` (the sibling authority used by the +//! declined-optional-branch dispatcher and others) carries the matching fix, +//! refined to keep composed filters that ALSO reference `ParentTarget` +//! (Jodah's "put the rest ... except the hit" cleanup) propagating as before. +//! +//! Oracle text is quoted verbatim from Scryfall (`client/public/card-data.json`). + +use engine::game::scenario::{GameRunner, GameScenario, P0}; +use engine::types::ability::{CastingPermission, TargetRef}; +use engine::types::actions::GameAction; +use engine::types::counter::CounterType; +use engine::types::game_state::{ExileLink, ExileLinkKind, WaitingFor}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +const FIGHT_RIGGING: &str = "Hideaway 5 (When this enchantment enters, look at the top five cards of your library, exile one face down, then put the rest on the bottom in a random order.)\nAt the beginning of combat on your turn, put a +1/+1 counter on target creature you control. Then if you control a creature with power 7 or greater, you may play the exiled card without paying its mana cost."; + +/// Drive the begin-combat trigger to completion: answer the counter's target +/// selection (when more than one legal creature forces an explicit prompt — +/// a lone legal creature auto-binds with no `TriggerTargetSelection` pause), +/// then (if the power-7 condition held) accept the optional free play. Loops +/// `advance_until_stack_empty` (which drains ordinary priority, +/// `OrderTriggers`, and library-bottom `EffectZoneChoice` prompts) against a +/// manual `OptionalEffectChoice` answer, since the driver has no generic +/// "accept every optional offer" policy. +fn resolve_begin_combat_trigger( + runner: &mut GameRunner, + counter_target: engine::types::identifiers::ObjectId, +) { + if matches!( + runner.state().waiting_for, + WaitingFor::TriggerTargetSelection { .. } + ) { + runner + .act(GameAction::SelectTargets { + targets: vec![TargetRef::Object(counter_target)], + }) + .expect("select the +1/+1 counter's target creature"); + } + + for _ in 0..10 { + if matches!( + runner.state().waiting_for, + WaitingFor::OptionalEffectChoice { .. } + ) { + runner + .act(GameAction::DecideOptionalEffect { accept: true }) + .expect("accept the optional free play of the hidden card"); + continue; + } + if runner.state().stack.is_empty() { + break; + } + runner.advance_until_stack_empty(); + } +} + +fn p1p1_counters(runner: &GameRunner, id: engine::types::identifiers::ObjectId) -> u32 { + runner + .state() + .objects + .get(&id) + .expect("object still present") + .counters + .get(&CounterType::Plus1Plus1) + .copied() + .unwrap_or(0) +} + +/// DISCRIMINATOR (condition met): a controlled 7-power creature satisfies +/// "you control a creature with power 7 or greater", so the granting +/// sub-ability offers the hidden card. Pre-fix this offer landed on the +/// TARGETED creature (Squire) instead of the linked hidden card, and Squire +/// was moved to exile rather than receiving its counter. +#[test] +fn fight_rigging_counters_the_target_and_offers_only_the_hidden_card() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let fight_rigging = scenario + .add_enchantment_from_oracle(P0, "Fight Rigging", FIGHT_RIGGING) + .id(); + // Satisfies "you control a creature with power 7 or greater" without + // being eligible as the counter's own target choice ambiguity: Squire is + // deliberately the ONLY creature selected below, proving the resolver + // doesn't just get lucky by counting the same object twice. + let big_beater = scenario.add_creature(P0, "Big Beater", 7, 7).id(); + let squire = scenario.add_creature(P0, "Squire", 1, 1).id(); + // Models an earlier turn's already-resolved Hideaway ETB: a card durably + // linked to Fight Rigging in exile (CR 406.6 + CR 607.2a), independent of + // this resolution's chosen targets. + let hidden = scenario.add_creature_to_exile(P0, "Hidden Card", 0, 0).id(); + + let mut runner = scenario.build(); + runner.state_mut().exile_links.push(ExileLink { + exiled_id: hidden, + source_id: fight_rigging, + kind: ExileLinkKind::HideawayLookable, + }); + + runner.pass_both_players(); + assert_eq!(runner.state().phase, Phase::BeginCombat); + assert!( + matches!( + runner.state().waiting_for, + WaitingFor::TriggerTargetSelection { .. } + ), + "reach-guard: begin-combat trigger must prompt for the counter's target, got {:?}", + runner.state().waiting_for + ); + + resolve_begin_combat_trigger(&mut runner, squire); + + assert_eq!( + p1p1_counters(&runner, squire), + 1, + "the TARGETED creature must receive the +1/+1 counter" + ); + assert_eq!( + runner.state().objects.get(&squire).map(|o| o.zone), + Some(Zone::Battlefield), + "the targeted creature must remain on the battlefield — it must never \ + be routed through the exiled-card's free-cast/exile-delivery path" + ); + assert!( + runner.state().objects[&squire] + .casting_permissions + .is_empty(), + "the targeted creature must not receive a casting permission meant for \ + the hidden card, got {:?}", + runner.state().objects[&squire].casting_permissions + ); + + let hidden_has_permission = runner.state().objects[&hidden] + .casting_permissions + .iter() + .any(|p| matches!(p, CastingPermission::ExileWithAltCost { granted_to: Some(p), .. } if *p == P0)); + assert!( + hidden_has_permission, + "the HIDDEN card (linked via exile_links) must receive the free-cast \ + permission, got {:?}", + runner.state().objects[&hidden].casting_permissions + ); + assert_eq!( + runner.state().objects.get(&hidden).map(|o| o.zone), + Some(Zone::Exile), + "the hidden card stays in exile — granting the permission is not a zone move" + ); + assert_eq!( + runner.state().objects.get(&big_beater).map(|o| o.zone), + Some(Zone::Battlefield), + "the power-7 creature that satisfied the condition is not itself a \ + target or subject of either clause and must be untouched" + ); +} + +/// CONTROL (condition NOT met): with no power-7 creature, only the counter +/// clause resolves — "nothing else happens" below the threshold. Guards +/// against a fix that accidentally engages the exiled-card path unconditionally. +#[test] +fn fight_rigging_below_power_threshold_only_places_the_counter() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let fight_rigging = scenario + .add_enchantment_from_oracle(P0, "Fight Rigging", FIGHT_RIGGING) + .id(); + let squire = scenario.add_creature(P0, "Squire", 1, 1).id(); + let hidden = scenario.add_creature_to_exile(P0, "Hidden Card", 0, 0).id(); + + let mut runner = scenario.build(); + runner.state_mut().exile_links.push(ExileLink { + exiled_id: hidden, + source_id: fight_rigging, + kind: ExileLinkKind::HideawayLookable, + }); + + runner.pass_both_players(); + // Squire is the only creature P0 controls, so "target creature you + // control" auto-binds to it with no `TriggerTargetSelection` pause + // (unlike the discriminator test above, which has two legal creatures). + resolve_begin_combat_trigger(&mut runner, squire); + + assert_eq!( + p1p1_counters(&runner, squire), + 1, + "the counter must still be placed with no power-7 creature in play" + ); + assert_eq!( + runner.state().objects.get(&squire).map(|o| o.zone), + Some(Zone::Battlefield) + ); + assert!( + runner.state().objects[&hidden] + .casting_permissions + .is_empty(), + "below the power threshold the hidden card must receive no permission, got {:?}", + runner.state().objects[&hidden].casting_permissions + ); + assert_eq!( + runner.state().objects.get(&hidden).map(|o| o.zone), + Some(Zone::Exile) + ); +} + +/// Issue #6437 (second root cause) — `capture_linked_exile_snapshot` +/// (`game/zones.rs`) must be kind-agnostic on the REAL leaves-the-battlefield +/// path, not only on the still-on-battlefield path the two tests above +/// exercise. Watcher for Tomorrow: "Hideaway 4 (...) This creature enters +/// tapped. When this creature leaves the battlefield, put the exiled card +/// into its owner's hand." — a pure LTB + `ExiledBySource` shape +/// (`Effect::ChangeZoneAll`, per the parser's +/// `hideaway_ltb_put_exiled_card_binds_exiled_by_source` test), unrelated to +/// the PutCounter target-propagation fix above and untouched by it. Casting a +/// real removal spell destroys Watcher through the actual casting/zone +/// pipeline (`zones::move_to_zone`), which is exactly the leaves-the- +/// battlefield departure `capture_linked_exile_snapshot` snapshots for the +/// trigger's later `ExiledBySource` lookup (`filter.rs`'s `trigger_source. +/// is_some()` branch). Pre-fix, that snapshot kept only `TrackedBySource` +/// links, silently dropping Hideaway's own `HideawayLookable` link and +/// leaving the hidden card stranded in exile instead of reaching the hand. +const WATCHER_FOR_TOMORROW: &str = "Hideaway 4 (When this creature enters, look at the top four cards of your library, exile one face down, then put the rest on the bottom in a random order.)\nThis creature enters tapped.\nWhen this creature leaves the battlefield, put the exiled card into its owner's hand."; + +#[test] +fn watcher_for_tomorrow_leaving_the_battlefield_finds_the_hideaway_linked_card() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let watcher = scenario + .add_creature_from_oracle(P0, "Watcher for Tomorrow", 2, 1, WATCHER_FOR_TOMORROW) + .id(); + // Models an earlier turn's already-resolved Hideaway ETB: a card durably + // linked to Watcher in exile (CR 406.6 + CR 607.2a) via the SAME + // `HideawayLookable` kind Fight Rigging uses. + let hidden = scenario.add_creature_to_exile(P0, "Hidden Card", 0, 0).id(); + let removal = scenario + .add_spell_to_hand_from_oracle(P0, "Destroy Spell", true, "Destroy target creature.") + .id(); + + let mut runner = scenario.build(); + runner.state_mut().exile_links.push(ExileLink { + exiled_id: hidden, + source_id: watcher, + kind: ExileLinkKind::HideawayLookable, + }); + + // Destroy Watcher through the REAL casting/resolution/zone pipeline — + // the battlefield departure this drives is exactly the + // `zones::move_to_zone` leaves-the-battlefield path under test, and the + // resulting LTB trigger is Watcher's own PARSED printed ability, not a + // hand-built stand-in. + let outcome = runner.cast(removal).target_objects(&[watcher]).resolve(); + + outcome.assert_zone(&[watcher], Zone::Graveyard); + assert_eq!( + outcome.state().objects.get(&hidden).map(|o| o.zone), + Some(Zone::Hand), + "the Hideaway-linked hidden card must reach its owner's hand via the \ + leaves-the-battlefield ExiledBySource lookup, got {:?}", + outcome.state().objects.get(&hidden).map(|o| o.zone) + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 46216a29fa..d03e17b7a4 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -617,6 +617,7 @@ mod issue_6403_moonmist_mass_transform; mod issue_6405_aang_multicolor_cost_reduction; mod issue_6416_extra_turn_resume_order; mod issue_6431_lava_dart_flashback_control_turn; +mod issue_6437_fight_rigging_exiled_card_target; mod issue_6440_mockingbird_uncast_copy_ceiling; mod issue_6498_portent_of_calamity; mod issue_6499_flickering_ward_protection_exemption; From 05c9999f46ff2a159ba79ec99b49caf731eab1ea Mon Sep 17 00:00:00 2001 From: phase-rs release bot Date: Wed, 29 Jul 2026 11:36:49 +0000 Subject: [PATCH 06/63] release: v0.41.0 --- Cargo.lock | 26 +++++++++++++------------- Cargo.toml | 2 +- client/package.json | 2 +- client/src-tauri/Cargo.lock | 2 +- client/src-tauri/Cargo.toml | 2 +- client/src-tauri/tauri.conf.json | 2 +- lobby-worker/broker-wasm/Cargo.lock | 4 ++-- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 97ead63b03..4eb3adc6dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -728,7 +728,7 @@ checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" [[package]] name = "draft-core" -version = "0.40.0" +version = "0.41.0" dependencies = [ "phase-engine", "rand 0.9.4", @@ -740,7 +740,7 @@ dependencies = [ [[package]] name = "draft-wasm" -version = "0.40.0" +version = "0.41.0" dependencies = [ "console_error_panic_hook", "draft-core", @@ -812,7 +812,7 @@ dependencies = [ [[package]] name = "engine-inventory-gen" -version = "0.40.0" +version = "0.41.0" dependencies = [ "anyhow", "proc-macro2", @@ -825,7 +825,7 @@ dependencies = [ [[package]] name = "engine-wasm" -version = "0.40.0" +version = "0.41.0" dependencies = [ "console_error_panic_hook", "getrandom 0.3.4", @@ -877,7 +877,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "feed-scraper" -version = "0.40.0" +version = "0.41.0" dependencies = [ "clap", "reqwest", @@ -1674,7 +1674,7 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lobby-broker" -version = "0.40.0" +version = "0.41.0" dependencies = [ "phase-engine", "serde", @@ -1711,7 +1711,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "manabrew-compat" -version = "0.40.0" +version = "0.41.0" dependencies = [ "manabrew-protocol", "phase-engine", @@ -1819,7 +1819,7 @@ dependencies = [ [[package]] name = "mtgish-import" -version = "0.40.0" +version = "0.41.0" dependencies = [ "anyhow", "phase-engine", @@ -2047,7 +2047,7 @@ dependencies = [ [[package]] name = "phase-ai" -version = "0.40.0" +version = "0.41.0" dependencies = [ "phase-engine", "rand 0.9.4", @@ -2063,7 +2063,7 @@ dependencies = [ [[package]] name = "phase-engine" -version = "0.40.0" +version = "0.41.0" dependencies = [ "assert_matches", "flate2", @@ -2094,7 +2094,7 @@ dependencies = [ [[package]] name = "phase-server" -version = "0.40.0" +version = "0.41.0" dependencies = [ "axum", "clap", @@ -2814,7 +2814,7 @@ dependencies = [ [[package]] name = "seat-reducer" -version = "0.40.0" +version = "0.41.0" dependencies = [ "phase-ai", "phase-engine", @@ -3003,7 +3003,7 @@ dependencies = [ [[package]] name = "server-core" -version = "0.40.0" +version = "0.41.0" dependencies = [ "draft-core", "lobby-broker", diff --git a/Cargo.toml b/Cargo.toml index f1703ce35d..f9535314e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = ["crates/*"] exclude = ["client/src-tauri", "lobby-worker/broker-wasm"] [workspace.package] -version = "0.40.0" +version = "0.41.0" license = "MIT OR Apache-2.0" [workspace.dependencies] diff --git a/client/package.json b/client/package.json index 971c4231bf..5f715543d8 100644 --- a/client/package.json +++ b/client/package.json @@ -1,7 +1,7 @@ { "name": "phase-rs-client", "private": true, - "version": "0.40.0", + "version": "0.41.0", "type": "module", "scripts": { "dev": "vite", diff --git a/client/src-tauri/Cargo.lock b/client/src-tauri/Cargo.lock index 262a966235..b4f1cb08bb 100644 --- a/client/src-tauri/Cargo.lock +++ b/client/src-tauri/Cargo.lock @@ -2616,7 +2616,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "phase-tauri" -version = "0.40.0" +version = "0.41.0" dependencies = [ "futures-util", "minisign-verify", diff --git a/client/src-tauri/Cargo.toml b/client/src-tauri/Cargo.toml index ee243ba21b..74e7fda5c6 100644 --- a/client/src-tauri/Cargo.toml +++ b/client/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "phase-tauri" -version = "0.40.0" +version = "0.41.0" edition = "2021" license = "MIT OR Apache-2.0" diff --git a/client/src-tauri/tauri.conf.json b/client/src-tauri/tauri.conf.json index d8a4f0fc65..90a606ded6 100644 --- a/client/src-tauri/tauri.conf.json +++ b/client/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/config.schema.json", "productName": "Phase", - "version": "0.40.0", + "version": "0.41.0", "identifier": "rs.phase.app", "build": { "frontendDist": "../bootstrap/dist", diff --git a/lobby-worker/broker-wasm/Cargo.lock b/lobby-worker/broker-wasm/Cargo.lock index d2ec4efd46..8316fdc0de 100644 --- a/lobby-worker/broker-wasm/Cargo.lock +++ b/lobby-worker/broker-wasm/Cargo.lock @@ -186,7 +186,7 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "lobby-broker" -version = "0.40.0" +version = "0.41.0" dependencies = [ "phase-engine", "serde", @@ -239,7 +239,7 @@ dependencies = [ [[package]] name = "phase-engine" -version = "0.40.0" +version = "0.41.0" dependencies = [ "im", "indexmap", From 309d7c49f28f4f3a4f35a5a8192a8f44bbb69afb Mon Sep 17 00:00:00 2001 From: Clayton <118192227+claytonlin1110@users.noreply.github.com> Date: Wed, 29 Jul 2026 06:41:44 -0500 Subject: [PATCH 07/63] fix/6435 mosswort bridge hideaway playfromexile (#6784) * fix(engine): stamp PlayFromExile for play-mode CastFromZone A play-mode CastFromZone (e.g. hideaway) must grant face-down exile look/play authority via CastingPermission::PlayFromExile. This fixes Mosswort Bridge and adds integration coverage. Co-authored-by: Cursor * fix(tests): satisfy clippy option checks --------- Co-authored-by: Cursor Co-authored-by: matthewevans Co-authored-by: Clayton --- .../engine/src/game/effects/cast_from_zone.rs | 143 +++++- ...brisk_heights_hideaway_exiled_by_source.rs | 29 ++ ...ssue_6435_mosswort_bridge_hideaway_play.rs | 448 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 4 files changed, 620 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/integration/issue_6435_mosswort_bridge_hideaway_play.rs diff --git a/crates/engine/src/game/effects/cast_from_zone.rs b/crates/engine/src/game/effects/cast_from_zone.rs index 218aafe81a..800a6ab934 100644 --- a/crates/engine/src/game/effects/cast_from_zone.rs +++ b/crates/engine/src/game/effects/cast_from_zone.rs @@ -8,7 +8,8 @@ use crate::types::events::GameEvent; use crate::types::game_state::{BatchCompletion, CastingVariant, GameState, WaitingFor}; use crate::types::identifiers::ObjectId; use crate::types::mana::ManaCost; -use crate::types::zones::Zone; +use crate::types::statics::CastFrequency; +use crate::types::zones::{EtbTapState, Zone}; /// CR 400.1/400.2: Recursively extract a filter's own `controller` axis, /// looking through the composed forms (`Not`/`And`/`Or`) a real card's target @@ -1135,6 +1136,7 @@ fn record_lingering_permissions( target_ids: &[ObjectId], ) -> Result<(), EffectError> { let ( + mode, without_paying, cast_transformed, alt_ability_cost, @@ -1143,6 +1145,7 @@ fn record_lingering_permissions( mana_spend_permission, ) = match &ability.effect { Effect::CastFromZone { + mode, without_paying_mana_cost, cast_transformed, alt_ability_cost, @@ -1151,6 +1154,7 @@ fn record_lingering_permissions( mana_spend_permission, .. } => ( + *mode, *without_paying_mana_cost, *cast_transformed, alt_ability_cost.clone(), @@ -1253,6 +1257,41 @@ fn record_lingering_permissions( if !obj.casting_permissions.contains(&permission) { obj.casting_permissions.push(permission); } + + // CR 305.1: A `CastFromZone` in `mode: Play` must also authorize + // playing the card when it is a land. The look-to-play contract + // for face-down exile is keyed on `CastingPermission::PlayFromExile` + // (CR 406.3a + CR 406.3b), not on `ExileWithAltCost` alone. + if matches!(mode, crate::types::ability::CardPlayMode::Play) + && alt_ability_cost.is_none() + { + // CR 305.1: lands are played (not cast) but still require + // face-down exile look/play authority. + let play_duration = duration.clone().unwrap_or_else(|| { + matches!(current_zone, Some(Zone::Graveyard | Zone::Hand)) + .then_some(Duration::UntilEndOfTurn) + .unwrap_or(Duration::Permanent) + }); + + let play_permission = CastingPermission::PlayFromExile { + duration: play_duration, + granted_to: ability.controller, + frequency: CastFrequency::Unlimited, + source_id: Some(ability.source_id), + invalidation: None, + exiled_by_ability_controller: Some(ability.controller), + mana_spend_permission, + card_filter: None, + single_use_group: None, + single_use: false, + cast_cost_raise: None, + land_enter_tapped: EtbTapState::Unspecified, + }; + + if !obj.casting_permissions.contains(&play_permission) { + obj.casting_permissions.push(play_permission); + } + } } } Ok(()) @@ -1330,6 +1369,108 @@ mod tests { obj_id } + #[test] + fn play_mode_without_paying_stamps_zero_cost_cast_and_play_from_exile() { + let mut state = make_test_state(); + let obj_id = add_card_to_exile(&mut state, PlayerId(1), CardId(100)); + + let ability = ResolvedAbility::new( + Effect::CastFromZone { + target: TargetFilter::Any, + without_paying_mana_cost: true, + mode: CardPlayMode::Play, + cast_transformed: false, + alt_ability_cost: None, + constraint: None, + duration: None, + driver: crate::types::ability::CastFromZoneDriver::LingeringPermission, + mana_spend_permission: None, + }, + vec![TargetRef::Object(obj_id)], + ObjectId(999), + PlayerId(0), + ); + + let mut events = vec![]; + resolve(&mut state, &ability, &mut events).unwrap(); + + let obj = state.objects.get(&obj_id).unwrap(); + + assert!( + obj.casting_permissions.iter().any(|p| matches!( + p, + CastingPermission::ExileWithAltCost { + cost, + granted_to: Some(PlayerId(0)), + duration: None, + .. + } if *cost == ManaCost::zero() + )), + "play-mode without-paying must grant a zero-cost exile alt-cost cast permission" + ); + + assert!( + obj.casting_permissions.iter().any(|p| matches!( + p, + CastingPermission::PlayFromExile { + duration: Duration::Permanent, + granted_to, + source_id: Some(ObjectId(999)), + exiled_by_ability_controller: Some(PlayerId(0)), + mana_spend_permission: None, + card_filter: None, + .. + } if *granted_to == PlayerId(0) + )), + "play-mode without-paying must also stamp PlayFromExile for face-down exile look/play" + ); + } + + #[test] + fn play_mode_with_alt_ability_cost_does_not_stamp_play_from_exile() { + let mut state = make_test_state(); + let obj_id = add_card_to_exile(&mut state, PlayerId(1), CardId(101)); + + let ability = ResolvedAbility::new( + Effect::CastFromZone { + target: TargetFilter::Any, + without_paying_mana_cost: false, + mode: CardPlayMode::Play, + cast_transformed: false, + alt_ability_cost: Some( + crate::types::ability::AbilityCost::KeywordCostOfCastSpell { + keyword: crate::types::keywords::KeywordKind::Suspend, + }, + ), + constraint: None, + duration: None, + driver: crate::types::ability::CastFromZoneDriver::LingeringPermission, + mana_spend_permission: None, + }, + vec![TargetRef::Object(obj_id)], + ObjectId(999), + PlayerId(0), + ); + + let mut events = vec![]; + resolve(&mut state, &ability, &mut events).unwrap(); + + let obj = state.objects.get(&obj_id).unwrap(); + assert!( + !obj.casting_permissions.iter().any(|p| matches!( + p, + CastingPermission::PlayFromExile { .. } + )), + "play-mode with alt-ability cost is a spell-cost override; it must not accidentally grant PlayFromExile" + ); + assert!( + obj.casting_permissions + .iter() + .any(|p| matches!(p, CastingPermission::ExileWithAltAbilityCost { .. })), + "play-mode with alt-ability cost must still grant ExileWithAltAbilityCost" + ); + } + fn electrodominance_hand_ability(max_value: i32) -> ResolvedAbility { ResolvedAbility::new( Effect::CastFromZone { diff --git a/crates/engine/tests/integration/issue_3246_windbrisk_heights_hideaway_exiled_by_source.rs b/crates/engine/tests/integration/issue_3246_windbrisk_heights_hideaway_exiled_by_source.rs index 813cbe08ad..9e074885f7 100644 --- a/crates/engine/tests/integration/issue_3246_windbrisk_heights_hideaway_exiled_by_source.rs +++ b/crates/engine/tests/integration/issue_3246_windbrisk_heights_hideaway_exiled_by_source.rs @@ -233,6 +233,28 @@ fn windbrisk_heights_plays_correct_hidden_card_across_unrelated_tracked_set_acti via ExiledBySource, got {:?}", runner.state().objects[&shock].casting_permissions ); + let shock_has_play_from_exile_permission = runner.state().objects[&shock] + .casting_permissions + .iter() + .any(|p| { + matches!( + p, + CastingPermission::PlayFromExile { + granted_to, + exiled_by_ability_controller, + card_filter, + .. + } if *granted_to == P0 + && *exiled_by_ability_controller == Some(P0) + && card_filter.is_none() + ) + }); + assert!( + shock_has_play_from_exile_permission, + "Windbrisk must also grant a PlayFromExile look/play permission on the hidden Shock \ + (face-down exile authority), got {:?}", + runner.state().objects[&shock].casting_permissions + ); assert!( runner.state().objects[&bolt_decoy] .casting_permissions @@ -240,6 +262,13 @@ fn windbrisk_heights_plays_correct_hidden_card_across_unrelated_tracked_set_acti "the unrelated TrackedSet(0) decoy must NOT receive a play permission, got {:?}", runner.state().objects[&bolt_decoy].casting_permissions ); + assert!( + !runner.state().objects[&bolt_decoy] + .casting_permissions + .iter() + .any(|p| matches!(p, CastingPermission::PlayFromExile { .. })), + "the unrelated TrackedSet(0) decoy must not receive PlayFromExile" + ); assert_eq!( runner.state().objects.get(&bolt_decoy).map(|o| o.zone), Some(Zone::Exile), diff --git a/crates/engine/tests/integration/issue_6435_mosswort_bridge_hideaway_play.rs b/crates/engine/tests/integration/issue_6435_mosswort_bridge_hideaway_play.rs new file mode 100644 index 0000000000..dd7ea84e1f --- /dev/null +++ b/crates/engine/tests/integration/issue_6435_mosswort_bridge_hideaway_play.rs @@ -0,0 +1,448 @@ +//! Issue #6435: Mosswort Bridge hideaway "play the exiled card" must +//! actually grant face-down exile look/play authority (CR 406.6 / 607.1 / +//! 607.2a / 702.75a) and allow both: +//! - casting the hidden spell without paying mana cost +//! - playing the hidden land as a land drop +//! +//! Root cause: `Effect::CastFromZone { mode: Play, ... }` currently granted +//! only `CastingPermission::ExileWithAltCost` and missed the `PlayFromExile` +//! authority needed for face-down exile actions. + +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::game::scenario_db::GameScenarioDbExt; +use engine::types::ability::CastingPermission; +use engine::types::actions::GameAction; +use engine::types::game_state::{ExileLinkKind, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaCost, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +use crate::support::shared_card_db as load_db; + +fn fund_green(runner: &mut GameRunner) { + let dummy = ObjectId(0); + let pool = &mut runner + .state_mut() + .players + .iter_mut() + .find(|p| p.id == P0) + .unwrap() + .mana_pool; + pool.add(ManaUnit::new(ManaType::Green, dummy, false, vec![])); +} + +fn play_mosswort_and_hide(runner: &mut GameRunner, mosswort: ObjectId, hidden: ObjectId) { + let mosswort_card_id = runner.state().objects[&mosswort].card_id; + runner + .act(GameAction::PlayLand { + object_id: mosswort, + card_id: mosswort_card_id, + }) + .expect("playing Mosswort Bridge as land must be legal"); + + let mut saw_dig_choice = false; + for _ in 0..128 { + match runner.state().waiting_for.clone() { + WaitingFor::DigChoice { cards, .. } => { + saw_dig_choice = true; + assert!( + cards.contains(&hidden), + "reach-guard: the hidden card must be offered by the Hideaway pick" + ); + runner + .act(GameAction::SelectCards { + cards: vec![hidden], + }) + .expect("selecting the Hideaway card must succeed"); + } + WaitingFor::Priority { .. } => { + if runner.state().stack.is_empty() && saw_dig_choice { + break; + } + runner.act(GameAction::PassPriority).expect("pass priority"); + } + other => panic!("unexpected waiting state while driving Hideaway ETB: {other:?}"), + } + } + assert!(saw_dig_choice, "Hideaway must surface a DigChoice prompt"); +} + +fn activate_mosswort_second_ability( + runner: &mut GameRunner, + mosswort: ObjectId, + accept: bool, +) -> bool { + let activate = runner.act(GameAction::ActivateAbility { + source_id: mosswort, + ability_index: 1, + }); + if let Err(e) = &activate { + panic!( + "Mosswort Bridge second ability must be activatable: {:?}. phase={:?} waiting_for={:?} active={:?} priority_player={:?}", + e, + runner.state().phase, + runner.state().waiting_for, + runner.state().active_player, + runner.state().priority_player + ); + } + activate.expect("Mosswort Bridge second ability must be activatable"); + + let mut saw_optional = false; + for _ in 0..128 { + match runner.state().waiting_for.clone() { + WaitingFor::OptionalEffectChoice { .. } => { + saw_optional = true; + runner + .act(GameAction::DecideOptionalEffect { accept }) + .expect("deciding the optional offer must succeed"); + } + WaitingFor::Priority { .. } => { + if runner.state().stack.is_empty() { + break; + } + runner.act(GameAction::PassPriority).expect("pass priority"); + } + other => { + panic!("unexpected waiting state while driving Mosswort activation: {other:?}") + } + } + } + saw_optional +} + +fn hidden_is_face_down_exiled_linked_to_source( + runner: &GameRunner, + hidden: ObjectId, + mosswort: ObjectId, +) { + let obj = runner.state().objects.get(&hidden).unwrap(); + assert_eq!(obj.zone, Zone::Exile, "hidden card must be in exile"); + assert!(obj.face_down, "hidden card must be face down (Hideaway)"); + assert!( + runner.state().exile_links.iter().any(|link| { + link.exiled_id == hidden + && link.source_id == mosswort + && link.kind == ExileLinkKind::HideawayLookable + }), + "hidden card must be linked to Mosswort Bridge via HideawayLookable" + ); +} + +#[test] +fn mosswort_bridge_hideaway_free_casts_hidden_spell_when_total_power_ge_10() { + let Some(db) = load_db() else { + return; + }; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let mosswort = scenario.add_real_card(P0, "Mosswort Bridge", Zone::Hand, db); + let hidden_spell = scenario.add_real_card(P0, "Shock", Zone::Library, db); + // Hideaway 4 looks at the top 4 cards; seed enough so P0 doesn't lose + // from an empty library after the hideaway exile removes the chosen card. + scenario.add_real_card(P0, "Forest", Zone::Library, db); + scenario.add_real_card(P0, "Island", Zone::Library, db); + scenario.add_real_card(P0, "Mountain", Zone::Library, db); + // Prevent the game from ending on P1's next draw step due to an empty library. + scenario.add_real_card(P1, "Island", Zone::Library, db); + + // Total power >= 10 gate. + scenario.add_creature(P0, "Power 10", 10, 1); + + let mut runner = scenario.build(); + engine::game::rehydrate_game_from_card_db(runner.state_mut(), db); + + // Turn 1: play Mosswort; resolve Hideaway ETB to exile the Shock. + play_mosswort_and_hide(&mut runner, mosswort, hidden_spell); + hidden_is_face_down_exiled_linked_to_source(&runner, hidden_spell, mosswort); + + // Advance to the next turn so Mosswort is untapped. + runner.advance_to_end_step(); + runner.advance_to_upkeep(); + runner.advance_to_phase(Phase::PreCombatMain); + if matches!( + runner.state().waiting_for, + WaitingFor::DeclareAttackers { .. } + ) { + // There are no Hideaway-related requirements for combat in this test; + // declare no attackers so we can reach the next untap/main window. + runner + .act(GameAction::DeclareAttackers { + attacks: vec![], + bands: vec![], + }) + .expect("declare no attackers"); + runner.advance_to_phase(Phase::PreCombatMain); + } + if runner.state().priority_player != P0 { + runner.advance_to_end_step(); + runner.advance_to_upkeep(); + runner.advance_to_phase(Phase::PreCombatMain); + if matches!( + runner.state().waiting_for, + WaitingFor::DeclareAttackers { .. } + ) { + runner + .act(GameAction::DeclareAttackers { + attacks: vec![], + bands: vec![], + }) + .expect("declare no attackers"); + runner.advance_to_phase(Phase::PreCombatMain); + } + } + + fund_green(&mut runner); + + // Activate, accept the optional "you may play" offer. + let saw_optional = activate_mosswort_second_ability(&mut runner, mosswort, true); + assert!(saw_optional, "power>=10 must surface the optional offer"); + + // The hidden spell must have both the free-cast permission and the + // face-down exile look/play authority. + let obj = runner.state().objects.get(&hidden_spell).unwrap(); + assert!( + obj.casting_permissions.iter().any(|p| matches!( + p, + CastingPermission::ExileWithAltCost { + cost, + granted_to: Some(P0), + duration: None, + .. + } if *cost == ManaCost::zero() + )), + "Mosswort must stamp a zero-cost ExileWithAltCost permission on the hidden spell" + ); + assert!( + obj.casting_permissions.iter().any(|p| matches!( + p, + CastingPermission::PlayFromExile { + granted_to, + exiled_by_ability_controller, + card_filter, + source_id: Some(src), + .. + } if *granted_to == P0 + && *exiled_by_ability_controller == Some(P0) + && card_filter.is_none() + && *src == mosswort + )), + "Mosswort must also stamp PlayFromExile so the player can look/cast the face-down exiled spell" + ); + + let life_before = runner.state().players[1].life; + let mana_before = runner.state().players[0].mana_pool.total(); + + let hidden_card_id = obj.card_id; + runner + .act(GameAction::CastSpell { + object_id: hidden_spell, + card_id: hidden_card_id, + targets: vec![], + payment_mode: engine::types::game_state::CastPaymentMode::Auto, + }) + .expect("cast of the face-down exiled hidden spell must be accepted"); + + // Drive target selection / stack settlement. + for _ in 0..128 { + match runner.state().waiting_for.clone() { + WaitingFor::TargetSelection { .. } | WaitingFor::TriggerTargetSelection { .. } => { + runner + .act(GameAction::ChooseTarget { + target: Some(engine::types::ability::TargetRef::Player(P1)), + }) + .expect("choose shock target"); + } + WaitingFor::Priority { .. } if runner.state().stack.is_empty() => break, + WaitingFor::Priority { .. } => { + runner.act(GameAction::PassPriority).expect("pass priority"); + } + other => panic!("unexpected waiting state while settling hidden spell cast: {other:?}"), + } + } + runner.advance_until_stack_empty(); + + assert_eq!( + runner.state().players[1].life, + life_before - 2, + "Shock must resolve and deal 2 damage to the targeted player" + ); + assert_eq!( + runner.state().players[0].mana_pool.total(), + mana_before, + "casting the hidden spell without paying mana cost must not drain mana pool" + ); +} + +#[test] +fn mosswort_bridge_hideaway_free_plays_hidden_land_when_total_power_ge_10() { + let Some(db) = load_db() else { + return; + }; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let mosswort = scenario.add_real_card(P0, "Mosswort Bridge", Zone::Hand, db); + let hidden_land = scenario.add_real_card(P0, "Forest", Zone::Library, db); + scenario.add_real_card(P0, "Island", Zone::Library, db); + scenario.add_real_card(P0, "Mountain", Zone::Library, db); + scenario.add_real_card(P0, "Swamp", Zone::Library, db); + scenario.add_real_card(P1, "Island", Zone::Library, db); + scenario.add_creature(P0, "Power 10", 10, 1); + + let mut runner = scenario.build(); + engine::game::rehydrate_game_from_card_db(runner.state_mut(), db); + + play_mosswort_and_hide(&mut runner, mosswort, hidden_land); + hidden_is_face_down_exiled_linked_to_source(&runner, hidden_land, mosswort); + + runner.advance_to_end_step(); + runner.advance_to_upkeep(); + runner.advance_to_phase(Phase::PreCombatMain); + if matches!( + runner.state().waiting_for, + WaitingFor::DeclareAttackers { .. } + ) { + runner + .act(GameAction::DeclareAttackers { + attacks: vec![], + bands: vec![], + }) + .expect("declare no attackers"); + runner.advance_to_phase(Phase::PreCombatMain); + } + if runner.state().priority_player != P0 { + runner.advance_to_end_step(); + runner.advance_to_upkeep(); + runner.advance_to_phase(Phase::PreCombatMain); + if matches!( + runner.state().waiting_for, + WaitingFor::DeclareAttackers { .. } + ) { + runner + .act(GameAction::DeclareAttackers { + attacks: vec![], + bands: vec![], + }) + .expect("declare no attackers"); + runner.advance_to_phase(Phase::PreCombatMain); + } + } + + fund_green(&mut runner); + let saw_optional = activate_mosswort_second_ability(&mut runner, mosswort, true); + assert!(saw_optional, "power>=10 must surface the optional offer"); + + let hidden_card_id = runner.state().objects[&hidden_land].card_id; + assert!( + runner.state().objects[&hidden_land].face_down, + "reach-guard: the hidden land is still face down before playing it" + ); + let lands_before = runner.state().lands_played_this_turn; + + runner + .act(GameAction::PlayLand { + object_id: hidden_land, + card_id: hidden_card_id, + }) + .expect("playing the face-down exiled hidden land must be accepted"); + + assert_eq!( + runner.state().objects[&hidden_land].zone, + Zone::Battlefield, + "playing the hidden land must move it to the battlefield" + ); + assert!( + !runner.state().objects[&hidden_land].face_down, + "playing a land must turn it face up on entry" + ); + assert_eq!( + runner.state().lands_played_this_turn, + lands_before + 1, + "playing the hidden land consumes exactly one land play action" + ); +} + +#[test] +fn mosswort_bridge_hideaway_with_power_lt_10_offers_nothing() { + let Some(db) = load_db() else { + return; + }; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let mosswort = scenario.add_real_card(P0, "Mosswort Bridge", Zone::Hand, db); + let hidden_spell = scenario.add_real_card(P0, "Shock", Zone::Library, db); + scenario.add_real_card(P1, "Island", Zone::Library, db); + scenario.add_real_card(P0, "Forest", Zone::Library, db); + scenario.add_real_card(P0, "Island", Zone::Library, db); + scenario.add_real_card(P0, "Mountain", Zone::Library, db); + + // Total power < 10. + scenario.add_creature(P0, "Power 9", 9, 1); + + let mut runner = scenario.build(); + engine::game::rehydrate_game_from_card_db(runner.state_mut(), db); + + play_mosswort_and_hide(&mut runner, mosswort, hidden_spell); + hidden_is_face_down_exiled_linked_to_source(&runner, hidden_spell, mosswort); + + runner.advance_to_end_step(); + runner.advance_to_upkeep(); + runner.advance_to_phase(Phase::PreCombatMain); + if matches!( + runner.state().waiting_for, + WaitingFor::DeclareAttackers { .. } + ) { + runner + .act(GameAction::DeclareAttackers { + attacks: vec![], + bands: vec![], + }) + .expect("declare no attackers"); + runner.advance_to_phase(Phase::PreCombatMain); + } + if runner.state().priority_player != P0 { + runner.advance_to_end_step(); + runner.advance_to_upkeep(); + runner.advance_to_phase(Phase::PreCombatMain); + if matches!( + runner.state().waiting_for, + WaitingFor::DeclareAttackers { .. } + ) { + runner + .act(GameAction::DeclareAttackers { + attacks: vec![], + bands: vec![], + }) + .expect("declare no attackers"); + runner.advance_to_phase(Phase::PreCombatMain); + } + } + + fund_green(&mut runner); + let saw_optional = activate_mosswort_second_ability(&mut runner, mosswort, true); + assert!( + !saw_optional, + "power<10 must not surface the optional \"you may play\" offer" + ); + + let obj = runner.state().objects.get(&hidden_spell).unwrap(); + assert!( + obj.casting_permissions + .iter() + .all(|p| !matches!(p, CastingPermission::ExileWithAltCost { .. })), + "power<10 must not grant ExileWithAltCost to the hidden spell" + ); + assert!( + obj.casting_permissions + .iter() + .all(|p| !matches!(p, CastingPermission::PlayFromExile { .. })), + "power<10 must not grant PlayFromExile to the hidden spell" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index d03e17b7a4..cee9f2fbb6 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -617,6 +617,7 @@ mod issue_6403_moonmist_mass_transform; mod issue_6405_aang_multicolor_cost_reduction; mod issue_6416_extra_turn_resume_order; mod issue_6431_lava_dart_flashback_control_turn; +mod issue_6435_mosswort_bridge_hideaway_play; mod issue_6437_fight_rigging_exiled_card_target; mod issue_6440_mockingbird_uncast_copy_ceiling; mod issue_6498_portent_of_calamity; From 4f2ff6eba08f78da44201c86a2661939a325be78 Mon Sep 17 00:00:00 2001 From: Full Stack Developer <30417830+jsdevninja@users.noreply.github.com> Date: Wed, 29 Jul 2026 07:42:50 -0500 Subject: [PATCH 08/63] fix(engine): repeated Choose(Opponent)/Choose(Player) picks are independent by default (#6381) (#6747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(engine): repeated Choose(Opponent)/Choose(Player) picks are independent by default (#6381) Benevolent Offering's two "Choose an opponent." instructions unconditionally excluded a player already chosen earlier in the resolution, which is only correct for Gluntch, the Bestower's ordinal-cued "choose a second/third player." Per the "Offering" cycle ruling ("You may choose the same opponent for each of the effects, or you may choose different opponents"), a bare repeated choose must allow repeating an earlier pick. In a two-player game the old behavior made the second choice impossible, silently dropping the chosen opponent's life gain. Adds `PlayerChoiceDistinctness` (mirroring `NumberRange`'s `distinctness` axis) so `ChoiceType::Player`/`ChoiceType::Opponent` only exclude prior choices when explicitly ordinal-cued. The parser sets it from the "second"/ "third" ordinal it already scans for. Also fixes two related recipient-binding gaps this card exposed: the "you and that player each ..." compound-subject splitter bound "that player" to the unrelated vote-fanout `ScopedPlayer` axis instead of the resolution-scoped chosen player, and `GainLife`'s subject-injection pass never rebound "that player gains N life" (for-each and plain) away from its no-subject `Controller` default. * fix(engine): gate GainLife subject-injection on a genuine player filter Review of the parse-diff artifact on PR #6747 found two card-level signature changes beyond the stated scope: - Intellectual Offering's second Draw now binds to ChosenPlayer{index: 0} instead of ScopedPlayer — intended: it shares Benevolent Offering's "Choose an opponent. You and that player each ." shape, so it exercises the same try_parse_compound_subject_each fix. Locked in with a new test. - Angel of Destiny's GainLife.player changed from the no-subject Controller default to TargetFilter::Any — a real regression. "You and that player each gain that much life" is a compound subject that GainLife isn't wired into in rewrite_recipient_on_link (Token/Draw/ Discard/Mill/Pump/GenericEffect only), so it falls through to a non-player-denoting subject filter; the new inject_subject_target arm blindly accepted it. Gated the arm on target_filter_can_target_player (mirroring the existing thread_for_each_subject GainLife arm) so an unresolved compound subject leaves the safer Controller default alone. Locked in with a regression test. Also corrects a pre-existing wrong CR citation (CR 800.4a, which governs a player leaving a multiplayer game) on the ChooseAPlayer replacement conversion touched by this PR, to CR 102.1-102.3 + CR 608.2d — the player/opponent + in-resolution-choice rules that actually apply. Resolves CodeRabbit thread r3669445472. * test(engine): add runtime draw-count proof for Intellectual Offering The existing intellectual_offering_second_draw_binds_to_chosen_opponent test only inspects the parsed Effect::Draw shape; it stays green even if runtime resolution drew for the wrong player, since it never executes game/effects/draw.rs's ChosenPlayer resolution. Adds intellectual_offering_draws_three_for_caster_and_chosen_opponent, which casts the real Oracle text with seeded libraries and mana, drives both Choose(Opponent) prompts to the same opponent (proving the repeated- choice fix along the way), and asserts both the caster and the chosen opponent actually draw three cards through the production cast/resolve pipeline. Keeps the original AST-shape test as a companion SHAPE assertion per the card-test skill. --------- Co-authored-by: Matt Evans <1388610+matthewevans@users.noreply.github.com> --- crates/engine/src/database/synthesis.rs | 4 +- crates/engine/src/game/ability_rw.rs | 10 +- crates/engine/src/game/coverage.rs | 2 +- crates/engine/src/game/effects/choose.rs | 116 ++++-- .../src/game/engine_resolution_choices.rs | 2 +- .../game/triggers_ordering_parity_tests.rs | 2 +- crates/engine/src/parser/oracle.rs | 4 +- crates/engine/src/parser/oracle_effect/mod.rs | 144 ++++++-- .../parser/oracle_effect/snapshot_tests.rs | 34 +- .../engine/src/parser/oracle_effect/tests.rs | 11 +- .../engine/src/parser/oracle_replacement.rs | 12 +- crates/engine/src/parser/oracle_vote.rs | 12 +- crates/engine/src/types/ability.rs | 201 ++++++++-- .../integration/baleful_mastery_regression.rs | 2 +- ...lum_scheming_guide_card_predicate_guess.rs | 13 +- .../issue_564_wishclaw_talisman_control.rs | 2 +- ...381_benevolent_offering_repeat_opponent.rs | 342 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + .../engine/tests/integration/rules/tribute.rs | 2 +- .../the_who_opponent_guess_resolution.rs | 8 +- crates/mtgish-import/src/convert/action.rs | 4 +- .../mtgish-import/src/convert/replacement.rs | 8 +- 22 files changed, 807 insertions(+), 129 deletions(-) create mode 100644 crates/engine/tests/integration/issue_6381_benevolent_offering_repeat_opponent.rs diff --git a/crates/engine/src/database/synthesis.rs b/crates/engine/src/database/synthesis.rs index 10388e1f4b..e18b8c9a9e 100644 --- a/crates/engine/src/database/synthesis.rs +++ b/crates/engine/src/database/synthesis.rs @@ -9712,7 +9712,7 @@ pub fn synthesize_siege_intrinsics(face: &mut CardFace) { protector_replacement.execute = Some(Box::new(AbilityDefinition::new( AbilityKind::Spell, Effect::Choose { - choice_type: ChoiceType::Opponent { restriction: None }, + choice_type: ChoiceType::opponent(), persist: true, selection: crate::types::ability::TargetSelectionMode::Chosen, }, @@ -9841,7 +9841,7 @@ pub fn synthesize_tribute_intrinsics(face: &mut CardFace) { let choose_stage = AbilityDefinition::new( AbilityKind::Spell, Effect::Choose { - choice_type: ChoiceType::Opponent { restriction: None }, + choice_type: ChoiceType::opponent(), persist: true, selection: crate::types::ability::TargetSelectionMode::Chosen, }, diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index ceaab2f1dc..4db0b41672 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -3435,7 +3435,7 @@ fn legacy_guess_subject(subject: &GuessSubject) -> bool { fn legacy_choice_type(choice_type: &crate::types::ability::ChoiceType) -> bool { match choice_type { - crate::types::ability::ChoiceType::Opponent { restriction } => { + crate::types::ability::ChoiceType::Opponent { restriction, .. } => { restriction.as_deref().is_some_and(legacy_player_filter) } crate::types::ability::ChoiceType::CreatureType { .. } @@ -3449,7 +3449,7 @@ fn legacy_choice_type(choice_type: &crate::types::ability::ChoiceType) -> bool { | crate::types::ability::ChoiceType::LandType | crate::types::ability::ChoiceType::CardPredicate { .. } | crate::types::ability::ChoiceType::CardPredicateGuess { .. } - | crate::types::ability::ChoiceType::Player + | crate::types::ability::ChoiceType::Player { .. } | crate::types::ability::ChoiceType::TwoColors | crate::types::ability::ChoiceType::Word | crate::types::ability::ChoiceType::Artist @@ -5728,7 +5728,7 @@ fn rw_guess_subject(subject: &GuessSubject) -> RwProfile { fn rw_choice_type(choice_type: &crate::types::ability::ChoiceType) -> RwProfile { match choice_type { - crate::types::ability::ChoiceType::Opponent { restriction } => match restriction { + crate::types::ability::ChoiceType::Opponent { restriction, .. } => match restriction { Some(filter) => rw_player_filter(filter), None => RwProfile::empty(), }, @@ -5743,7 +5743,7 @@ fn rw_choice_type(choice_type: &crate::types::ability::ChoiceType) -> RwProfile | crate::types::ability::ChoiceType::LandType | crate::types::ability::ChoiceType::CardPredicate { .. } | crate::types::ability::ChoiceType::CardPredicateGuess { .. } - | crate::types::ability::ChoiceType::Player + | crate::types::ability::ChoiceType::Player { .. } | crate::types::ability::ChoiceType::TwoColors | crate::types::ability::ChoiceType::Word | crate::types::ability::ChoiceType::Artist @@ -6824,7 +6824,7 @@ mod tests { #[test] fn b7_choose_persist_member_bound() { let choose = |persist: bool| Effect::Choose { - choice_type: ChoiceType::Opponent { restriction: None }, + choice_type: ChoiceType::opponent(), persist, selection: TargetSelectionMode::default(), }; diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index e81afd2fea..285a24f6a9 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -2030,7 +2030,7 @@ fn fmt_choice_type(ct: &ChoiceType) -> String { ChoiceType::CardPredicate { .. } => "card predicate", ChoiceType::CardPredicateGuess { .. } => "card predicate guess", ChoiceType::Opponent { .. } => "opponent", - ChoiceType::Player => "player", + ChoiceType::Player { .. } => "player", ChoiceType::TwoColors => "two colors", ChoiceType::Word => "word", ChoiceType::Artist => "artist", diff --git a/crates/engine/src/game/effects/choose.rs b/crates/engine/src/game/effects/choose.rs index 452ea02fdd..e218acba8d 100644 --- a/crates/engine/src/game/effects/choose.rs +++ b/crates/engine/src/game/effects/choose.rs @@ -2,8 +2,8 @@ use rand::Rng; use crate::game::players; use crate::types::ability::{ - ChoiceType, ChoiceValue, ChosenAttribute, Effect, EffectError, EffectKind, ResolvedAbility, - SeatDirection, TargetSelectionMode, + ChoiceType, ChoiceValue, ChosenAttribute, Effect, EffectError, EffectKind, + PlayerChoiceDistinctness, ResolvedAbility, SeatDirection, TargetSelectionMode, }; use crate::types::card_type::CoreType; use crate::types::events::GameEvent; @@ -180,7 +180,7 @@ pub(crate) fn resolve_random_in_chain( // it to the sub via `apply_parent_chain_context`. if matches!( choice_type, - ChoiceType::Player | ChoiceType::Opponent { .. } + ChoiceType::Player { .. } | ChoiceType::Opponent { .. } ) { if let Ok(pid) = chosen.parse::() { let mut updated = ability.chosen_players.clone(); @@ -306,7 +306,7 @@ pub(crate) fn bind_named_choice( | ChoiceType::BasicLandType | ChoiceType::Color { .. } | ChoiceType::Keyword { .. } - | ChoiceType::Player + | ChoiceType::Player { .. } | ChoiceType::Opponent { .. } // CR 613.1: A persisted `Label` gates `ChosenLabelIs` // continuous statics — anchor-word modal permanents @@ -505,12 +505,16 @@ const LAND_TYPES: &[&str] = &[ /// casting or resolution. If an option would be illegal, it can't be chosen. /// /// `already_chosen` is the resolution-scoped list of players picked by earlier -/// `Choose(Player)` instructions in this chain. CR 608.2c + the Gluntch card -/// ruling ("three distinct players") require each successive "choose a player" -/// to exclude players already chosen — `ChoiceType::Player` and -/// `ChoiceType::Opponent` filter them out. When fewer eligible players remain -/// than the card asks for, the options list is empty and the choice (and its -/// dependent effect) does nothing — the standard empty-options path. +/// `Choose(Player)` instructions in this chain. `ChoiceType::Player` and +/// `ChoiceType::Opponent` only consult it when their `distinctness` is +/// `DistinctFromPriorChoices` (CR 608.2c + the Gluntch ordinal-cued "choose a +/// second/third player" ruling, "three distinct players"). The default +/// `Independent` distinctness never filters on it — the "Offering" cycle +/// ruling (Benevolent/Infernal/Intellectual/Sylvan Offering) confirms a +/// repeated "Choose an opponent." may pick the same player again. When +/// `DistinctFromPriorChoices` narrows the eligible set below what the card +/// asks for, the options list is empty and the choice (and its dependent +/// effect) does nothing — the standard empty-options path. fn compute_options( state: &GameState, choice_type: &ChoiceType, @@ -612,15 +616,23 @@ fn compute_options( // (in a free-for-all game, every other player). `players::opponents` // already drops eliminated players (CR 104.3a — a player who loses // leaves the game and is no longer an opponent). - // CR 608.2c: Exclude players already chosen earlier in this resolution. + // CR 608.2c: `DistinctFromPriorChoices` excludes players already chosen + // earlier in this resolution; the default `Independent` does not (the + // "Offering" cycle may repeat the same opponent). // CR 102.3 + CR 608.2d: When a `restriction` is present ("with the most // life among your opponents"), narrow the eligible set to opponents // satisfying that `PlayerFilter` — the controller then picks ONE of the // qualifying opponents (CR 608.2d handles ties), keeping it a single // pick rather than fanning the effect out to every tied opponent. - ChoiceType::Opponent { restriction } => players::opponents(state, controller) + ChoiceType::Opponent { + restriction, + distinctness, + } => players::opponents(state, controller) .iter() - .filter(|id| !already_chosen.contains(id)) + .filter(|id| { + *distinctness != PlayerChoiceDistinctness::DistinctFromPriorChoices + || !already_chosen.contains(id) + }) .filter(|id| { restriction.as_ref().is_none_or(|filter| { super::matches_player_scope(state, **id, filter, controller, source_id) @@ -629,11 +641,16 @@ fn compute_options( .map(|id| id.0.to_string()) .collect(), // CR 102.1: A player is one of the people in the game. - // CR 608.2c: Exclude players already chosen earlier in this resolution. - ChoiceType::Player => state + // CR 608.2c: `DistinctFromPriorChoices` (Gluntch's "choose a + // second/third player") excludes players already chosen earlier in + // this resolution; the default `Independent` does not. + ChoiceType::Player { distinctness } => state .seat_order .iter() - .filter(|id| !already_chosen.contains(id)) + .filter(|id| { + *distinctness != PlayerChoiceDistinctness::DistinctFromPriorChoices + || !already_chosen.contains(id) + }) .map(|id| id.0.to_string()) .collect(), ChoiceType::TwoColors => two_color_options(), @@ -1266,7 +1283,7 @@ mod tests { #[test] fn choose_opponent_lists_opponents() { let mut state = GameState::new_two_player(42); - let ability = make_choose_ability(ChoiceType::Opponent { restriction: None }); + let ability = make_choose_ability(ChoiceType::opponent()); let mut events = Vec::new(); resolve(&mut state, &ability, &mut events).unwrap(); match &state.waiting_for { @@ -1278,10 +1295,56 @@ mod tests { } } + /// Issue #6381 (Benevolent Offering): the "Offering" cycle ruling — + /// "You may choose the same opponent for each of the effects, or you may + /// choose different opponents" — means the default `Independent` + /// distinctness must NOT exclude an opponent chosen by an earlier + /// `Choose(Opponent)` in the same resolution. In a two-player game this is + /// the difference between a legal repeat pick (correct) and an impossible + /// no-op second choice (the reported bug). + #[test] + fn choose_opponent_independent_by_default_allows_repeat_choice() { + let mut state = GameState::new_two_player(42); + let mut ability = make_choose_ability(ChoiceType::opponent()); + ability.chosen_players = vec![PlayerId(1)]; + let mut events = Vec::new(); + resolve(&mut state, &ability, &mut events).unwrap(); + match &state.waiting_for { + WaitingFor::NamedChoice { options, .. } => { + assert_eq!( + options, + &["1"], + "the previously-chosen opponent must remain a legal repeat pick" + ); + } + other => panic!("Expected NamedChoice, got {:?}", other), + } + } + #[test] fn choose_player_lists_all_players() { let mut state = GameState::new_two_player(42); - let ability = make_choose_ability(ChoiceType::Player); + let ability = make_choose_ability(ChoiceType::player()); + let mut events = Vec::new(); + resolve(&mut state, &ability, &mut events).unwrap(); + match &state.waiting_for { + WaitingFor::NamedChoice { options, .. } => { + assert_eq!(options.len(), 2); + assert!(options.contains(&"0".to_string())); + assert!(options.contains(&"1".to_string())); + } + other => panic!("Expected NamedChoice, got {:?}", other), + } + } + + #[test] + fn choose_player_independent_by_default_allows_repeat_choice() { + // The default `Independent` distinctness (bare "choose a player") does + // NOT exclude a player already chosen earlier in this resolution — + // only the ordinal-cued `DistinctFromPriorChoices` (Gluntch) does. + let mut state = GameState::new_two_player(42); + let mut ability = make_choose_ability(ChoiceType::player()); + ability.chosen_players = vec![PlayerId(0)]; let mut events = Vec::new(); resolve(&mut state, &ability, &mut events).unwrap(); match &state.waiting_for { @@ -1295,11 +1358,12 @@ mod tests { } #[test] - fn choose_player_excludes_already_chosen_players() { - // CR 608.2c + Gluntch ruling: a successive "choose a player" omits - // players already chosen earlier in the same resolution. + fn choose_player_distinct_from_prior_excludes_already_chosen_players() { + // CR 608.2c + Gluntch ruling ("choose a second/third player"): a + // successive `DistinctFromPriorChoices` pick omits players already + // chosen earlier in the same resolution. let mut state = GameState::new_two_player(42); - let mut ability = make_choose_ability(ChoiceType::Player); + let mut ability = make_choose_ability(ChoiceType::player_distinct_from_prior()); ability.chosen_players = vec![PlayerId(0)]; let mut events = Vec::new(); resolve(&mut state, &ability, &mut events).unwrap(); @@ -1312,7 +1376,7 @@ mod tests { } #[test] - fn choose_player_with_all_players_chosen_resolves_as_no_op() { + fn choose_player_distinct_from_prior_with_all_players_chosen_resolves_as_no_op() { // CR 609.3 (issue #3040): when every eligible player is already chosen, // the engine-enumerated option set is empty — choosing is impossible, so // the choice does nothing and resolution continues. It must NOT raise a @@ -1324,7 +1388,7 @@ mod tests { state.waiting_for = WaitingFor::Priority { player: PlayerId(0), }; - let mut ability = make_choose_ability(ChoiceType::Player); + let mut ability = make_choose_ability(ChoiceType::player_distinct_from_prior()); ability.chosen_players = vec![PlayerId(0), PlayerId(1)]; let mut events = Vec::new(); resolve(&mut state, &ability, &mut events).unwrap(); @@ -1425,7 +1489,7 @@ mod tests { let mut state = GameState::new_two_player(42); let mut ability = ResolvedAbility::new( Effect::Choose { - choice_type: ChoiceType::Player, + choice_type: ChoiceType::player(), persist: false, selection: TargetSelectionMode::Random, }, @@ -1454,7 +1518,7 @@ mod tests { // Building-block regression: a Chosen Choose is left to the interactive // `resolve` path (returns false; raises nothing here). let mut state = GameState::new_two_player(42); - let mut ability = make_choose_ability(ChoiceType::Player); + let mut ability = make_choose_ability(ChoiceType::player()); let mut events = Vec::new(); assert!(!resolve_random_in_chain( &mut state, diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index e9ca9781b0..edf3f0a465 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -5633,7 +5633,7 @@ pub(super) fn handle_resolution_choice( // single GameState slot cleared after every drain. if matches!( choice_type, - ChoiceType::Player | ChoiceType::Opponent { .. } + ChoiceType::Player { .. } | ChoiceType::Opponent { .. } ) { if let Ok(pid) = choice.parse::() { if let Some(frame) = state.active_ability_continuation_frame_mut() { diff --git a/crates/engine/src/game/triggers_ordering_parity_tests.rs b/crates/engine/src/game/triggers_ordering_parity_tests.rs index c0c8b70456..eace11120c 100644 --- a/crates/engine/src/game/triggers_ordering_parity_tests.rs +++ b/crates/engine/src/game/triggers_ordering_parity_tests.rs @@ -1906,7 +1906,7 @@ fn choose_opponent_then_draw() -> ResolvedAbility { target: TargetFilter::Controller, }); ra(Effect::Choose { - choice_type: ChoiceType::Opponent { restriction: None }, + choice_type: ChoiceType::opponent(), persist: false, selection: TargetSelectionMode::default(), }) diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index 7fb0a1c821..a91e399735 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -1921,7 +1921,7 @@ fn ability_chain_has_player_choice(def: &AbilityDefinition) -> bool { matches!( def.effect.as_ref(), Effect::Choose { - choice_type: ChoiceType::Player | ChoiceType::Opponent { .. }, + choice_type: ChoiceType::Player { .. } | ChoiceType::Opponent { .. }, .. } ) || def @@ -2006,7 +2006,7 @@ fn filter_references_source_chosen_player(filter: &TargetFilter) -> bool { /// sub-ability chain) to `persist: true` so its choice is stored durably. fn persist_player_choice_in_ability(def: &mut AbilityDefinition) { if let Effect::Choose { - choice_type: ChoiceType::Player | ChoiceType::Opponent { .. }, + choice_type: ChoiceType::Player { .. } | ChoiceType::Opponent { .. }, persist, .. } = def.effect.as_mut() diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 0a9a92d403..95786e59d7 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -104,14 +104,14 @@ use crate::types::ability::{ EffectScope, FilterProp, GameRestriction, GuessSubject, IntensityScope, IterationKindBinding, KeeperConstraint, LibraryPosition, ManaProduction, ManaSpendPermission, MultiTargetSpec, NumberDistinctness, ObjectProperty, ObjectScope, OriginConstraint, PerpetualModification, - PlayPermissionInvalidation, PlayerFilter, PlayerRelation, PlayerScope, PreventionAmount, - PreventionScope, ProhibitedActivity, PtValue, QuantityExpr, QuantityRef, ReplacementCondition, - ReplacementDefinition, RestrictionExpiry, RestrictionPlayerScope, RevealUntilDisposition, - RoundingMode, SharedQuality, SharedQualityRelation, SiblingCondition, SkipScope, - SpellStackToGraveyardReplacement, StaticCondition, StaticDefinition, StepSkipTarget, - SubAbilityLink, TapStateChange, TargetFilter, TargetSelectionMode, ThisWayCause, - TrackedAnaphorSource, TriggerCondition, TriggerDefinition, TypeFilter, TypedFilter, - UnlessPayModifier, UntilCondition, ZoneOwner, + PlayPermissionInvalidation, PlayerChoiceDistinctness, PlayerFilter, PlayerRelation, + PlayerScope, PreventionAmount, PreventionScope, ProhibitedActivity, PtValue, QuantityExpr, + QuantityRef, ReplacementCondition, ReplacementDefinition, RestrictionExpiry, + RestrictionPlayerScope, RevealUntilDisposition, RoundingMode, SharedQuality, + SharedQualityRelation, SiblingCondition, SkipScope, SpellStackToGraveyardReplacement, + StaticCondition, StaticDefinition, StepSkipTarget, SubAbilityLink, TapStateChange, + TargetFilter, TargetSelectionMode, ThisWayCause, TrackedAnaphorSource, TriggerCondition, + TriggerDefinition, TypeFilter, TypedFilter, UnlessPayModifier, UntilCondition, ZoneOwner, }; #[cfg(test)] use crate::types::ability::{AttackScope, AttackSubject}; @@ -529,7 +529,7 @@ fn finalize_committed_guess_choice_types( _ => unreachable!("matched above"), }; *ability.effect = Effect::Choose { - choice_type: ChoiceType::Opponent { restriction: None }, + choice_type: ChoiceType::opponent(), persist: false, selection: TargetSelectionMode::Chosen, }; @@ -7144,8 +7144,15 @@ fn parse_for_each_object_copy_parts( /// `ChosenPlayer` so a *following* sentence ("They put counters on a creature /// they control") binds its "they"/"that player" anaphora to this choice. /// -/// The ordinal word ("second"/"third") is a parse-time consistency hint only — -/// the engine index is derived from chain position, not the ordinal. +/// The ordinal word ("second"/"third") doubles as the CR 608.2c distinctness +/// signal: its presence marks this pick +/// `PlayerChoiceDistinctness::DistinctFromPriorChoices` (Gluntch, the +/// Bestower — each successive choice must exclude players already chosen +/// earlier in this resolution); its absence keeps the default `Independent`, +/// under which a bare repeated "choose a player"/"choose an opponent" may +/// repeat an earlier choice (confirmed by the "Offering" cycle ruling — +/// Benevolent/Infernal/Intellectual/Sylvan Offering — issue #6381). Either +/// way, the engine index is derived from chain position, not the ordinal. /// CR 102.3 + CR 608.2d: Recognize the "with the most life [among /// your opponents]" qualifier on a "choose an opponent" instruction and lower it /// to the equivalent `PlayerFilter::PlayerAttribute` restriction: each candidate @@ -7208,14 +7215,22 @@ fn try_parse_choose_player_to_verb( // and silently dropped opponent-form choose clauses. let player_arm = |i| { let (i, _) = tag::<_, _, OracleError<'_>>(" ").parse(i)?; - let i = super::oracle_util::parse_ordinal(i) - .map(|(_, rest)| rest) - .unwrap_or(i); + let (i, ordinal_present) = match super::oracle_util::parse_ordinal(i) { + Some((_, rest)) => (rest, true), + None => (i, false), + }; let (i, _) = tag::<_, _, OracleError<'_>>("player").parse(i)?; - Ok::<_, nom::Err>>((i, ChoiceType::Player)) + // CR 608.2c: the ordinal ("second"/"third") is the distinctness signal + // — see the doc comment above. + let distinctness = if ordinal_present { + PlayerChoiceDistinctness::DistinctFromPriorChoices + } else { + PlayerChoiceDistinctness::Independent + }; + Ok::<_, nom::Err>>((i, ChoiceType::Player { distinctness })) }; let opponent_arm = value( - ChoiceType::Opponent { restriction: None }, + ChoiceType::opponent(), tag::<_, _, OracleError<'_>>("n opponent"), ); let (after_player, mut choice_type) = @@ -7226,7 +7241,7 @@ fn try_parse_choose_player_to_verb( // restriction to the `Opponent` choice so it stays a single pick (CR 608.2d // resolves ties) rather than fanning out. Consume the qualifier so it is not // left dangling on the verb tail. - let after_player = if let ChoiceType::Opponent { restriction } = &mut choice_type { + let after_player = if let ChoiceType::Opponent { restriction, .. } = &mut choice_type { match parse_opponent_most_life_restriction(after_player) { Ok((rest, filter)) => { *restriction = Some(Box::new(filter)); @@ -7342,7 +7357,7 @@ fn try_parse_an_opponent_to_verb( } let mut clause = parsed_clause(Effect::Choose { - choice_type: ChoiceType::Opponent { restriction: None }, + choice_type: ChoiceType::opponent(), persist: false, selection: TargetSelectionMode::Chosen, }); @@ -7388,7 +7403,7 @@ fn try_parse_opponent_guesses_chosen_library_kind( }); let mut choose_opponent = parsed_clause(Effect::Choose { - choice_type: ChoiceType::Opponent { restriction: None }, + choice_type: ChoiceType::opponent(), persist: false, selection: TargetSelectionMode::Chosen, }); @@ -14525,13 +14540,25 @@ fn thread_for_each_subject(effect: Effect, original: &str, ctx: &mut ParseContex unless_filter, filter, }, + // CR 119.3 + CR 608.2c (issue #6381): "target player gains N life for + // each X" (issue #1508) needs an actual CR 601.2c target declaration + // (`is_targeted`); "that player gains N life for each X" is instead a + // resolution-scoped anaphor to a player chosen by an earlier "Choose an + // opponent."/"Choose a player." instruction (the "Offering" cycle: + // Benevolent/Infernal/Intellectual/Sylvan Offering) and never sets + // `application.target`. Accept either so both recipient-binding shapes + // rebind away from the no-subject `Controller` default. Effect::GainLife { amount, player: TargetFilter::Controller, - } if is_targeted && target_filter_can_target_player(&target) => Effect::GainLife { - amount, - player: target, - }, + } if target_filter_can_target_player(&target) + && (is_targeted || is_chosen_player_anaphor(&target)) => + { + Effect::GainLife { + amount, + player: target, + } + } // CR 115.1a/c + CR 701.17a + CR 608.2c: "Target opponent/player sacrifices // a [typed] permanent ... for each X" (Urborg Justice, Din of the Fireherd, // Rakdos Riteknife). The for-each interception strips the dynamic count @@ -17837,6 +17864,30 @@ fn try_parse_compound_subject_each( let (consumed_prefix, first_filter, second_filter) = parse_compound_subject_prefix(lower.as_str())?; + // CR 109.4 + CR 608.2c (issue #6381): "that player" in "you and that + // player each ..." is ambiguous prose with two distinct antecedents. The + // grammar's `ScopedPlayer` default is correct for a per-voter/per-opponent + // fan-out body (Master of Ceremonies-style vote bodies), but when a + // PRECEDING "Choose an opponent."/"Choose a player." instruction in the + // same resolution bound `ctx.relative_player_scope` to a + // `ControllerRef::ChosenPlayer { index }` (the "Offering" cycle: + // Benevolent/Infernal/Intellectual/Sylvan Offering), "that player" refers + // to THAT chosen player instead — rebind to match, mirroring + // `rebind_opponent_player_recipient_to_chosen`'s player-only `TargetFilter::Typed` + // encoding. Without this, the second recipient silently defaults to + // `scoped_player_or_controller`'s controller fallback (unset outside a + // fan-out), so every token/effect meant for "that player" was created for + // the caster instead. + let second_filter = match (&second_filter, ctx.relative_player_scope.clone()) { + (TargetFilter::ScopedPlayer, Some(ControllerRef::ChosenPlayer { index })) => { + TargetFilter::Typed(TypedFilter { + controller: Some(ControllerRef::ChosenPlayer { index }), + ..Default::default() + }) + } + _ => second_filter, + }; + // Slice the original-case body text using the consumed offset. let body_text = text[consumed_prefix..].trim(); if body_text.is_empty() { @@ -20474,6 +20525,24 @@ fn target_filter_can_target_player(filter: &TargetFilter) -> bool { } } +/// CR 608.2c: true when `filter` is the resolution-scoped "that player"/"that +/// opponent" anaphor to a player chosen earlier in the same resolution by a +/// `Choose(Player)`/`Choose(Opponent)` instruction, encoded as the player-only +/// `TargetFilter::Typed` carrying `ControllerRef::ChosenPlayer { index }` +/// (mirrors `retarget_effect_to_chosen_player`'s encoding). Distinct from a +/// CR 601.2c cast-time target declaration ("target player"), which sets +/// `SubjectApplication.target` instead. +fn is_chosen_player_anaphor(filter: &TargetFilter) -> bool { + matches!( + filter, + TargetFilter::Typed(TypedFilter { + controller: Some(ControllerRef::ChosenPlayer { .. }), + type_filters, + .. + }) if type_filters.is_empty() + ) +} + fn wrap_target_subject_damage( mut clause: ParsedEffectClause, subject: &SubjectPhraseAst, @@ -20716,6 +20785,27 @@ fn inject_subject_target(effect: &mut Effect, subject: &SubjectPhraseAst) { Effect::AdditionalPhase { target, .. } if *target == TargetFilter::Controller => { *target = subject_filter; } + // CR 119.3 (issue #6381): "that player gains N life" / "target player + // gains N life" — the imperative path defaults `player` to the + // no-subject `Controller` (the "you gain life" reading); inject the + // parsed subject when the sentence actually names a different, + // genuinely player-denoting recipient. Without this arm, EVERY + // "[non-you subject] gains N life" clause silently credited the + // ability's controller instead (the "Offering" cycle's "that player + // gains 2 life for each creature they control" — Benevolent/Infernal/ + // Intellectual/Sylvan Offering). Gated on `target_filter_can_target_player` + // (mirrors the `thread_for_each_subject` GainLife arm) so an + // unresolved compound subject ("you and that player each gain that + // much life" — Angel of Destiny, where the "each"-bodied split + // doesn't cover `GainLife` and falls back to a non-player `Any` + // subject) leaves the safer `Controller` default alone instead of + // rebinding to a nonsensical recipient. + Effect::GainLife { player, .. } + if *player == TargetFilter::Controller + && target_filter_can_target_player(&subject_filter) => + { + *player = subject_filter; + } // CR 400.7: "shuffle [subject]'s graveyard into their library" — inject // subject target for zone-wide changes and shuffles. Effect::ChangeZoneAll { ref mut target, .. } @@ -23325,7 +23415,7 @@ fn opponent_guess_clause(guesser: ControllerRef, subject: GuessSubject) -> Parse // so the chosen player is threaded to the dependent guess as a // same-resolution `ChosenPlayer`. let mut clause = parsed_clause(Effect::Choose { - choice_type: ChoiceType::Opponent { restriction: None }, + choice_type: ChoiceType::opponent(), persist: false, selection: TargetSelectionMode::Chosen, }); @@ -23549,9 +23639,9 @@ pub(crate) fn parse_named_choice_object(rest: &str) -> Option { Some(ChoiceType::LandType) } else if tag::<_, _, E>("an opponent").parse(rest).is_ok() { // CR 800.4a: Choose an opponent from among players in the game. - Some(ChoiceType::Opponent { restriction: None }) + Some(ChoiceType::opponent()) } else if tag::<_, _, E>("a player").parse(rest).is_ok() { - Some(ChoiceType::Player) + Some(ChoiceType::player()) } else if tag::<_, _, E>("two colors").parse(rest).is_ok() { Some(ChoiceType::TwoColors) } else if tag::<_, _, E>("a word").parse(rest).is_ok() { @@ -30264,7 +30354,7 @@ pub(crate) fn parse_effect_chain_ir( if matches!( clause.effect, Effect::Choose { - choice_type: ChoiceType::Player | ChoiceType::Opponent { .. }, + choice_type: ChoiceType::Player { .. } | ChoiceType::Opponent { .. }, .. } ) { diff --git a/crates/engine/src/parser/oracle_effect/snapshot_tests.rs b/crates/engine/src/parser/oracle_effect/snapshot_tests.rs index 44dfe8f139..24045f6569 100644 --- a/crates/engine/src/parser/oracle_effect/snapshot_tests.rs +++ b/crates/engine/src/parser/oracle_effect/snapshot_tests.rs @@ -973,7 +973,10 @@ fn strax_choose_a_player_at_random_records_random_selection() { ); match def.effect.as_ref() { Effect::Choose { - choice_type: ChoiceType::Player, + choice_type: + ChoiceType::Player { + distinctness: PlayerChoiceDistinctness::Independent, + }, selection, .. } => assert_eq!(*selection, TargetSelectionMode::Random), @@ -990,16 +993,20 @@ fn gluntch_choose_player_chain_parses_with_chosen_player_scopes() { AbilityKind::Spell, ); - // Node 0: the first `Choose(Player)`. + // Node 0: the first `Choose(Player)` — no ordinal, so the default + // `Independent` distinctness applies (CR 608.2c; issue #6381 confirms the + // bare "choose a player" must NOT exclude prior choices). assert!( matches!( def.effect.as_ref(), Effect::Choose { - choice_type: ChoiceType::Player, + choice_type: ChoiceType::Player { + distinctness: PlayerChoiceDistinctness::Independent + }, .. } ), - "first node must be Choose(Player), got {:?}", + "first node must be Choose(Player) with Independent distinctness, got {:?}", def.effect ); @@ -1017,17 +1024,21 @@ fn gluntch_choose_player_chain_parses_with_chosen_player_scopes() { "the +1/+1 counters go on a creature the 1st chosen player controls" ); - // Node 2: the second `Choose(Player)`. + // Node 2: the second `Choose(Player)` — "a second player" carries the + // ordinal, so it must be `DistinctFromPriorChoices` (Gluntch's "three + // distinct players" ruling). let node2 = node1.sub_ability.as_ref().expect("2nd Choose node"); assert!( matches!( node2.effect.as_ref(), Effect::Choose { - choice_type: ChoiceType::Player, + choice_type: ChoiceType::Player { + distinctness: PlayerChoiceDistinctness::DistinctFromPriorChoices + }, .. } ), - "node 2 must be Choose(Player) — not Unimplemented — got {:?}", + "node 2 must be Choose(Player) with DistinctFromPriorChoices — not Unimplemented — got {:?}", node2.effect ); @@ -1042,17 +1053,20 @@ fn gluntch_choose_player_chain_parses_with_chosen_player_scopes() { "the 2nd chosen player draws the card" ); - // Node 4: the third `Choose(Player)`. + // Node 4: the third `Choose(Player)` — "a third player" carries the + // ordinal, so it must be `DistinctFromPriorChoices` too. let node4 = node3.sub_ability.as_ref().expect("3rd Choose node"); assert!( matches!( node4.effect.as_ref(), Effect::Choose { - choice_type: ChoiceType::Player, + choice_type: ChoiceType::Player { + distinctness: PlayerChoiceDistinctness::DistinctFromPriorChoices + }, .. } ), - "node 4 must be Choose(Player) — not Unimplemented — got {:?}", + "node 4 must be Choose(Player) with DistinctFromPriorChoices — not Unimplemented — got {:?}", node4.effect ); diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 7aecd09b42..2cfbffb6bf 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -24452,7 +24452,10 @@ fn gollum_scheming_guide_guess_sequence_has_no_unimplemented() { && matches!( node.effect.as_ref(), Effect::Choose { - choice_type: ChoiceType::Opponent { restriction: None }, + choice_type: ChoiceType::Opponent { + restriction: None, + .. + }, persist: false, .. } @@ -24665,7 +24668,10 @@ fn committed_choice_guess_chooses_single_opponent_before_guess() { matches!( choose_opponent.effect.as_ref(), Effect::Choose { - choice_type: ChoiceType::Opponent { restriction: None }, + choice_type: ChoiceType::Opponent { + restriction: None, + .. + }, persist: false, .. } @@ -39190,6 +39196,7 @@ fn the_master_most_life_villainous_choice() { choice_type: ChoiceType::Opponent { restriction: Some(restriction), + .. }, persist, .. diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 3b7bb0a50c..52b57a484d 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -2113,7 +2113,7 @@ fn front_opponent_choice_for_nontargeted_look(reveal: &Effect) -> Option<(Effect // observable outcome. let choose_opponent = Effect::Choose { // CR 608.2d + CR 102.3: the controller chooses one opponent. - choice_type: ChoiceType::Opponent { restriction: None }, + choice_type: ChoiceType::opponent(), persist: true, // Same controller-choice selection mode as the fronted card-name choice. selection: crate::types::ability::TargetSelectionMode::Chosen, @@ -14752,7 +14752,10 @@ mod tests { matches!( &*execute.effect, Effect::Choose { - choice_type: ChoiceType::Opponent { restriction: None }, + choice_type: ChoiceType::Opponent { + restriction: None, + .. + }, persist: true, .. } @@ -14863,7 +14866,10 @@ mod tests { matches!( &*mid.effect, Effect::Choose { - choice_type: ChoiceType::Opponent { restriction: None }, + choice_type: ChoiceType::Opponent { + restriction: None, + .. + }, persist: true, .. } diff --git a/crates/engine/src/parser/oracle_vote.rs b/crates/engine/src/parser/oracle_vote.rs index 13fb10298a..45ccb9afc6 100644 --- a/crates/engine/src/parser/oracle_vote.rs +++ b/crates/engine/src/parser/oracle_vote.rs @@ -1210,10 +1210,13 @@ fn parse_vote_for_each_suffix_clause<'a>( opt(tag_no_case("then ")), alt(( value( - ChoiceType::Opponent { restriction: None }, + ChoiceType::opponent(), tag_no_case("choose an opponent at random"), ), - value(ChoiceType::Player, tag_no_case("choose a player at random")), + value( + ChoiceType::player(), + tag_no_case("choose a player at random"), + ), )), tag(". "), )) @@ -2397,7 +2400,10 @@ mod tests { assert_eq!(rest, ""); assert!(matches!( setup, - Some(ChoiceType::Opponent { restriction: None }) + Some(ChoiceType::Opponent { + restriction: None, + .. + }) )); match &*def.effect { Effect::DealDamage { amount, target, .. } => { diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 22a671cb91..aaf1b5b3f1 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -525,6 +525,28 @@ pub enum NumberDistinctness { DistinctFromSourceHistory, } +/// CR 608.2c: whether a "choose a player"/"choose an opponent" instruction +/// must exclude players already chosen earlier in the SAME resolution, or is +/// an independent pick that may repeat an earlier choice. Parse-detected; +/// static; serialized only when non-default so existing `Player`/`Opponent` +/// card-data stays byte-stable. Mirrors `NumberDistinctness`'s axis on the +/// sibling `NumberRange` choice. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum PlayerChoiceDistinctness { + /// The default: repeated "Choose an opponent."/"Choose a player." + /// instructions in one resolution are independent picks — the same + /// player may be chosen more than once. Confirmed by the "Offering" cycle + /// ruling (Benevolent/Infernal/Intellectual/Sylvan Offering): "You may + /// choose the same opponent for each of the effects, or you may choose + /// different opponents." + #[default] + Independent, + /// Ordinal-cued instructions ("choose a second player", "choose a third + /// player" — Gluntch, the Bestower) require each successive choice to + /// exclude every player already chosen earlier in this resolution. + DistinctFromPriorChoices, +} + /// What kind of named choice the player must make at resolution time. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ChoiceType { @@ -590,11 +612,22 @@ pub enum ChoiceType { /// the qualifying opponents (CR 608.2d handles ties) — rather than fanning /// the effect out to every tied opponent. Boxed to avoid inflating the /// `ChoiceType` enum with the recursive `PlayerFilter` payload. + /// + /// `distinctness` (CR 608.2c) governs whether this pick must exclude + /// players already chosen by an earlier `Opponent`/`Player` choice in the + /// same resolution. Defaults to `Independent` — see + /// [`PlayerChoiceDistinctness`]. Opponent { restriction: Option>, + distinctness: PlayerChoiceDistinctness, + }, + /// "Choose a player" — selects any player in the game. `distinctness` + /// (CR 608.2c) governs whether this pick must exclude players already + /// chosen by an earlier `Opponent`/`Player` choice in the same + /// resolution — see [`PlayerChoiceDistinctness`]. + Player { + distinctness: PlayerChoiceDistinctness, }, - /// "Choose a player" — selects any player in the game. - Player, /// "Choose two colors" — selects two distinct mana colors. TwoColors, /// "Choose a word" — names any English word (Un-set and silver-border cards). @@ -702,6 +735,39 @@ impl ChoiceType { Self::CardType { excluded } } + /// Unrestricted "choose an opponent" (CR 102.3), independent of any other + /// choice in the resolution (the "Offering" cycle default). + pub fn opponent() -> Self { + Self::Opponent { + restriction: None, + distinctness: PlayerChoiceDistinctness::Independent, + } + } + + /// "Choose an opponent [with the most life ...]" (CR 608.2d). + pub fn opponent_with_restriction(restriction: PlayerFilter) -> Self { + Self::Opponent { + restriction: Some(Box::new(restriction)), + distinctness: PlayerChoiceDistinctness::Independent, + } + } + + /// "Choose a player" (CR 102.1), independent of any other choice in the + /// resolution. + pub fn player() -> Self { + Self::Player { + distinctness: PlayerChoiceDistinctness::Independent, + } + } + + /// Ordinal-cued "choose a second/third player" (Gluntch, the Bestower): + /// must exclude players already chosen earlier in this resolution. + pub fn player_distinct_from_prior() -> Self { + Self::Player { + distinctness: PlayerChoiceDistinctness::DistinctFromPriorChoices, + } + } + pub fn land_or_nonland_card_predicate_options() -> Vec { vec![CardPredicateChoice::Land, CardPredicateChoice::Nonland] } @@ -840,19 +906,47 @@ impl Serialize for ChoiceType { variant.serialize_field("options", options)?; variant.end() } - // Serialize the unrestricted form as the legacy unit variant - // "Opponent" so existing card-data JSON stays byte-stable; only emit - // the struct form when a restriction is present. - Self::Opponent { restriction } => match restriction { - None => serializer.serialize_unit_variant("ChoiceType", 9, "Opponent"), - Some(restriction) => { - let mut variant = - serializer.serialize_struct_variant("ChoiceType", 9, "Opponent", 1)?; + // Serialize the unrestricted, default-distinctness form as the + // legacy unit variant "Opponent" so existing card-data JSON stays + // byte-stable; only emit the struct form when a restriction + // and/or a non-default `distinctness` is present. + Self::Opponent { + restriction, + distinctness, + } => { + let non_default_distinctness = + *distinctness != PlayerChoiceDistinctness::Independent; + if restriction.is_none() && !non_default_distinctness { + serializer.serialize_unit_variant("ChoiceType", 9, "Opponent") + } else { + let field_count = 1 + non_default_distinctness as usize; + let mut variant = serializer.serialize_struct_variant( + "ChoiceType", + 9, + "Opponent", + field_count, + )?; variant.serialize_field("restriction", restriction)?; + if non_default_distinctness { + variant.serialize_field("distinctness", distinctness)?; + } variant.end() } - }, - Self::Player => serializer.serialize_unit_variant("ChoiceType", 10, "Player"), + } + // Serialize the default-distinctness form as the legacy unit + // variant "Player" so existing card-data JSON stays byte-stable; + // only emit the struct form when `distinctness` is non-default + // (Gluntch, the Bestower's ordinal-cued picks). + Self::Player { distinctness } => { + if *distinctness == PlayerChoiceDistinctness::Independent { + serializer.serialize_unit_variant("ChoiceType", 10, "Player") + } else { + let mut variant = + serializer.serialize_struct_variant("ChoiceType", 10, "Player", 1)?; + variant.serialize_field("distinctness", distinctness)?; + variant.end() + } + } Self::TwoColors => serializer.serialize_unit_variant("ChoiceType", 11, "TwoColors"), Self::Word => serializer.serialize_unit_variant("ChoiceType", 12, "Word"), Self::Artist => serializer.serialize_unit_variant("ChoiceType", 13, "Artist"), @@ -929,6 +1023,12 @@ impl<'de> Deserialize<'de> for ChoiceType { Opponent { #[serde(default)] restriction: Option>, + #[serde(default)] + distinctness: PlayerChoiceDistinctness, + }, + Player { + #[serde(default)] + distinctness: PlayerChoiceDistinctness, }, Keyword { options: Vec, @@ -955,8 +1055,8 @@ impl<'de> Deserialize<'de> for ChoiceType { "CardType" => Ok(Self::card_type()), "CardName" => Ok(Self::CardName), "LandType" => Ok(Self::LandType), - "Opponent" => Ok(Self::Opponent { restriction: None }), - "Player" => Ok(Self::Player), + "Opponent" => Ok(Self::opponent()), + "Player" => Ok(Self::player()), "TwoColors" => Ok(Self::TwoColors), "Word" => Ok(Self::Word), "Artist" => Ok(Self::Artist), @@ -996,7 +1096,14 @@ impl<'de> Deserialize<'de> for ChoiceType { ChoiceTypeData::CardPredicateGuess { options } => { Ok(Self::CardPredicateGuess { options }) } - ChoiceTypeData::Opponent { restriction } => Ok(Self::Opponent { restriction }), + ChoiceTypeData::Opponent { + restriction, + distinctness, + } => Ok(Self::Opponent { + restriction, + distinctness, + }), + ChoiceTypeData::Player { distinctness } => Ok(Self::Player { distinctness }), ChoiceTypeData::Keyword { options, count } => Ok(Self::Keyword { options, count }), ChoiceTypeData::CounterKind { options } => Ok(Self::CounterKind { options }), }, @@ -1379,7 +1486,7 @@ impl ChosenAttribute { distinctness: NumberDistinctness::Repeatable, }, // Player covers both Player and Opponent choice types - Self::Player(_) => ChoiceType::Player, + Self::Player(_) => ChoiceType::player(), Self::TwoColors(_) => ChoiceType::TwoColors, // CR 702.104: Tribute outcome uses a dedicated prompt type rather than // a NamedChoice (two fixed labels: Paid / Declined). Classify under the @@ -1514,7 +1621,7 @@ impl ChoiceValue { } ChoiceType::LandType => Some(Self::LandType(value.to_string())), // CR 800.4a: Parse player ID from string. - ChoiceType::Opponent { .. } | ChoiceType::Player => value + ChoiceType::Opponent { .. } | ChoiceType::Player { .. } => value .parse::() .ok() .map(|id| Self::Player(PlayerId(id))), @@ -24223,14 +24330,14 @@ mod tests { // unrestricted form; it must round-trip to `restriction: None`. let choice_type: ChoiceType = serde_json::from_str("\"Opponent\"").unwrap(); - assert_eq!(choice_type, ChoiceType::Opponent { restriction: None }); + assert_eq!(choice_type, ChoiceType::opponent()); } #[test] fn choice_type_opponent_unrestricted_serializes_as_legacy_unit() { // The hand-rolled Serialize must keep emitting the bare string for the // unrestricted form so existing card-data.json stays byte-stable. - let json = serde_json::to_string(&ChoiceType::Opponent { restriction: None }).unwrap(); + let json = serde_json::to_string(&ChoiceType::opponent()).unwrap(); assert_eq!(json, "\"Opponent\""); } @@ -24291,22 +24398,20 @@ mod tests { // The Master, Gallifrey's End: "choose an opponent with the most life". // The hand-rolled Serialize/Deserialize for the restricted struct form // must be symmetric or card-data.json load corrupts silently. - let original = ChoiceType::Opponent { - restriction: Some(Box::new(PlayerFilter::PlayerAttribute { - relation: PlayerRelation::Opponent, - attr: Box::new(QuantityRef::LifeTotal { - player: PlayerScope::ScopedPlayer, - }), - comparator: Comparator::GE, - value: Box::new(QuantityExpr::Ref { - qty: QuantityRef::LifeTotal { - player: PlayerScope::Opponent { - aggregate: AggregateFunction::Max, - }, + let original = ChoiceType::opponent_with_restriction(PlayerFilter::PlayerAttribute { + relation: PlayerRelation::Opponent, + attr: Box::new(QuantityRef::LifeTotal { + player: PlayerScope::ScopedPlayer, + }), + comparator: Comparator::GE, + value: Box::new(QuantityExpr::Ref { + qty: QuantityRef::LifeTotal { + player: PlayerScope::Opponent { + aggregate: AggregateFunction::Max, }, - }), - })), - }; + }, + }), + }); let json = serde_json::to_string(&original).unwrap(); // Restricted form must use the externally-tagged struct variant so it is @@ -24320,6 +24425,34 @@ mod tests { assert_eq!(round_tripped, original); } + #[test] + fn choice_type_player_unrestricted_serializes_as_legacy_unit() { + // The hand-rolled Serialize must keep emitting the bare "Player" string + // for the default-distinctness form so existing card-data.json (Strax, + // Sontaran Nurse) stays byte-stable. + let json = serde_json::to_string(&ChoiceType::player()).unwrap(); + assert_eq!(json, "\"Player\""); + + let round_tripped: ChoiceType = serde_json::from_str(&json).unwrap(); + assert_eq!(round_tripped, ChoiceType::player()); + } + + #[test] + fn choice_type_player_distinct_from_prior_serde_round_trips() { + // Gluntch, the Bestower's ordinal-cued "choose a second/third player" + // must serialize to the struct form carrying the non-default + // `distinctness`, and round-trip back losslessly. + let original = ChoiceType::player_distinct_from_prior(); + let json = serde_json::to_string(&original).unwrap(); + assert!( + json.starts_with(r#"{"Player":"#), + "non-default distinctness should serialize as a struct variant, got: {json}" + ); + + let round_tripped: ChoiceType = serde_json::from_str(&json).unwrap(); + assert_eq!(round_tripped, original); + } + #[test] fn restricted_color_choice_value_rejects_excluded_color() { assert_eq!( diff --git a/crates/engine/tests/integration/baleful_mastery_regression.rs b/crates/engine/tests/integration/baleful_mastery_regression.rs index 89bfdc70a3..1f8ff20dc9 100644 --- a/crates/engine/tests/integration/baleful_mastery_regression.rs +++ b/crates/engine/tests/integration/baleful_mastery_regression.rs @@ -170,7 +170,7 @@ fn baleful_mastery_alternative_cost_makes_chosen_opponent_draw() { options, .. } => { - assert_eq!(choice_type, ChoiceType::Opponent { restriction: None }); + assert_eq!(choice_type, ChoiceType::opponent()); assert_eq!(options, vec![P1.0.to_string()]); runner .act(GameAction::ChooseOption { diff --git a/crates/engine/tests/integration/gollum_scheming_guide_card_predicate_guess.rs b/crates/engine/tests/integration/gollum_scheming_guide_card_predicate_guess.rs index cb3cceb7b3..70c612bf94 100644 --- a/crates/engine/tests/integration/gollum_scheming_guide_card_predicate_guess.rs +++ b/crates/engine/tests/integration/gollum_scheming_guide_card_predicate_guess.rs @@ -63,7 +63,10 @@ fn gollum_attack_trigger_parser_promotes_choices_to_card_predicates() { assert!(matches!( choose_opponent.effect.as_ref(), Effect::Choose { - choice_type: ChoiceType::Opponent { restriction: None }, + choice_type: ChoiceType::Opponent { + restriction: None, + .. + }, persist: false, .. } @@ -278,7 +281,13 @@ fn choose_opponent( }; assert_eq!(player, expected_chooser, "wrong player choosing opponent"); assert!( - matches!(choice_type, ChoiceType::Opponent { restriction: None }), + matches!( + choice_type, + ChoiceType::Opponent { + restriction: None, + .. + } + ), "expected opponent choice, got {choice_type:?}" ); let choice = opponent.0.to_string(); diff --git a/crates/engine/tests/integration/issue_564_wishclaw_talisman_control.rs b/crates/engine/tests/integration/issue_564_wishclaw_talisman_control.rs index 7f3a001bc7..f2004f5bf7 100644 --- a/crates/engine/tests/integration/issue_564_wishclaw_talisman_control.rs +++ b/crates/engine/tests/integration/issue_564_wishclaw_talisman_control.rs @@ -29,7 +29,7 @@ fn choose_opponent(runner: &mut GameRunner, opponent: engine::types::PlayerId) { options, .. } => { - assert_eq!(*choice_type, ChoiceType::Opponent { restriction: None }); + assert_eq!(*choice_type, ChoiceType::opponent()); assert!( options.contains(&opponent.0.to_string()), "opponent must be legal; options={options:?}" diff --git a/crates/engine/tests/integration/issue_6381_benevolent_offering_repeat_opponent.rs b/crates/engine/tests/integration/issue_6381_benevolent_offering_repeat_opponent.rs new file mode 100644 index 0000000000..35cb715289 --- /dev/null +++ b/crates/engine/tests/integration/issue_6381_benevolent_offering_repeat_opponent.rs @@ -0,0 +1,342 @@ +//! Regression (issue #6381): Benevolent Offering's two independent "Choose an +//! opponent." instructions must each accept the SAME opponent in a two-player +//! game. Official ruling: "You may choose the same opponent for each of the +//! effects, or you may choose different opponents." (Confirmed identically +//! for the "Offering" cycle: Infernal/Intellectual/Sylvan Offering.) +//! +//! Before the fix, `ChoiceType::Opponent`/`ChoiceType::Player` unconditionally +//! excluded players already chosen earlier in the same resolution (correct +//! only for Gluntch, the Bestower's ordinal-cued "choose a second/third +//! player"). In a two-player game that made the SECOND "Choose an opponent." +//! impossible — CR 609.3 turned it into a no-op, so "that player" never got +//! bound for the life-gain clause and the chosen opponent gained 0 life +//! instead of 2 life per creature they control. + +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::ability::{ChoiceType, ControllerRef, Effect, TargetFilter, TypedFilter}; +use engine::types::game_state::WaitingFor; +use engine::types::mana::{ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::triggers::TriggerMode; + +const BENEVOLENT_OFFERING: &str = "Choose an opponent. You and that player each create three 1/1 white Spirit \ + creature tokens with flying.\nChoose an opponent. You gain 2 life for each creature you control and that \ + player gains 2 life for each creature they control."; + +fn floating_mana(color: ManaType, n: usize) -> Vec { + (0..n) + .map(|_| { + ManaUnit::new( + color, + engine::types::identifiers::ObjectId(0), + false, + vec![], + ) + }) + .collect() +} + +fn player_life(runner: &GameRunner, player: PlayerId) -> i32 { + runner + .state() + .players + .iter() + .find(|p| p.id == player) + .unwrap() + .life +} + +/// Assert a `NamedChoice(Opponent)` prompt is showing, that `opponent` is +/// among the legal (non-excluded) options, then answer it. +fn choose_opponent(runner: &mut GameRunner, opponent: PlayerId) { + match &runner.state().waiting_for { + WaitingFor::NamedChoice { + choice_type, + options, + .. + } => { + assert!( + matches!( + choice_type, + ChoiceType::Opponent { + restriction: None, + .. + } + ), + "expected an unrestricted opponent choice, got {choice_type:?}" + ); + assert!( + options.contains(&opponent.0.to_string()), + "opponent P{} must remain a legal pick (Offering cycle ruling allows \ + repeating an earlier choice); options={options:?}", + opponent.0 + ); + } + other => panic!("expected NamedChoice(Opponent), got {other:?}"), + } + runner + .act(engine::types::actions::GameAction::ChooseOption { + choice: opponent.0.to_string(), + }) + .expect("ChooseOption(opponent) must succeed"); +} + +#[test] +fn benevolent_offering_allows_choosing_the_same_opponent_twice() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool( + P0, + [ + floating_mana(ManaType::Colorless, 3), + floating_mana(ManaType::White, 1), + ] + .concat(), + ); + + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Benevolent Offering", true, BENEVOLENT_OFFERING) + .id(); + + let mut runner = scenario.build(); + let life_before_p0 = player_life(&runner, P0); + let life_before_p1 = player_life(&runner, P1); + + runner.cast(spell).resolve(); + + // First "Choose an opponent." (fronting the twin token creation). + choose_opponent(&mut runner, P1); + // Second "Choose an opponent." — must offer P1 again, not exclude it. + choose_opponent(&mut runner, P1); + + for _ in 0..8 { + if matches!(runner.state().waiting_for, WaitingFor::Priority { .. }) + && runner.state().stack.is_empty() + { + break; + } + runner + .act(engine::types::actions::GameAction::PassPriority) + .ok(); + } + + // CR 111.7: each player controls exactly three of the created Spirit tokens. + let p0_spirits = runner + .state() + .objects + .values() + .filter(|o| o.controller == P0 && o.name == "Spirit") + .count(); + let p1_spirits = runner + .state() + .objects + .values() + .filter(|o| o.controller == P1 && o.name == "Spirit") + .count(); + assert_eq!(p0_spirits, 3, "the caster must control three Spirit tokens"); + assert_eq!( + p1_spirits, 3, + "the chosen opponent must control three Spirit tokens" + ); + + // CR 119.3: each player gains 2 life per creature they control (their own + // three Spirit tokens). Under the pre-fix bug, P1's gain was 0 because the + // second Choose(Opponent) resolved as an impossible no-op. + assert_eq!( + player_life(&runner, P0) - life_before_p0, + 6, + "the caster must gain 2 life per creature controlled (3 Spirits)" + ); + assert_eq!( + player_life(&runner, P1) - life_before_p1, + 6, + "the chosen opponent must gain 2 life per creature controlled (3 Spirits) \ + — this is the reported defect: it read 0 before the fix" + ); +} + +const INTELLECTUAL_OFFERING: &str = "Choose an opponent. You and that player each draw three cards.\nChoose an \ + opponent. Untap all nonland permanents you control and all nonland permanents that player controls."; + +fn hand_count(runner: &GameRunner, player: PlayerId) -> usize { + runner + .state() + .objects + .values() + .filter(|o| o.owner == player && o.zone == engine::types::zones::Zone::Hand) + .count() +} + +/// Runtime counterpart to `intellectual_offering_second_draw_binds_to_chosen_opponent` +/// below: drives the real cast/resolution pipeline (not just the parsed AST) +/// so the fix is proven all the way through `game/effects/draw.rs`'s +/// `ChosenPlayer` resolution (`game/effects/mod.rs`'s `resolve_player_for_context_ref`), +/// not just the parser. An AST-only assertion would stay green even if the +/// resolver drew for the wrong player. +#[test] +fn intellectual_offering_draws_three_for_caster_and_chosen_opponent() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool( + P0, + [ + floating_mana(ManaType::Colorless, 4), + floating_mana(ManaType::Blue, 1), + ] + .concat(), + ); + // Seed both libraries well past the three cards each side draws. + scenario.with_library_top(P0, &["Forest", "Forest", "Forest", "Forest", "Forest"]); + scenario.with_library_top(P1, &["Island", "Island", "Island", "Island", "Island"]); + + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Intellectual Offering", true, INTELLECTUAL_OFFERING) + .id(); + + let mut runner = scenario.build(); + // The spell itself occupies P0's hand until it resolves off the stack; + // measure the draw delta from the post-cast baseline, not pre-cast. + let hand_before_p1 = hand_count(&runner, P1); + + runner.cast(spell).resolve(); + let hand_before_p0 = hand_count(&runner, P0); + + // First "Choose an opponent." (fronting the twin three-card draw). + choose_opponent(&mut runner, P1); + // Second "Choose an opponent." — must offer P1 again, not exclude it. + choose_opponent(&mut runner, P1); + + for _ in 0..8 { + if matches!(runner.state().waiting_for, WaitingFor::Priority { .. }) + && runner.state().stack.is_empty() + { + break; + } + runner + .act(engine::types::actions::GameAction::PassPriority) + .ok(); + } + + assert_eq!( + hand_count(&runner, P0) - hand_before_p0, + 3, + "the caster must draw three cards" + ); + assert_eq!( + hand_count(&runner, P1) - hand_before_p1, + 3, + "the chosen opponent must draw three cards — this is the runtime proof \ + that ChosenPlayer{{index: 0}} (not the unrelated ScopedPlayer default) \ + resolves the second Draw's recipient" + ); +} + +/// Intellectual Offering shares Benevolent Offering's "Choose an opponent. +/// You and that player each ." shape, so it exercises the SAME +/// `try_parse_compound_subject_each` fix: "that player" must rebind to the +/// resolution-scoped chosen player (`ChosenPlayer { index }`), not the +/// unrelated vote/fan-out `ScopedPlayer` axis. Locks in that the whole +/// "Offering" cycle — not just Benevolent Offering — benefits. +/// +/// AST-shape companion to `intellectual_offering_draws_three_for_caster_and_chosen_opponent` +/// above; kept as a SHAPE test (see the `card-test` skill) because it pins +/// the exact parser output distinct from the runtime draw-count proof. +#[test] +fn intellectual_offering_second_draw_binds_to_chosen_opponent() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Intellectual Offering", true, INTELLECTUAL_OFFERING) + .id(); + let runner = scenario.build(); + let ability = &runner.state().objects.get(&spell).unwrap().abilities[0]; + assert!( + matches!( + ability.effect.as_ref(), + Effect::Choose { + choice_type: ChoiceType::Opponent { .. }, + .. + } + ), + "head must be Choose(Opponent), got {:?}", + ability.effect + ); + let first_draw = ability.sub_ability.as_ref().expect("first Draw node"); + assert!( + matches!( + first_draw.effect.as_ref(), + Effect::Draw { + target: TargetFilter::OriginalController, + .. + } + ), + "the caster's draw must target OriginalController, got {:?}", + first_draw.effect + ); + let second_draw = first_draw.sub_ability.as_ref().expect("second Draw node"); + assert!( + matches!( + second_draw.effect.as_ref(), + Effect::Draw { + target: TargetFilter::Typed(TypedFilter { + controller: Some(ControllerRef::ChosenPlayer { index: 0 }), + .. + }), + .. + } + ), + "the chosen opponent's draw must bind to ChosenPlayer{{index: 0}}, not ScopedPlayer, got {:?}", + second_draw.effect + ); +} + +/// Regression guard for the fix above: `inject_subject_target`'s new +/// `GainLife` arm must NOT rebind the recipient when the detected subject +/// isn't a genuine player reference. Angel of Destiny's "you and that player +/// each gain that much life" is a compound subject that `GainLife` doesn't +/// support in `rewrite_recipient_on_link` (Token/Draw/Discard/Mill/Pump/ +/// GenericEffect only), so it falls through to a non-player-denoting subject +/// filter; the safe no-subject `Controller` default must survive rather than +/// being corrupted into an incoherent recipient. This is a pre-existing gap +/// (the damaged player still doesn't gain life) — not fixed here — but the +/// `player` field must stay a well-defined `Controller`, not silently swap to +/// something meaningless. +#[test] +fn angel_of_destiny_combat_damage_gain_life_keeps_well_defined_recipient() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature_from_oracle( + P0, + "Angel of Destiny", + 3, + 4, + "Flying, double strike\nWhenever a creature you control deals combat damage to a player, you and that player each gain that much life.\nAt the beginning of your end step, if you have at least 15 life more than your starting life total, each player this creature attacked this turn loses the game.", + ) + .id(); + let runner = scenario.build(); + let obj = runner.state().objects.get(&creature).unwrap(); + let damage_trigger = obj + .trigger_definitions + .iter_unchecked() + .find(|entry| matches!(entry.definition.mode, TriggerMode::DamageDone)) + .expect("Angel of Destiny must have a DamageDone trigger"); + let execute = damage_trigger + .definition + .execute + .as_ref() + .expect("DamageDone trigger must have an execute body"); + assert!( + matches!( + execute.effect.as_ref(), + Effect::GainLife { + player: TargetFilter::Controller, + .. + } + ), + "GainLife.player must stay the well-defined Controller default, not an \ + unresolved compound-subject filter like Any, got {:?}", + execute.effect + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index cee9f2fbb6..0b221f2498 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -613,6 +613,7 @@ mod issue_6092_ability_block_reason; mod issue_6102_ragavan_exile_cast; mod issue_6157_gold_token_auto_mana_payment; mod issue_629_fractured_sanity_cycling; +mod issue_6381_benevolent_offering_repeat_opponent; mod issue_6403_moonmist_mass_transform; mod issue_6405_aang_multicolor_cost_reduction; mod issue_6416_extra_turn_resume_order; diff --git a/crates/engine/tests/integration/rules/tribute.rs b/crates/engine/tests/integration/rules/tribute.rs index 1c12efc64a..2c30e4628f 100644 --- a/crates/engine/tests/integration/rules/tribute.rs +++ b/crates/engine/tests/integration/rules/tribute.rs @@ -64,7 +64,7 @@ fn cast_tribute_creature(count: u32, paid: bool) -> GameRunner { .. } => { assert_eq!(*player, P0, "controller should be choosing the opponent"); - assert_eq!(*choice_type, ChoiceType::Opponent { restriction: None }); + assert_eq!(*choice_type, ChoiceType::opponent()); assert!( options.contains(&P1.0.to_string()), "P1 must be a valid opponent choice, got {options:?}" diff --git a/crates/engine/tests/integration/the_who_opponent_guess_resolution.rs b/crates/engine/tests/integration/the_who_opponent_guess_resolution.rs index 2fed2bc254..f4560960ad 100644 --- a/crates/engine/tests/integration/the_who_opponent_guess_resolution.rs +++ b/crates/engine/tests/integration/the_who_opponent_guess_resolution.rs @@ -126,7 +126,13 @@ fn choose_guessing_opponent(runner: &mut GameRunner, opponent: PlayerId) { "the controller chooses which opponent makes the guess" ); assert!( - matches!(choice_type, ChoiceType::Opponent { restriction: None }), + matches!( + choice_type, + ChoiceType::Opponent { + restriction: None, + .. + } + ), "expected opponent choice before the guess, got {choice_type:?}" ); assert!( diff --git a/crates/mtgish-import/src/convert/action.rs b/crates/mtgish-import/src/convert/action.rs index d4cdb8f421..edec390cce 100644 --- a/crates/mtgish-import/src/convert/action.rs +++ b/crates/mtgish-import/src/convert/action.rs @@ -4441,8 +4441,8 @@ pub fn convert(a: &Action) -> ConvResult { // existing `players_to_controller` bridge for opponent detection. Action::ChooseAPlayer(players) => { let choice_type = match filter_mod::players_to_controller(players.as_ref()) { - Ok(ControllerRef::Opponent) => ChoiceType::Opponent { restriction: None }, - _ => ChoiceType::Player, + Ok(ControllerRef::Opponent) => ChoiceType::opponent(), + _ => ChoiceType::player(), }; Effect::Choose { choice_type, diff --git a/crates/mtgish-import/src/convert/replacement.rs b/crates/mtgish-import/src/convert/replacement.rs index 1b9a2f6319..a11778a778 100644 --- a/crates/mtgish-import/src/convert/replacement.rs +++ b/crates/mtgish-import/src/convert/replacement.rs @@ -1864,15 +1864,15 @@ fn build_replacement_exec( persist: true, selection: engine::types::ability::TargetSelectionMode::Chosen, }, - // CR 800.4a: opponent-scoped player choice when the schema - // filter narrows to opponents; broader player choice + // CR 102.1-102.3 + CR 608.2d: opponent-scoped player choice when the + // schema filter narrows to opponents; broader player choice // otherwise. Re-uses the existing `players_to_controller` // bridge for opponent detection. A::ChooseAPlayer(players) => { let choice_type = match crate::convert::filter::players_to_controller(players.as_ref()) { - Ok(ControllerRef::Opponent) => ChoiceType::Opponent { restriction: None }, - _ => ChoiceType::Player, + Ok(ControllerRef::Opponent) => ChoiceType::opponent(), + _ => ChoiceType::player(), }; Effect::Choose { choice_type, From ff1aed6cbd1e635691c16ebebca8c3eb707bcf59 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 14:20:44 +0000 Subject: [PATCH 09/63] test(engine): pin the Adamant static-grant rider gate at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Silverflame Ritual's Adamant rider grants a continuous static effect ("creatures you control gain vigilance") rather than an ability-level effect, so its gate lands on `StaticDefinition.condition` (CR 613 layer 6) instead of the ability's own `condition`. The CI parse-diff bot reads the ability level only, so that layer move renders as `conditional: 3+ White spent -> ∅`, which reads like a dropped gate -- the exact bug class this file guards. It is not a drop: the condition is present and enforced. Verified at runtime in both polarities, because nothing pinned this subclass. The existing R8 rider guard (Slaying Fire) cannot catch it -- that payload is ability-level -- so a future refactor really could drop the static's condition with every other test here staying green. Both new tests carry a positive reach-guard asserting the card's UNGATED first line resolved (the +1/+1 counter), so the "no vigilance" negative cannot pass vacuously on a spell that never resolved. Co-Authored-By: Claude Opus 5 --- .../adamant_enters_with_leading_if_gate.rs | 100 +++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/crates/engine/tests/integration/adamant_enters_with_leading_if_gate.rs b/crates/engine/tests/integration/adamant_enters_with_leading_if_gate.rs index 77993981b8..3dc89acdc9 100644 --- a/crates/engine/tests/integration/adamant_enters_with_leading_if_gate.rs +++ b/crates/engine/tests/integration/adamant_enters_with_leading_if_gate.rs @@ -23,7 +23,7 @@ use engine::game::scenario::{CastOutcome, GameRunner, GameScenario, P0, P1}; use engine::types::ability::Effect; use engine::types::counter::CounterType; use engine::types::identifiers::ObjectId; -use engine::types::keywords::KeywordKind; +use engine::types::keywords::{Keyword, KeywordKind}; use engine::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType, ManaUnit}; use engine::types::phase::Phase; use engine::types::zones::Zone; @@ -366,3 +366,101 @@ fn slaying_fire_one_red_deals_three() { let outcome = cast_slaying_fire(&[(ManaType::Red, 1), (ManaType::Colorless, 2)]); outcome.assert_life_delta(P1, -3); } + +// --------------------------------------------------------------------------- +// R9 — the Adamant rider whose payload is a CONTINUOUS STATIC GRANT, not an +// ability-level effect. This subclass routes DIFFERENTLY from R8: the gate +// lands on `StaticDefinition.condition` (CR 613 layer 6 keyword grant) rather +// than on the ability's own `condition`, because the payload is a continuous +// effect over a set of permanents. +// +// Why this test exists: the CI parse-diff bot reports Silverflame Ritual's +// ABILITY-level `conditional` as `3+ White spent → ∅`, which reads like a +// dropped gate — the exact bug class this file guards. It is NOT a drop; the +// condition moved down one layer onto the static. The bot's signature reads the +// ability level only, so a layer move renders as `∅`. R8 cannot catch this +// because Slaying Fire's payload is ability-level. Nothing else pins it, so a +// future refactor really COULD drop the static's condition and every existing +// test here would stay green while "creatures you control gain vigilance" +// became unconditional. +// --------------------------------------------------------------------------- + +/// Silverflame Ritual {3}{W} — verbatim Oracle text (`data/card-data.json`). +const SILVERFLAME_RITUAL: &str = "Put a +1/+1 counter on each creature you control.\nAdamant — If \ + at least three white mana was spent to cast this spell, \ + creatures you control gain vigilance until end of turn."; + +/// Casts Silverflame Ritual with `payment` and returns the runner plus the +/// controller's creature, so the caller can read granted keywords off the board +/// AFTER resolution (a continuous grant is not visible in `CastOutcome` deltas). +fn cast_silverflame_ritual(payment: &[(ManaType, usize)]) -> (GameRunner, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let bear = scenario.add_vanilla(P0, 2, 2); + let ritual = scenario + .add_spell_to_hand_from_oracle(P0, "Silverflame Ritual", true, SILVERFLAME_RITUAL) + .with_mana_cost(ManaCost::Cost { + generic: 3, + shards: vec![ManaCostShard::White], + }) + .id(); + scenario.with_mana_pool(P0, pool(payment)); + let mut runner = scenario.build(); + assert!( + !runner.state().objects[&ritual] + .abilities + .iter() + .any(|a| matches!(&*a.effect, Effect::Unimplemented { .. })), + "Silverflame Ritual must parse with zero Effect::Unimplemented, got {:?}", + runner.state().objects[&ritual].abilities + ); + runner.cast(ritual).resolve(); + (runner, bear) +} + +/// R9 positive reach-guard: three white mana satisfies the rider → the creature +/// gains vigilance. Also proves the UNGATED half of the card resolved (the +/// +1/+1 counter), so a total failure to resolve cannot masquerade as a pass. +#[test] +fn silverflame_ritual_three_white_grants_vigilance() { + let (runner, bear) = cast_silverflame_ritual(&[(ManaType::White, 3), (ManaType::Colorless, 1)]); + let obj = &runner.state().objects[&bear]; + assert_eq!( + obj.counters + .get(&CounterType::Plus1Plus1) + .copied() + .unwrap_or(0), + 1, + "the ungated first line must always resolve — if this is 0 the spell never resolved \ + and the vigilance assertion below would be vacuous" + ); + assert!( + obj.has_keyword(&Keyword::Vigilance), + "three white mana satisfies the Adamant rider, so the static grant must apply" + ); +} + +/// R9 negative (paired with the reach-guard above): one white + three colorless +/// is four TOTAL mana but only one WHITE, so the rider must NOT fire. The +/// +1/+1 counter still lands, proving the spell resolved and the absence of +/// vigilance is a real gate decision rather than a non-resolution. +/// +/// This is the assertion that would fail if the static's condition were ever +/// dropped — i.e. if the `∅` the CI bot displays ever became literally true. +#[test] +fn silverflame_ritual_one_white_does_not_grant_vigilance() { + let (runner, bear) = cast_silverflame_ritual(&[(ManaType::White, 1), (ManaType::Colorless, 3)]); + let obj = &runner.state().objects[&bear]; + assert_eq!( + obj.counters + .get(&CounterType::Plus1Plus1) + .copied() + .unwrap_or(0), + 1, + "the ungated first line must still resolve, so the vigilance check below is not vacuous" + ); + assert!( + !obj.has_keyword(&Keyword::Vigilance), + "only one white mana was spent — the Adamant static grant must stay gated off" + ); +} From 0c5c771721b0118c8e596af971165808b31ac176 Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Wed, 29 Jul 2026 07:54:18 -0700 Subject: [PATCH 10/63] refactor(parser): retain trigger die tables in trigger IR (#6781) * refactor(parser): add trigger die-result IR infrastructure * refactor(parser): retain trigger die tables in IR * fix(parser): retain die tables on compound triggers --------- Co-authored-by: matthewevans --- crates/engine/src/parser/oracle.rs | 137 +-- crates/engine/src/parser/oracle_effect/mod.rs | 39 +- .../src/parser/oracle_ir/snapshot_tests.rs | 268 ++++++ ..._ir__snapshot_tests__bomat_courier_ir.snap | 153 +++- ..._snapshot_tests__bonecrusher_giant_ir.snap | 149 +++- ...t_tests__case_of_the_crimson_pulse_ir.snap | 444 ++++++--- ...ests__case_of_the_stashed_skeleton_ir.snap | 181 ++-- ...ir__snapshot_tests__caustic_bronco_ir.snap | 434 ++++++--- ...napshot_tests__chalice_of_the_void_ir.snap | 176 ++-- ...r__snapshot_tests__conclave_mentor_ir.snap | 150 +++- ...ir__snapshot_tests__dark_confidant_ir.snap | 310 +++++-- ...snapshot_tests__edgewall_innkeeper_ir.snap | 157 +++- ..._tests__eidolon_of_the_great_revel_ir.snap | 173 ++-- ...r__snapshot_tests__fevered_visions_ir.snap | 258 ++++-- ...e_ir__snapshot_tests__goblin_guide_ir.snap | 228 +++-- ...ir__snapshot_tests__jaws_of_defeat_ir.snap | 200 +++-- ...apshot_tests__karn_legacy_reforged_ir.snap | 287 ++++-- ...pshot_tests__kathril_aspect_warper_ir.snap | 840 +++++------------- ...le_ir__snapshot_tests__kroxa_titan_ir.snap | 423 ++++++--- ...pshot_tests__liliana_the_repentant_ir.snap | 215 +++-- ...snapshot_tests__luminarch_aspirant_ir.snap | 155 +++- ...snapshot_tests__mishra_eminent_one_ir.snap | 410 ++++++--- ...r__snapshot_tests__murderous_rider_ir.snap | 151 +++- ...shot_tests__nashi_moon_sages_scion_ir.snap | 299 +++++-- ...pshot_tests__odric_lunarch_marshal_ir.snap | 743 +++++----------- ...ir__snapshot_tests__questing_beast_ir.snap | 167 ++-- ...apshot_tests__reckless_bushwhacker_ir.snap | 213 +++-- ...__snapshot_tests__smugglers_copter_ir.snap | 224 +++-- ...r__snapshot_tests__snapcaster_mage_ir.snap | 286 ++++-- ..._snapshot_tests__stoneforge_mystic_ir.snap | 256 ++++-- ...ir__snapshot_tests__sylvan_library_ir.snap | 285 ++++-- ...e_ir__snapshot_tests__themberchaud_ir.snap | 188 ++-- ...__snapshot_tests__tireless_tracker_ir.snap | 311 +++++-- ...__snapshot_tests__young_pyromancer_ir.snap | 215 +++-- crates/engine/src/parser/oracle_ir/trigger.rs | 47 +- crates/engine/src/parser/oracle_special.rs | 45 +- crates/engine/src/parser/oracle_trigger.rs | 27 +- 37 files changed, 5887 insertions(+), 3357 deletions(-) diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index a91e399735..97927aabf3 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -75,7 +75,7 @@ use super::oracle_ir::feature::ItemIdTracks; use super::oracle_ir::relation::{DocumentRelationIr, LinkedChoiceKind, LinkedReturnOutcome}; use super::oracle_ir::replacement::ReplacementIr; use super::oracle_ir::static_ir::StaticIr; -use super::oracle_ir::trigger::TriggerNodeIr; +use super::oracle_ir::trigger::{TriggerIr, TriggerNodeIr}; pub use super::oracle_keyword::keyword_display_name; use super::oracle_keyword::{ is_keyword_cost_line, is_kicker_family_line, parse_kicker_additional_cost_line, @@ -96,8 +96,8 @@ use super::oracle_saga::{is_saga_chapter, parse_saga_chapters}; use super::oracle_spacecraft::parse_spacecraft_threshold_lines; use super::oracle_special::{ attach_die_result_branches_to_chain, normalize_self_refs_for_static, - parse_cumulative_upkeep_keyword, parse_defiler_cost_reduction, parse_harmonize_keyword, - parse_mayhem_keyword, parse_solve_condition, try_parse_die_roll_table, + parse_cumulative_upkeep_keyword, parse_defiler_cost_reduction, parse_die_result_branches_ir, + parse_harmonize_keyword, parse_mayhem_keyword, parse_solve_condition, try_parse_die_roll_table, }; use super::oracle_static::{ is_speed_unlock_sentence, lower_static_ir, parse_alternative_keyword_cost, @@ -107,7 +107,10 @@ use super::oracle_static::{ try_parse_graveyard_keyword_grant_static, try_parse_top_of_library_cast_permission, GrantedCastKeywordKind, }; -use super::oracle_trigger::{lower_trigger_node_ir, parse_trigger_lines_at_index}; +use super::oracle_trigger::{ + lower_trigger_ir, lower_trigger_node_ir, parse_trigger_lines_at_index, + parse_trigger_lines_at_index_ir, +}; use super::oracle_util::{ normalize_card_name_refs, parse_mana_symbols, parse_number, split_same_is_true_static_tail, strip_reminder_text, TextPair, GRANTING_SELF_PLACEHOLDER, @@ -1210,11 +1213,10 @@ fn item_replacement(item: &OracleItemIr) -> Option<&ReplacementDefinition> { /// that closed representation without a wildcard, so a fourth spell payload /// must be handled here and in `lower_spell_node` at compile time. /// -/// `item_trigger` still keeps a borrow and pushes its own exhaustiveness down -/// onto `TriggerNodeIr::definition()`, because both of its shapes own a -/// `TriggerDefinition`. The spell layer differs only in its representations: -/// the IR-native and residual payloads must be lowered into owned definitions, -/// while the already-lowered payload can lend its definition. +/// `item_trigger` uses the trigger-side equivalent of `item_ability`: an +/// assembled node lends its definition, while a parsed node lowers into an +/// owned `Cow`. Relations therefore observe the same definition document +/// lowering will publish without fabricating a pre-lowered representation. /// /// Lowering is the same `lower_ability_ir` call `lower_oracle_ir` (the `Spell` /// arm) will make for the same item, so a relation predicate sees exactly the @@ -1241,15 +1243,18 @@ fn item_ability(item: &OracleItemIr) -> Option> { /// the regression would surface on a DIFFERENT card from the converted one, /// where per-card byte-identity cannot catch it. /// -/// The exhaustiveness obligation lives on `TriggerNodeIr::definition()`, not on -/// this match, and that is the correct layer: a new trigger representation is a -/// new `TriggerNodeIr` variant, so it breaks that match at compile time before -/// it can reach here. Enumerating `OracleNodeIr` instead would add nothing — -/// every other variant is genuinely `None`. -fn item_trigger(item: &OracleItemIr) -> Option<&TriggerDefinition> { +/// The match is exhaustive over `TriggerNodeIr`, so a new trigger +/// representation cannot silently evade relation discovery. Every other +/// `OracleNodeIr` variant is genuinely `None` here. +fn item_trigger(item: &OracleItemIr) -> Option> { match &item.node { - OracleNodeIr::Trigger(node) => node.definition(), - OracleNodeIr::PreLoweredTrigger(def) => Some(def), + OracleNodeIr::Trigger(TriggerNodeIr::Parsed(trigger)) => { + Some(Cow::Owned(lower_trigger_ir(trigger))) + } + OracleNodeIr::Trigger(TriggerNodeIr::Assembled { definition, .. }) => { + Some(Cow::Borrowed(definition.as_ref())) + } + OracleNodeIr::PreLoweredTrigger(def) => Some(Cow::Borrowed(def)), _ => None, } } @@ -1543,9 +1548,12 @@ fn detect_linked_choice_type_statics( ) }); let is_dig = item_ability(item).is_some_and(|def| ability_chain_has_dig(&def)) - || item_trigger(item) - .and_then(|trigger| trigger.execute.as_deref()) - .is_some_and(ability_chain_has_dig); + || item_trigger(item).is_some_and(|trigger| { + trigger + .execute + .as_deref() + .is_some_and(ability_chain_has_dig) + }); if is_cost_reducer || is_dig { retarget.push(item.id); } @@ -1668,8 +1676,8 @@ fn chosen_subtype_kind_from_persisted_choice_items( .or_else(|| { items .iter() - .filter_map(|item| item_trigger(item)?.execute.as_deref()) - .find_map(chosen_subtype_kind_from_ability) + .filter_map(|item| item_trigger(item).and_then(|trigger| trigger.execute.clone())) + .find_map(|ability| chosen_subtype_kind_from_ability(&ability)) }) } @@ -1765,7 +1773,8 @@ fn detect_linked_choice_persisted_player( let has_durable_reader = items.iter().any(|item| { item_static(item).is_some_and(static_references_source_chosen_player) || item_ability(item).is_some_and(|def| ability_references_source_chosen_player(&def)) - || item_trigger(item).is_some_and(trigger_references_source_chosen_player) + || item_trigger(item) + .is_some_and(|trigger| trigger_references_source_chosen_player(&trigger)) }); if !has_durable_reader { return; @@ -1774,9 +1783,12 @@ fn detect_linked_choice_persisted_player( .iter() .filter(|item| { item_ability(item).is_some_and(|def| ability_chain_has_player_choice(&def)) - || item_trigger(item) - .and_then(|trigger| trigger.execute.as_deref()) - .is_some_and(ability_chain_has_player_choice) + || item_trigger(item).is_some_and(|trigger| { + trigger + .execute + .as_deref() + .is_some_and(ability_chain_has_player_choice) + }) }) .map(|item| item.id) .collect(); @@ -3505,13 +3517,14 @@ fn trigger_is_etb_exile_pending_duration(def: &TriggerDefinition) -> bool { fn detect_etb_exile_ltb_return(items: &[OracleItemIr], relations: &mut Vec) { let ltb_return = items .iter() - .find(|item| item_trigger(item).is_some_and(trigger_is_ltb_return)); + .find(|item| item_trigger(item).is_some_and(|trigger| trigger_is_ltb_return(&trigger))); let (ltb, outcome) = match ltb_return { Some(ltb) => (ltb, LinkedReturnOutcome::DurationStamped), None => { let Some(ltb) = items.iter().find(|item| { - item_trigger(item).is_some_and(trigger_is_ltb_return_with_entry_modifier) + item_trigger(item) + .is_some_and(|trigger| trigger_is_ltb_return_with_entry_modifier(&trigger)) }) else { return; }; @@ -3526,7 +3539,8 @@ fn detect_etb_exile_ltb_return(items: &[OracleItemIr], relations: &mut Vec DocEmitter<'a> { } } +/// Attaches a following die-result table to every terminal die-roll trigger +/// produced from one printed line. Compound triggers share that line's table. +/// +/// CR 706.3b: A die result table belongs to the die roll it follows. Leave the +/// scanner at `start_line` when no trigger owns a terminal die roll so ordinary +/// dispatch can retain the following lines. +fn attach_trigger_die_result_branches( + triggers: &mut [TriggerIr], + lines: &[&str], + start_line: usize, +) -> usize { + if !triggers.iter().any(TriggerIr::has_terminal_roll_die) { + return start_line; + } + + let (branches, next_line) = parse_die_result_branches_ir(lines, start_line, AbilityKind::Spell); + for trigger in triggers + .iter_mut() + .filter(|trigger| trigger.has_terminal_roll_die()) + { + trigger.die_results = branches.clone(); + } + next_line +} + /// Produce an `OracleDocIr` from Oracle text — the IR-production half of the /// parse/lower split (Phase 49, Plan 03). /// @@ -5107,24 +5146,20 @@ pub(crate) fn parse_oracle_ir( // CR 707.9a: Pass the running trigger count as the base index so // any "and it has this ability" except clause in this trigger's // body resolves to the correct printed-trigger slot. - let mut triggers = parse_trigger_lines_at_index( + let mut triggers = parse_trigger_lines_at_index_ir( &line, card_name, Some(PrintedTriggerIndex::placeholder()), &mut ctx, ); i += 1; - // CR 706: If the trigger's effect ends with "roll a dN", consume - // subsequent d20 table lines and attach them as die result branches. + // CR 706.3b: Preserve table rows as trigger IR until body lowering + // attaches them before finalization. if has_roll_die_pattern(&lower) { - if let Some(last) = triggers.last_mut() { - if let Some(ref mut execute) = last.execute { - i = attach_die_result_branches_to_chain(execute, &lines, i); - } - } + i = attach_trigger_die_result_branches(&mut triggers, &lines, i); } for __item in triggers { - emitter.trigger_at(item_line, __item); + emitter.trigger_ir_at(item_line, TriggerNodeIr::Parsed(Box::new(__item))); } continue; } @@ -5159,7 +5194,7 @@ pub(crate) fn parse_oracle_ir( } if has_trigger_prefix(&effect_lower) { // CR 707.9a: Thread the running trigger count as the base index. - let mut triggers = parse_trigger_lines_at_index( + let mut triggers = parse_trigger_lines_at_index_ir( &effect_text, card_name, Some(PrintedTriggerIndex::placeholder()), @@ -5168,20 +5203,18 @@ pub(crate) fn parse_oracle_ir( // B7: Attach ability-word condition as fallback when extract_if_condition // doesn't recognize the intervening-if pattern. for trigger in &mut triggers { - if trigger.condition.is_none() { - trigger.condition = ability_word_to_trigger_condition(&aw_name); + if trigger.partial_def.condition.is_none() + && trigger.modifiers.intervening_if.is_none() + { + trigger.partial_def.condition = ability_word_to_trigger_condition(&aw_name); } } i += 1; if has_roll_die_pattern(&effect_lower) { - if let Some(last) = triggers.last_mut() { - if let Some(ref mut execute) = last.execute { - i = attach_die_result_branches_to_chain(execute, &lines, i); - } - } + i = attach_trigger_die_result_branches(&mut triggers, &lines, i); } for __item in triggers { - emitter.trigger_at(item_line, __item); + emitter.trigger_ir_at(item_line, TriggerNodeIr::Parsed(Box::new(__item))); } continue; } @@ -6520,7 +6553,7 @@ pub(crate) fn parse_oracle_ir( // Try as trigger if has_trigger_prefix(&effect_lower) { // CR 707.9a: Thread the running trigger count as the base index. - let mut triggers = parse_trigger_lines_at_index( + let mut triggers = parse_trigger_lines_at_index_ir( &effect_text, card_name, Some(PrintedTriggerIndex::placeholder()), @@ -6529,14 +6562,10 @@ pub(crate) fn parse_oracle_ir( i += 1; // CR 706: Consume subsequent d20 table lines for triggered die rolls. if has_roll_die_pattern(&effect_lower) { - if let Some(last) = triggers.last_mut() { - if let Some(ref mut execute) = last.execute { - i = attach_die_result_branches_to_chain(execute, &lines, i); - } - } + i = attach_trigger_die_result_branches(&mut triggers, &lines, i); } for __item in triggers { - emitter.trigger_at(item_line, __item); + emitter.trigger_ir_at(item_line, TriggerNodeIr::Parsed(Box::new(__item))); } continue; } diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 95786e59d7..d55e5f7e43 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -26967,21 +26967,7 @@ pub(crate) enum ChainLoweringMode { /// non-die producer byte-identical. pub(crate) fn lower_ability_ir(ir: &AbilityIr) -> AbilityDefinition { let mut def = lower_effect_chain_ir(&ir.body); - if !ir.die_results.is_empty() { - if let Some(Effect::RollDie { results, .. }) = - super::oracle_special::find_terminal_roll_die(&mut def) - { - *results = ir - .die_results - .iter() - .map(|DieResultBranchIr { min, max, effect }| DieResultBranch { - min: *min, - max: *max, - effect: Box::new(lower_ability_ir(effect)), - }) - .collect(); - } - } + attach_die_result_branches_before_finalization(&mut def, &ir.die_results); finalize_effect_chain(&mut def); apply_owner_library_reveal_anchor_from_text(&mut def, &ir.source_text); // CR 608.2c: a root the chain cannot describe (it has no previous boundary). @@ -27002,6 +26988,29 @@ pub(crate) fn lower_ability_ir(ir: &AbilityIr) -> AbilityDefinition { def } +/// CR 706.3b: Attach typed die-result branches before finalization so every +/// caller lowers each branch through the same `lower_ability_ir` authority. +pub(crate) fn attach_die_result_branches_before_finalization( + def: &mut AbilityDefinition, + die_results: &[DieResultBranchIr], +) { + if die_results.is_empty() { + return; + } + if let Some(Effect::RollDie { results, .. }) = + super::oracle_special::find_terminal_roll_die(def) + { + *results = die_results + .iter() + .map(|DieResultBranchIr { min, max, effect }| DieResultBranch { + min: *min, + max: *max, + effect: Box::new(lower_ability_ir(effect)), + }) + .collect(); + } +} + /// Apply the CR 602.1 activation envelope onto an already-lowered root. /// /// Every field is **defer-on-default**, so a `default()` shell is a no-op and the diff --git a/crates/engine/src/parser/oracle_ir/snapshot_tests.rs b/crates/engine/src/parser/oracle_ir/snapshot_tests.rs index 2fce6e9e17..321fe1b865 100644 --- a/crates/engine/src/parser/oracle_ir/snapshot_tests.rs +++ b/crates/engine/src/parser/oracle_ir/snapshot_tests.rs @@ -7,7 +7,9 @@ use crate::parser::oracle::{lower_oracle_ir, parse_oracle_ir, ParsedAbilities}; use crate::parser::oracle_ir::diagnostic::OracleDiagnostic; use crate::parser::oracle_ir::doc::{OracleDocIr, OracleNodeIr}; +use crate::parser::oracle_ir::trigger::TriggerNodeIr; use crate::types::ability::MultiTargetSpec; +use crate::types::ability::{Effect, TriggerCondition}; use crate::types::game_state::DistributionUnit; /// Parse Oracle text through both IR and lowering layers. @@ -35,6 +37,272 @@ fn parse_two_layer_with_keywords( (ir, lowered) } +/// CR 706.3b: ordinary trigger dispatch retains a die-result table in native +/// trigger IR and attaches it to the terminal roll before finalization. +#[test] +fn direct_trigger_die_table_is_ir_native_and_lowers_as_one_ability() { + let (ir, lowered) = parse_two_layer( + "Whenever this creature attacks, roll a d20.\n1—9 | Create a Treasure token. (It's an artifact with \"{T}, Sacrifice this token: Add one mana of any color.\")\n10—19 | Create two Treasure tokens.\n20 | Create three Treasure tokens.", + "Hoarding Ogre", + &["Creature"], + &["Giant"], + ); + + assert_eq!( + ir.items.len(), + 1, + "result rows must not become document items" + ); + let OracleNodeIr::Trigger(TriggerNodeIr::Parsed(trigger)) = &ir.items[0].node else { + panic!( + "expected a native parsed trigger, got {:?}", + ir.items[0].node + ); + }; + assert_eq!(trigger.die_results.len(), 3); + assert_eq!( + trigger + .die_results + .iter() + .map(|branch| (branch.min, branch.max)) + .collect::>(), + vec![(1, 9), (10, 19), (20, 20)] + ); + + let execute = lowered.triggers[0] + .execute + .as_deref() + .expect("Hoarding Ogre trigger must have an execute ability"); + let Effect::RollDie { results, .. } = execute.effect.as_ref() else { + panic!( + "expected terminal roll-die effect, got {:?}", + execute.effect + ); + }; + assert_eq!(results.len(), 3); + assert!( + results + .iter() + .all(|branch| !matches!(branch.effect.effect.as_ref(), Effect::Unimplemented { .. })), + "table branches must be lowered through the ordinary ability authority: {results:?}" + ); +} + +/// CR 603.2 + CR 706.3b: Each trigger produced by a compound trigger line owns +/// the following die-result table when its body ends in that line's die roll. +#[test] +fn compound_trigger_die_table_attaches_to_every_terminal_roll() { + let (ir, lowered) = parse_two_layer( + "Whenever this creature attacks and whenever this creature blocks, roll a d20.\n1—9 | Draw a card.\n10—20 | Create a Treasure token.", + "Compound Die Fixture", + &["Creature"], + &[], + ); + + assert_eq!( + ir.items.len(), + 2, + "result rows must not become document items" + ); + for item in &ir.items { + let OracleNodeIr::Trigger(TriggerNodeIr::Parsed(trigger)) = &item.node else { + panic!( + "expected native parsed compound triggers, got {:?}", + item.node + ); + }; + assert_eq!(trigger.die_results.len(), 2); + } + + assert_eq!(lowered.triggers.len(), 2); + for trigger in &lowered.triggers { + assert!(matches!( + trigger.execute.as_deref().map(|execute| execute.effect.as_ref()), + Some(Effect::RollDie { results, .. }) if results.len() == 2 + )); + } + + let (shared_subject_ir, shared_subject_lowered) = parse_two_layer( + "Whenever this creature attacks, blocks, or becomes the target of a spell, roll a d20.\n1—9 | Draw a card.\n10—20 | Create a Treasure token.", + "Shared Subject Die Fixture", + &["Creature"], + &[], + ); + assert_eq!(shared_subject_ir.items.len(), 3); + assert_eq!(shared_subject_lowered.triggers.len(), 3); + for (item, trigger) in shared_subject_ir + .items + .iter() + .zip(&shared_subject_lowered.triggers) + { + let OracleNodeIr::Trigger(TriggerNodeIr::Parsed(parsed)) = &item.node else { + panic!("expected a native parsed shared-subject trigger"); + }; + assert_eq!(parsed.die_results.len(), 2); + assert!(matches!( + trigger.execute.as_deref().map(|execute| execute.effect.as_ref()), + Some(Effect::RollDie { results, .. }) if results.len() == 2 + )); + } +} + +/// CR 118.12 + CR 603.12 + CR 706.3b: A reflexive payment owns its nested +/// terminal die roll, so the parent trigger's result rows lower into that +/// `When you do` sub-ability. +#[test] +fn reflexive_payment_trigger_retains_die_table_on_nested_roll() { + let (ir, lowered) = parse_two_layer( + "Whenever this creature attacks, you may pay {1}. When you do, roll a d20.\n1—9 | Draw a card.\n10—20 | Create a Treasure token.", + "Reflexive Die Fixture", + &["Creature"], + &[], + ); + + let OracleNodeIr::Trigger(TriggerNodeIr::Parsed(trigger)) = &ir.items[0].node else { + panic!("expected a native parsed trigger"); + }; + assert!(trigger.has_terminal_roll_die()); + assert_eq!(trigger.die_results.len(), 2); + + let pay = lowered.triggers[0] + .execute + .as_deref() + .expect("trigger must have an execute ability"); + let Effect::PayCost { .. } = pay.effect.as_ref() else { + panic!("expected reflexive payment root, got {:?}", pay.effect); + }; + let reflexive_roll = pay + .sub_ability + .as_deref() + .expect("payment must retain its reflexive sub-ability"); + assert!(matches!( + reflexive_roll.effect.as_ref(), + Effect::RollDie { results, .. } if results.len() == 2 + )); +} + +/// The ability-word trigger route is likewise native IR. Its existing fallback +/// condition is applied only when trigger parsing did not already find one. +#[test] +fn ability_word_trigger_table_is_ir_native_and_preserves_condition_fallback() { + let (ir, lowered) = parse_two_layer( + "Wild Magic Surge — Whenever this creature attacks, roll a d20.\n1—9 | Exile the top card of your library. You may play it this turn.\n10—19 | Exile the top two cards of your library. You may play them this turn.\n20 | Exile the top three cards of your library. You may play them this turn.", + "Chaos Channeler", + &["Creature"], + &["Human", "Shaman"], + ); + let OracleNodeIr::Trigger(TriggerNodeIr::Parsed(trigger)) = &ir.items[0].node else { + panic!("expected a native parsed ability-word trigger"); + }; + assert_eq!(trigger.die_results.len(), 3); + assert!(matches!( + lowered.triggers[0].execute.as_deref().map(|execute| execute.effect.as_ref()), + Some(Effect::RollDie { results, .. }) if results.len() == 3 + )); + + let (threshold_ir, threshold_lowered) = parse_two_layer( + "Threshold — Whenever this creature attacks, draw a card.", + "Threshold Fixture", + &["Creature"], + &[], + ); + let OracleNodeIr::Trigger(TriggerNodeIr::Parsed(threshold)) = &threshold_ir.items[0].node + else { + panic!("expected a native parsed threshold trigger"); + }; + assert!( + threshold.modifiers.intervening_if.is_none(), + "fixture must exercise the ability-word fallback rather than an explicit if clause" + ); + assert!(matches!( + threshold_lowered.triggers[0].condition, + Some(TriggerCondition::QuantityComparison { .. }) + )); + + let (shoreline_ir, shoreline_lowered) = parse_two_layer( + "Delirium — Whenever this creature deals combat damage to a player, draw a card. Then discard a card unless there are seven or more cards in your graveyard.", + "Shoreline Looter", + &["Creature"], + &["Rat", "Rogue"], + ); + let OracleNodeIr::Trigger(TriggerNodeIr::Parsed(shoreline)) = &shoreline_ir.items[0].node + else { + panic!("expected a native parsed Shoreline Looter trigger"); + }; + assert!(shoreline.partial_def.condition.is_none()); + assert!( + shoreline.modifiers.intervening_if.is_some(), + "parsed intervening-if must suppress the ability-word fallback" + ); + assert!(matches!( + shoreline_lowered.triggers[0].condition, + Some(TriggerCondition::Not { .. }) + )); +} + +/// The table scanner consumes only actual rows. A following ordinary line stays +/// available to document dispatch, while unsupported table text remains honest. +#[test] +fn trigger_die_table_scanner_preserves_non_table_and_unsupported_rows() { + let (non_table_ir, non_table_lowered) = parse_two_layer( + "Whenever this creature attacks, roll a d20.\nDraw a card.", + "Non-table Fixture", + &["Creature"], + &[], + ); + assert_eq!( + non_table_ir.items.len(), + 2, + "ordinary next line must remain dispatched" + ); + assert!(matches!( + non_table_ir.items[0].node, + OracleNodeIr::Trigger(_) + )); + assert!(matches!(non_table_ir.items[1].node, OracleNodeIr::Spell(_))); + assert!(matches!( + non_table_lowered.abilities[0].effect.as_ref(), + Effect::Draw { .. } + )); + + let (_, unsupported_lowered) = parse_two_layer( + "Whenever this creature attacks, roll a d20.\n1—20 | Frobnicate target creature.", + "Unsupported Table Fixture", + &["Creature"], + &[], + ); + let execute = unsupported_lowered.triggers[0] + .execute + .as_deref() + .expect("trigger must still lower"); + let Effect::RollDie { results, .. } = execute.effect.as_ref() else { + panic!("expected roll die"); + }; + assert!(matches!( + results[0].effect.effect.as_ref(), + Effect::Unimplemented { .. } + )); + + let (mastiff_ir, mastiff_lowered) = parse_two_layer( + "Whenever this creature attacks, roll a d20 for each player being attacked and ignore all but the highest roll.\n1—9 | This creature deals damage equal to its power to you.\n10—19 | This creature deals damage equal to its power to defending player.\n20 | This creature deals damage equal to its power to each opponent.", + "Iron Mastiff", + &["Artifact", "Creature"], + &["Dog"], + ); + let OracleNodeIr::Trigger(TriggerNodeIr::Parsed(mastiff)) = &mastiff_ir.items[0].node else { + panic!("expected a native parsed Iron Mastiff trigger"); + }; + assert!( + !mastiff.has_terminal_roll_die(), + "unsupported multi-player roll must not consume a result table" + ); + assert_eq!(mastiff_ir.items.len(), 4); + assert!(mastiff_ir.items[1..] + .iter() + .all(|item| matches!(item.node, OracleNodeIr::Unsupported { .. }))); + assert_eq!(mastiff_lowered.abilities.len(), 3); +} + /// ISSUES #17: the swallow audit's findings must live in the doc IR's diagnostics /// channel, not be direct-appended to `ParsedAbilities::parse_warnings` behind the /// doc's back. diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__bomat_courier_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__bomat_courier_ir.snap index bc9a8adce9..3504c919a1 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__bomat_courier_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__bomat_courier_ir.snap @@ -50,52 +50,119 @@ expression: "&ir" "fragment": "Whenever ~ attacks, exile the top card of your library face down. (You can't look at it.)" }, "node": { - "PreLoweredTrigger": { - "mode": "Attacks", - "execute": { - "kind": "Spell", - "effect": { - "type": "ExileTop", - "player": { - "type": "Controller" + "Trigger": { + "Parsed": { + "condition": "Attacks", + "partial_def": { + "mode": "Attacks", + "execute": null, + "valid_card": { + "type": "SelfRef" }, - "count": { - "type": "Fixed", - "value": 1 - }, - "position": { - "type": "Top" + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 44, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "exile the top card of your library face down" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "ExileTop", + "player": { + "type": "Controller" + }, + "count": { + "type": "Fixed", + "value": 1 + }, + "position": { + "type": "Top" + }, + "face_down": true + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null + } + }, + "modifiers": { + "optional": false, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "SelfRef" }, - "face_down": true + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "exile the top card of your library face down.", + "relative_player_scope": null }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": { - "type": "SelfRef" - }, - "origin": null, - "destination": null, - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "Whenever ~ attacks, exile the top card of your library face down.", - "constraint": null, - "condition": null, - "batched": false + "source_text": "Whenever ~ attacks, exile the top card of your library face down.", + "die_results": [] + } } } }, diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__bonecrusher_giant_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__bonecrusher_giant_ir.snap index 286ed5fd0e..1cd79498af 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__bonecrusher_giant_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__bonecrusher_giant_ir.snap @@ -22,50 +22,117 @@ expression: "&ir" "fragment": "Whenever ~ becomes the target of a spell, ~ deals 2 damage to that spell's controller." }, "node": { - "PreLoweredTrigger": { - "mode": "BecomesTarget", - "execute": { - "kind": "Spell", - "effect": { - "type": "DealDamage", - "amount": { - "type": "Fixed", - "value": 2 + "Trigger": { + "Parsed": { + "condition": "BecomesTarget", + "partial_def": { + "mode": "BecomesTarget", + "execute": null, + "valid_card": { + "type": "SelfRef" }, - "target": { - "type": "TriggeringSpellController" + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": { + "type": "StackSpell" + }, + "description": null, + "constraint": null, + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 43, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "~ deals 2 damage to that spell's controller" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "DealDamage", + "amount": { + "type": "Fixed", + "value": 2 + }, + "target": { + "type": "TriggeringSpellController" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null } }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": { - "type": "SelfRef" - }, - "origin": null, - "destination": null, - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": { - "type": "StackSpell" - }, - "description": "Whenever ~ becomes the target of a spell, ~ deals 2 damage to that spell's controller.", - "constraint": null, - "condition": null, - "batched": false + "modifiers": { + "optional": false, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "SelfRef" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "~ deals 2 damage to that spell's controller.", + "relative_player_scope": null + }, + "source_text": "Whenever ~ becomes the target of a spell, ~ deals 2 damage to that spell's controller.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__case_of_the_crimson_pulse_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__case_of_the_crimson_pulse_ir.snap index ebe70705fd..0264d834b8 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__case_of_the_crimson_pulse_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__case_of_the_crimson_pulse_ir.snap @@ -22,69 +22,170 @@ expression: "&ir" "fragment": "When this ~ enters, discard a card, then draw two cards." }, "node": { - "PreLoweredTrigger": { - "mode": "ChangesZone", - "execute": { - "kind": "Spell", - "effect": { - "type": "Discard", - "count": { - "type": "Fixed", - "value": 1 + "Trigger": { + "Parsed": { + "condition": "ChangesZone", + "partial_def": { + "mode": "ChangesZone", + "execute": null, + "valid_card": { + "type": "SelfRef" }, - "target": { - "type": "Controller" - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "Draw", - "count": { - "type": "Fixed", - "value": 2 - }, - "target": { - "type": "Controller" - } - }, - "cost": null, - "sub_ability": null, - "duration": null, + "origin": null, + "destination": "Battlefield", + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, "description": null, - "target_prompt": null, + "constraint": null, "condition": null, - "optional_targeting": false, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 14, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "discard a card" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Discard", + "count": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "Controller" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Then", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 21, + "end_byte": 35, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "draw two cards" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Draw", + "count": { + "type": "Fixed", + "value": 2 + }, + "target": { + "type": "Controller" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null + } + }, + "modifiers": { "optional": false, - "forward_result": false + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "SelfRef" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "discard a card, then draw two cards.", + "relative_player_scope": null }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": { - "type": "SelfRef" - }, - "origin": null, - "destination": "Battlefield", - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "When this ~ enters, discard a card, then draw two cards.", - "constraint": null, - "condition": null, - "batched": false + "source_text": "When this ~ enters, discard a card, then draw two cards.", + "die_results": [] + } } } }, @@ -146,74 +247,175 @@ expression: "&ir" "fragment": "Solved — At the beginning of your upkeep, discard your hand, then draw two cards." }, "node": { - "PreLoweredTrigger": { - "mode": "Phase", - "execute": { - "kind": "Spell", - "effect": { - "type": "Discard", - "count": { - "type": "Ref", - "qty": { - "type": "HandSize", - "player": { - "type": "Controller" - } - } + "Trigger": { + "Parsed": { + "condition": "Phase", + "partial_def": { + "mode": "Phase", + "execute": null, + "valid_card": null, + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": "Upkeep", + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": { + "type": "OnlyDuringYourTurn" }, - "target": { - "type": "Controller" + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 17, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "discard your hand" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Discard", + "count": { + "type": "Ref", + "qty": { + "type": "HandSize", + "player": { + "type": "Controller" + } + } + }, + "target": { + "type": "Controller" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Then", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 24, + "end_byte": 38, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "draw two cards" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Draw", + "count": { + "type": "Fixed", + "value": 2 + }, + "target": { + "type": "Controller" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null } }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "Draw", - "count": { - "type": "Fixed", - "value": 2 - }, - "target": { - "type": "Controller" - } - }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, + "modifiers": { "optional": false, - "forward_result": false + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "Any" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "discard your hand, then draw two cards.", + "relative_player_scope": null }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": null, - "origin": null, - "destination": null, - "trigger_zones": [ - "Battlefield" - ], - "phase": "Upkeep", - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "At the beginning of your upkeep, discard your hand, then draw two cards.", - "constraint": { - "type": "OnlyDuringYourTurn" - }, - "condition": null, - "batched": false + "source_text": "At the beginning of your upkeep, discard your hand, then draw two cards.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__case_of_the_stashed_skeleton_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__case_of_the_stashed_skeleton_ir.snap index 17dd0a21fe..483d01c5b9 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__case_of_the_stashed_skeleton_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__case_of_the_stashed_skeleton_ir.snap @@ -22,67 +22,134 @@ expression: "&ir" "fragment": "When this ~ enters, create a 2/1 black Skeleton creature token and suspect it. (It has menace and can't block.)" }, "node": { - "PreLoweredTrigger": { - "mode": "ChangesZone", - "execute": { - "kind": "Spell", - "effect": { - "type": "Token", - "name": "Skeleton", - "power": { - "type": "Fixed", - "value": 2 + "Trigger": { + "Parsed": { + "condition": "ChangesZone", + "partial_def": { + "mode": "ChangesZone", + "execute": null, + "valid_card": { + "type": "SelfRef" }, - "toughness": { - "type": "Fixed", - "value": 1 - }, - "types": [ - "Creature", - "Skeleton" - ], - "colors": [ - "Black" + "origin": null, + "destination": "Battlefield", + "trigger_zones": [ + "Battlefield" ], - "keywords": [], - "tapped": false, - "count": { - "type": "Fixed", - "value": 1 - }, - "owner": { - "type": "Controller" + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 57, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "create a 2/1 black Skeleton creature token and suspect it" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Token", + "name": "Skeleton", + "power": { + "type": "Fixed", + "value": 2 + }, + "toughness": { + "type": "Fixed", + "value": 1 + }, + "types": [ + "Creature", + "Skeleton" + ], + "colors": [ + "Black" + ], + "keywords": [], + "tapped": false, + "count": { + "type": "Fixed", + "value": 1 + }, + "owner": { + "type": "Controller" + }, + "enters_attacking": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null + } + }, + "modifiers": { + "optional": false, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "SelfRef" }, - "enters_attacking": false + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "create a 2/1 black skeleton creature token and suspect it.", + "relative_player_scope": null }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": { - "type": "SelfRef" - }, - "origin": null, - "destination": "Battlefield", - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "When this ~ enters, create a 2/1 black Skeleton creature token and suspect it.", - "constraint": null, - "condition": null, - "batched": false + "source_text": "When this ~ enters, create a 2/1 black Skeleton creature token and suspect it.", + "die_results": [] + } } } }, diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__caustic_bronco_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__caustic_bronco_ir.snap index 655531fbfc..895ade8d80 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__caustic_bronco_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__caustic_bronco_ir.snap @@ -22,137 +22,329 @@ expression: "&ir" "fragment": "Whenever ~ attacks, reveal the top card of your library and put it into your hand. You lose life equal to that card's mana value if ~ isn't saddled. Otherwise, each opponent loses that much life." }, "node": { - "PreLoweredTrigger": { - "mode": "Attacks", - "execute": { - "kind": "Spell", - "effect": { - "type": "RevealTop", - "player": { - "type": "Controller" + "Trigger": { + "Parsed": { + "condition": "Attacks", + "partial_def": { + "mode": "Attacks", + "execute": null, + "valid_card": { + "type": "SelfRef" }, - "count": 1 + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "ChangeZone", - "origin": null, - "destination": "Hand", - "target": { - "type": "ParentTarget" - }, - "owner_library": false, - "enter_transformed": false, - "enter_tapped": false, - "enters_attacking": false - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "LoseLife", - "amount": { - "type": "Ref", - "qty": { - "type": "ObjectManaValue", - "scope": { - "type": "Demonstrative" + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 35, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "reveal the top card of your library" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null } - } + }, + "parsed": { + "effect": { + "type": "Dig", + "player": { + "type": "Controller" + }, + "count": { + "type": "Fixed", + "value": 1 + }, + "destination": null, + "keep_count": null, + "up_to": false, + "filter": { + "type": "Any" + }, + "rest_destination": null, + "reveal": true, + "enter_tapped": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Comma", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "target": { - "type": "Controller" - } - }, - "cost": null, - "sub_ability": null, - "else_ability": { - "kind": "Spell", - "effect": { - "type": "LoseLife", - "amount": { - "type": "Ref", - "qty": { - "type": "ObjectManaValue", - "scope": { - "type": "Demonstrative" - } + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 40, + "end_byte": 61, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "put it into your hand" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null } - } + }, + "parsed": { + "effect": { + "type": "ChangeZone", + "origin": null, + "destination": "Hand", + "target": { + "type": "ParentTarget" + }, + "owner_library": false, + "enter_transformed": false, + "enter_tapped": false, + "enters_attacking": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "player_scope": { - "type": "Opponent" - } - }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": { - "type": "Not", - "condition": { - "type": "SourceMatchesFilter", - "filter": { - "type": "Typed", - "type_filters": [], - "controller": null, - "properties": [ - { - "type": "IsSaddled" + { + "id": 2, + "source": { + "id": { + "item": 0, + "ordinal": 3 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 63, + "end_byte": 127, + "precision": "ChainRelative", + "ordinal_within_span": 2 + }, + "fragment": "You lose life equal to that card's mana value if ~ isn't saddled" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "LoseLife", + "amount": { + "type": "Ref", + "qty": { + "type": "ObjectManaValue", + "scope": { + "type": "Demonstrative" + } + } + }, + "target": { + "type": "Controller" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": { + "type": "Not", + "condition": { + "type": "SourceMatchesFilter", + "filter": { + "type": "Typed", + "type_filters": [], + "controller": null, + "properties": [ + { + "type": "IsSaddled" + } + ] } - ] - } + } + }, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 3, + "source": { + "id": { + "item": 0, + "ordinal": 4 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 129, + "end_byte": 174, + "precision": "ChainRelative", + "ordinal_within_span": 3 + }, + "fragment": "Otherwise, each opponent loses that much life" + }, + "disposition": { + "BranchOtherwise": { + "else_def": { + "kind": "Spell", + "effect": { + "type": "LoseLife", + "amount": { + "type": "Ref", + "qty": { + "type": "EventContextAmount" + } + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false, + "player_scope": { + "type": "Opponent" + } + }, + "kind": "Bound" + } + }, + "parsed": { + "effect": { + "type": "Unimplemented", + "name": "otherwise_placeholder", + "description": null + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } - }, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "sub_link": "SequentialSibling" - }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null + } + }, + "modifiers": { "optional": false, - "forward_result": false + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "SelfRef" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "reveal the top card of your library and put it into your hand. you lose life equal to that card's mana value if ~ isn't saddled. otherwise, each opponent loses that much life.", + "relative_player_scope": null }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": { - "type": "SelfRef" - }, - "origin": null, - "destination": null, - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "Whenever ~ attacks, reveal the top card of your library and put it into your hand. You lose life equal to that card's mana value if ~ isn't saddled. Otherwise, each opponent loses that much life.", - "constraint": null, - "condition": null, - "batched": false + "source_text": "Whenever ~ attacks, reveal the top card of your library and put it into your hand. You lose life equal to that card's mana value if ~ isn't saddled. Otherwise, each opponent loses that much life.", + "die_results": [] + } } } }, diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__chalice_of_the_void_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__chalice_of_the_void_ir.snap index 052fbfdac6..6c26a17b90 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__chalice_of_the_void_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__chalice_of_the_void_ir.snap @@ -1,6 +1,5 @@ --- source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs -assertion_line: 993 expression: "&ir" --- { @@ -84,64 +83,131 @@ expression: "&ir" "fragment": "Whenever a player casts a spell with mana value equal to the number of charge counters on ~, counter that spell." }, "node": { - "PreLoweredTrigger": { - "mode": "SpellCast", - "execute": { - "kind": "Spell", - "effect": { - "type": "Counter", - "target": { - "type": "TriggeringSource" - } + "Trigger": { + "Parsed": { + "condition": "SpellCast", + "partial_def": { + "mode": "SpellCast", + "execute": null, + "valid_card": { + "type": "Typed", + "type_filters": [ + "Card" + ], + "controller": null, + "properties": [ + { + "type": "Cmc", + "comparator": "EQ", + "value": { + "type": "Ref", + "qty": { + "type": "CountersOn", + "scope": { + "type": "Source" + }, + "counter_type": "charge" + } + } + } + ] + }, + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": { - "type": "Typed", - "type_filters": [ - "Card" - ], - "controller": null, - "properties": [ - { - "type": "Cmc", - "comparator": "EQ", - "value": { - "type": "Ref", - "qty": { - "type": "CountersOn", - "scope": { - "type": "Source" + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 18, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "counter that spell" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Counter", + "target": { + "type": "TriggeringSource" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null }, - "counter_type": "charge" + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } - } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null } - ] - }, - "origin": null, - "destination": null, - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "Whenever a player casts a spell with mana value equal to the number of charge counters on ~, counter that spell.", - "constraint": null, - "condition": null, - "batched": false + }, + "modifiers": { + "optional": false, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "Player" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "counter that spell.", + "relative_player_scope": null + }, + "source_text": "Whenever a player casts a spell with mana value equal to the number of charge counters on ~, counter that spell.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__conclave_mentor_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__conclave_mentor_ir.snap index 84149b25f4..dd0a141a59 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__conclave_mentor_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__conclave_mentor_ir.snap @@ -1,6 +1,5 @@ --- source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs -assertion_line: 453 expression: "&ir" --- { @@ -72,50 +71,117 @@ expression: "&ir" "fragment": "When ~ dies, you gain life equal to its power." }, "node": { - "PreLoweredTrigger": { - "mode": "ChangesZone", - "execute": { - "kind": "Spell", - "effect": { - "type": "GainLife", - "amount": { - "type": "Ref", - "qty": { - "type": "Power", - "scope": { - "type": "Anaphoric" + "Trigger": { + "Parsed": { + "condition": "ChangesZone", + "partial_def": { + "mode": "ChangesZone", + "execute": null, + "valid_card": { + "type": "SelfRef" + }, + "origin": "Battlefield", + "destination": "Graveyard", + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 32, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "you gain life equal to its power" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "GainLife", + "amount": { + "type": "Ref", + "qty": { + "type": "Power", + "scope": { + "type": "Anaphoric" + } + } + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } - } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null } }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": { - "type": "SelfRef" - }, - "origin": "Battlefield", - "destination": "Graveyard", - "trigger_zones": [ - "Graveyard" - ], - "phase": null, - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "When ~ dies, you gain life equal to its power.", - "constraint": null, - "condition": null, - "batched": false + "modifiers": { + "optional": false, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "SelfRef" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "you gain life equal to its power.", + "relative_player_scope": null + }, + "source_text": "When ~ dies, you gain life equal to its power.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__dark_confidant_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__dark_confidant_ir.snap index 6d28ce4003..a34a512c85 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__dark_confidant_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__dark_confidant_ir.snap @@ -22,95 +22,241 @@ expression: "&ir" "fragment": "At the beginning of your upkeep, reveal the top card of your library and put that card into your hand. You lose life equal to its mana value." }, "node": { - "PreLoweredTrigger": { - "mode": "Phase", - "execute": { - "kind": "Spell", - "effect": { - "type": "RevealTop", - "player": { - "type": "Controller" + "Trigger": { + "Parsed": { + "condition": "Phase", + "partial_def": { + "mode": "Phase", + "execute": null, + "valid_card": null, + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": "Upkeep", + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": { + "type": "OnlyDuringYourTurn" }, - "count": 1 + "condition": null, + "batched": false }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "ChangeZone", - "origin": null, - "destination": "Hand", - "target": { - "type": "ParentTarget" - }, - "owner_library": false, - "enter_transformed": false, - "enter_tapped": false, - "enters_attacking": false - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "LoseLife", - "amount": { - "type": "Ref", - "qty": { - "type": "ObjectManaValue", - "scope": { - "type": "Anaphoric" + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 35, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "reveal the top card of your library" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Dig", + "player": { + "type": "Controller" + }, + "count": { + "type": "Fixed", + "value": 1 + }, + "destination": null, + "keep_count": null, + "up_to": false, + "filter": { + "type": "Any" + }, + "rest_destination": null, + "reveal": true, + "enter_tapped": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Comma", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 40, + "end_byte": 68, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "put that card into your hand" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null } - } + }, + "parsed": { + "effect": { + "type": "ChangeZone", + "origin": null, + "destination": "Hand", + "target": { + "type": "ParentTarget" + }, + "owner_library": false, + "enter_transformed": false, + "enter_tapped": false, + "enters_attacking": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "target": { - "type": "Controller" + { + "id": 2, + "source": { + "id": { + "item": 0, + "ordinal": 3 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 70, + "end_byte": 107, + "precision": "ChainRelative", + "ordinal_within_span": 2 + }, + "fragment": "You lose life equal to its mana value" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "LoseLife", + "amount": { + "type": "Ref", + "qty": { + "type": "ObjectManaValue", + "scope": { + "type": "Anaphoric" + } + } + }, + "target": { + "type": "Controller" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } - }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "sub_link": "SequentialSibling" - }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null + } + }, + "modifiers": { "optional": false, - "forward_result": false + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "Any" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "reveal the top card of your library and put that card into your hand. you lose life equal to its mana value.", + "relative_player_scope": null }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": null, - "origin": null, - "destination": null, - "trigger_zones": [ - "Battlefield" - ], - "phase": "Upkeep", - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "At the beginning of your upkeep, reveal the top card of your library and put that card into your hand. You lose life equal to its mana value.", - "constraint": { - "type": "OnlyDuringYourTurn" - }, - "condition": null, - "batched": false + "source_text": "At the beginning of your upkeep, reveal the top card of your library and put that card into your hand. You lose life equal to its mana value.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__edgewall_innkeeper_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__edgewall_innkeeper_ir.snap index 5ba27e5f70..356524dc60 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__edgewall_innkeeper_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__edgewall_innkeeper_ir.snap @@ -22,55 +22,122 @@ expression: "&ir" "fragment": "Whenever you cast a creature spell that has an Adventure, draw a card. (It doesn't need to have gone on the adventure first.)" }, "node": { - "PreLoweredTrigger": { - "mode": "SpellCast", - "execute": { - "kind": "Spell", - "effect": { - "type": "Draw", - "count": { - "type": "Fixed", - "value": 1 + "Trigger": { + "Parsed": { + "condition": "SpellCast", + "partial_def": { + "mode": "SpellCast", + "execute": null, + "valid_card": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [] }, - "target": { + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": { "type": "Controller" + }, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 11, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "draw a card" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Draw", + "count": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "Controller" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null } }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": null, - "properties": [] - }, - "origin": null, - "destination": null, - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": { - "type": "Controller" - }, - "valid_source": null, - "description": "Whenever you cast a creature spell that has an Adventure, draw a card.", - "constraint": null, - "condition": null, - "batched": false + "modifiers": { + "optional": false, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "Controller" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "draw a card.", + "relative_player_scope": null + }, + "source_text": "Whenever you cast a creature spell that has an Adventure, draw a card.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__eidolon_of_the_great_revel_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__eidolon_of_the_great_revel_ir.snap index e87b0b5b84..646471ba35 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__eidolon_of_the_great_revel_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__eidolon_of_the_great_revel_ir.snap @@ -22,62 +22,129 @@ expression: "&ir" "fragment": "Whenever a player casts a spell with mana value 3 or less, ~ deals 2 damage to that player." }, "node": { - "PreLoweredTrigger": { - "mode": "SpellCast", - "execute": { - "kind": "Spell", - "effect": { - "type": "DealDamage", - "amount": { - "type": "Fixed", - "value": 2 + "Trigger": { + "Parsed": { + "condition": "SpellCast", + "partial_def": { + "mode": "SpellCast", + "execute": null, + "valid_card": { + "type": "Typed", + "type_filters": [ + "Card" + ], + "controller": null, + "properties": [ + { + "type": "Cmc", + "comparator": "LE", + "value": { + "type": "Fixed", + "value": 3 + } + } + ] }, - "target": { - "type": "TriggeringPlayer" - } + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": { - "type": "Typed", - "type_filters": [ - "Card" - ], - "controller": null, - "properties": [ - { - "type": "Cmc", - "comparator": "LE", - "value": { - "type": "Fixed", - "value": 3 - } + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 31, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "~ deals 2 damage to that player" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "DealDamage", + "amount": { + "type": "Fixed", + "value": 2 + }, + "target": { + "type": "TriggeringPlayer" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null } - ] - }, - "origin": null, - "destination": null, - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "Whenever a player casts a spell with mana value 3 or less, ~ deals 2 damage to that player.", - "constraint": null, - "condition": null, - "batched": false + }, + "modifiers": { + "optional": false, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "Player" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "~ deals 2 damage to that player.", + "relative_player_scope": null + }, + "source_text": "Whenever a player casts a spell with mana value 3 or less, ~ deals 2 damage to that player.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__fevered_visions_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__fevered_visions_ir.snap index c6b757313b..e7c1e3f164 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__fevered_visions_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__fevered_visions_ir.snap @@ -22,95 +22,195 @@ expression: "&ir" "fragment": "At the beginning of each player's end step, that player draws a card. If the player is your opponent and has four or more cards in hand, ~ deals 2 damage to that player." }, "node": { - "PreLoweredTrigger": { - "mode": "Phase", - "execute": { - "kind": "Spell", - "effect": { - "type": "Draw", - "count": { - "type": "Fixed", - "value": 1 - }, - "target": { - "type": "ScopedPlayer" - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "DealDamage", - "amount": { - "type": "Fixed", - "value": 2 - }, - "target": { - "type": "ScopedPlayer" - } - }, - "cost": null, - "sub_ability": null, - "duration": null, + "Trigger": { + "Parsed": { + "condition": "Phase", + "partial_def": { + "mode": "Phase", + "execute": null, + "valid_card": null, + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": "End", + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, "description": null, - "target_prompt": null, - "condition": { - "type": "And", - "conditions": [ + "constraint": null, + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ { - "type": "ScopedPlayerMatches", - "filter": { - "type": "Opponent" - } + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 24, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "that player draws a card" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Draw", + "count": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "ScopedPlayer" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, { - "type": "QuantityCheck", - "lhs": { - "type": "Ref", - "qty": { - "type": "HandSize", - "player": { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 26, + "end_byte": 124, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "If the player is your opponent and has four or more cards in hand, ~ deals 2 damage to that player" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "DealDamage", + "amount": { + "type": "Fixed", + "value": 2 + }, + "target": { "type": "ScopedPlayer" } - } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null }, - "comparator": "GE", - "rhs": { - "type": "Fixed", - "value": 4 - } + "boundary": null, + "condition": { + "type": "And", + "conditions": [ + { + "type": "ScopedPlayerMatches", + "filter": { + "type": "Opponent" + } + }, + { + "type": "QuantityCheck", + "lhs": { + "type": "Ref", + "qty": { + "type": "HandSize", + "player": { + "type": "ScopedPlayer" + } + } + }, + "comparator": "GE", + "rhs": { + "type": "Fixed", + "value": 4 + } + } + ] + }, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } - ] - }, - "optional_targeting": false, + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null + } + }, + "modifiers": { "optional": false, - "forward_result": false, - "sub_link": "SequentialSibling" + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "Any" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "that player draws a card. if the player is your opponent and has four or more cards in hand, ~ deals 2 damage to that player.", + "relative_player_scope": "ScopedPlayer" }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": null, - "origin": null, - "destination": null, - "trigger_zones": [ - "Battlefield" - ], - "phase": "End", - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "At the beginning of each player's end step, that player draws a card. If the player is your opponent and has four or more cards in hand, ~ deals 2 damage to that player.", - "constraint": null, - "condition": null, - "batched": false + "source_text": "At the beginning of each player's end step, that player draws a card. If the player is your opponent and has four or more cards in hand, ~ deals 2 damage to that player.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__goblin_guide_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__goblin_guide_ir.snap index 9b18d0f9d2..707aa83ea1 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__goblin_guide_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__goblin_guide_ir.snap @@ -50,74 +50,174 @@ expression: "&ir" "fragment": "Whenever ~ attacks, defending player reveals the top card of their library. If it's a land card, that player puts it into their hand." }, "node": { - "PreLoweredTrigger": { - "mode": "Attacks", - "execute": { - "kind": "Spell", - "effect": { - "type": "RevealTop", - "player": { - "type": "DefendingPlayer" + "Trigger": { + "Parsed": { + "condition": "Attacks", + "partial_def": { + "mode": "Attacks", + "execute": null, + "valid_card": { + "type": "SelfRef" }, - "count": 1 - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "ChangeZone", - "origin": null, - "destination": "Hand", - "target": { - "type": "ParentTarget" - }, - "owner_library": false, - "enter_transformed": false, - "enter_tapped": false, - "enters_attacking": false - }, - "cost": null, - "sub_ability": null, - "duration": null, + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, "description": null, - "target_prompt": null, - "condition": { - "type": "RevealedHasCardType", - "card_types": [ - "Land" - ] - }, - "optional_targeting": false, + "constraint": null, + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 54, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "defending player reveals the top card of their library" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "RevealTop", + "player": { + "type": "DefendingPlayer" + }, + "count": 1 + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 56, + "end_byte": 112, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "If it's a land card, that player puts it into their hand" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "ChangeZone", + "origin": null, + "destination": "Hand", + "target": { + "type": "ParentTarget" + }, + "owner_library": false, + "enter_transformed": false, + "enter_tapped": false, + "enters_attacking": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": { + "type": "RevealedHasCardType", + "card_types": [ + "Land" + ] + }, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null + } + }, + "modifiers": { "optional": false, - "forward_result": false, - "sub_link": "SequentialSibling" + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "SelfRef" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "defending player reveals the top card of their library. if it's a land card, that player puts it into their hand.", + "relative_player_scope": null }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": { - "type": "SelfRef" - }, - "origin": null, - "destination": null, - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "Whenever ~ attacks, defending player reveals the top card of their library. If it's a land card, that player puts it into their hand.", - "constraint": null, - "condition": null, - "batched": false + "source_text": "Whenever ~ attacks, defending player reveals the top card of their library. If it's a land card, that player puts it into their hand.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jaws_of_defeat_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jaws_of_defeat_ir.snap index 9864aa7af7..baa5f60c5c 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jaws_of_defeat_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jaws_of_defeat_ir.snap @@ -22,75 +22,145 @@ expression: "&ir" "fragment": "Whenever a creature you control enters, target opponent loses life equal to the difference between that creature's power and its toughness." }, "node": { - "PreLoweredTrigger": { - "mode": "ChangesZone", - "execute": { - "kind": "Spell", - "effect": { - "type": "LoseLife", - "amount": { - "type": "Difference", - "left": { - "type": "Ref", - "qty": { - "type": "Power", - "scope": { - "type": "Demonstrative" - } - } - }, - "right": { - "type": "Ref", - "qty": { - "type": "Toughness", - "scope": { - "type": "Demonstrative" - } - } - } - }, - "target": { + "Trigger": { + "Parsed": { + "condition": "ChangesZone", + "partial_def": { + "mode": "ChangesZone", + "execute": null, + "valid_card": { "type": "Typed", - "type_filters": [], - "controller": "Opponent", + "type_filters": [ + "Creature" + ], + "controller": "You", "properties": [] + }, + "origin": null, + "destination": "Battlefield", + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 98, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "target opponent loses life equal to the difference between that creature's power and its toughness" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "LoseLife", + "amount": { + "type": "Difference", + "left": { + "type": "Ref", + "qty": { + "type": "Power", + "scope": { + "type": "Demonstrative" + } + } + }, + "right": { + "type": "Ref", + "qty": { + "type": "Toughness", + "scope": { + "type": "Demonstrative" + } + } + } + }, + "target": { + "type": "Typed", + "type_filters": [], + "controller": "Opponent", + "properties": [] + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null } }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - }, - "origin": null, - "destination": "Battlefield", - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": { - "type": "Player" - }, - "valid_source": null, - "description": "Whenever a creature you control enters, target opponent loses life equal to the difference between that creature's power and its toughness.", - "constraint": null, - "condition": null, - "batched": false + "modifiers": { + "optional": false, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "target opponent loses life equal to the difference between that creature's power and its toughness.", + "relative_player_scope": null + }, + "source_text": "Whenever a creature you control enters, target opponent loses life equal to the difference between that creature's power and its toughness.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__karn_legacy_reforged_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__karn_legacy_reforged_ir.snap index 40d9244567..b1badd777f 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__karn_legacy_reforged_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__karn_legacy_reforged_ir.snap @@ -98,68 +98,237 @@ expression: "&ir" "fragment": "At the beginning of your upkeep, add {C} for each artifact you control. This mana can't be spent to cast nonartifact spells. Until end of turn, you don't lose this mana as steps and phases end." }, "node": { - "PreLoweredTrigger": { - "mode": "Phase", - "execute": { - "kind": "Spell", - "effect": { - "type": "Mana", - "produced": { - "type": "Colorless", - "count": { - "type": "Ref", - "qty": { - "type": "ObjectCount", - "filter": { - "type": "Typed", - "type_filters": [ - "Artifact" - ], - "controller": "You", - "properties": [] - } - } - } + "Trigger": { + "Parsed": { + "condition": "Phase", + "partial_def": { + "mode": "Phase", + "execute": null, + "valid_card": null, + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": "Upkeep", + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": { + "type": "OnlyDuringYourTurn" }, - "restrictions": [ - { - "SpellTypeOrAbilityActivation": { - "spell_type": "Artifact", - "ability": "Any" + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 37, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "add {C} for each artifact you control" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Mana", + "produced": { + "type": "Colorless", + "count": { + "type": "Ref", + "qty": { + "type": "ObjectCount", + "filter": { + "type": "Typed", + "type_filters": [ + "Artifact" + ], + "controller": "You", + "properties": [] + } + } + } + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 39, + "end_byte": 90, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "This mana can't be spent to cast nonartifact spells" + }, + "disposition": { + "Continue": { + "continuation": { + "ManaRestriction": { + "restrictions": [ + { + "SpellTypeOrAbilityActivation": { + "spell_type": "Artifact", + "ability": "Any" + } + } + ], + "grants": [] + } + } + } + }, + "parsed": { + "effect": { + "type": "Unimplemented", + "name": "can't", + "description": "can't be spent to cast nonartifact spells" + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 2, + "source": { + "id": { + "item": 0, + "ordinal": 3 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 92, + "end_byte": 159, + "precision": "ChainRelative", + "ordinal_within_span": 2 + }, + "fragment": "Until end of turn, you don't lose this mana as steps and phases end" + }, + "disposition": { + "ModifyPrior": { + "modifier": { + "ManaRetention": "EndOfTurn" + } + } + }, + "parsed": { + "effect": { + "type": "Unimplemented", + "name": "mana_retention_placeholder", + "description": null + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } - } - ], - "expiry": "EndOfTurn" + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null + } }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "is_mana_ability": true - }, - "valid_card": null, - "origin": null, - "destination": null, - "trigger_zones": [ - "Battlefield" - ], - "phase": "Upkeep", - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "At the beginning of your upkeep, add {C} for each artifact you control. This mana can't be spent to cast nonartifact spells. Until end of turn, you don't lose this mana as steps and phases end.", - "constraint": { - "type": "OnlyDuringYourTurn" - }, - "condition": null, - "batched": false + "modifiers": { + "optional": false, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "Any" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "add {c} for each artifact you control. this mana can't be spent to cast nonartifact spells. until end of turn, you don't lose this mana as steps and phases end.", + "relative_player_scope": null + }, + "source_text": "At the beginning of your upkeep, add {C} for each artifact you control. This mana can't be spent to cast nonartifact spells. Until end of turn, you don't lose this mana as steps and phases end.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__kathril_aspect_warper_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__kathril_aspect_warper_ir.snap index a10829e3c9..0a374d950d 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__kathril_aspect_warper_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__kathril_aspect_warper_ir.snap @@ -22,108 +22,61 @@ expression: "&ir" "fragment": "When ~ enters, put a flying counter on any creature you control if a creature card in your graveyard has flying. Repeat this process for first strike, double strike, deathtouch, hexproof, indestructible, lifelink, menace, reach, trample, and vigilance. Then put a +1/+1 counter on ~ for each counter put on a creature this way." }, "node": { - "PreLoweredTrigger": { - "mode": "ChangesZone", - "execute": { - "kind": "Spell", - "effect": { - "type": "PutCounter", - "counter_type": "flying", - "count": { - "type": "Fixed", - "value": 1 + "Trigger": { + "Parsed": { + "condition": "ChangesZone", + "partial_def": { + "mode": "ChangesZone", + "execute": null, + "valid_card": { + "type": "SelfRef" }, - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - } + "origin": null, + "destination": "Battlefield", + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "PutCounter", - "counter_type": "first strike", - "count": { - "type": "Fixed", - "value": 1 - }, - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "PutCounter", - "counter_type": "double strike", - "count": { - "type": "Fixed", - "value": 1 - }, - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "PutCounter", - "counter_type": "deathtouch", - "count": { - "type": "Fixed", - "value": 1 - }, - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "PutCounter", - "counter_type": "hexproof", - "count": { - "type": "Fixed", - "value": 1 + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 }, - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 96, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "put a flying counter on any creature you control if a creature card in your graveyard has flying" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null } }, - "cost": null, - "sub_ability": { - "kind": "Spell", + "parsed": { "effect": { "type": "PutCounter", - "counter_type": "indestructible", + "counter_type": "flying", "count": { "type": "Fixed", "value": 1 @@ -137,377 +90,15 @@ expression: "&ir" "properties": [] } }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "PutCounter", - "counter_type": "lifelink", - "count": { - "type": "Fixed", - "value": 1 - }, - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "PutCounter", - "counter_type": "menace", - "count": { - "type": "Fixed", - "value": 1 - }, - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "PutCounter", - "counter_type": "reach", - "count": { - "type": "Fixed", - "value": 1 - }, - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "PutCounter", - "counter_type": "trample", - "count": { - "type": "Fixed", - "value": 1 - }, - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "PutCounter", - "counter_type": "vigilance", - "count": { - "type": "Fixed", - "value": 1 - }, - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "PutCounter", - "counter_type": "P1P1", - "count": { - "type": "Ref", - "qty": { - "type": "TrackedSetSize" - } - }, - "target": { - "type": "SelfRef" - } - }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "sub_link": "SequentialSibling" - }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": { - "type": "QuantityCheck", - "lhs": { - "type": "Ref", - "qty": { - "type": "ObjectCount", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "InZone", - "zone": "Graveyard" - }, - { - "type": "WithKeyword", - "value": "Vigilance" - } - ] - } - } - }, - "comparator": "GE", - "rhs": { - "type": "Fixed", - "value": 1 - } - }, - "optional_targeting": false, - "optional": false, - "target_choice_timing": "Resolution", - "forward_result": false, - "sub_link": "SequentialSibling", - "sibling_condition": "ReplicatedOrBranch" - }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": { - "type": "QuantityCheck", - "lhs": { - "type": "Ref", - "qty": { - "type": "ObjectCount", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "InZone", - "zone": "Graveyard" - }, - { - "type": "WithKeyword", - "value": "Trample" - } - ] - } - } - }, - "comparator": "GE", - "rhs": { - "type": "Fixed", - "value": 1 - } - }, - "optional_targeting": false, - "optional": false, - "target_choice_timing": "Resolution", - "forward_result": false, - "sub_link": "SequentialSibling", - "sibling_condition": "ReplicatedOrBranch" - }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": { - "type": "QuantityCheck", - "lhs": { - "type": "Ref", - "qty": { - "type": "ObjectCount", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "InZone", - "zone": "Graveyard" - }, - { - "type": "WithKeyword", - "value": "Reach" - } - ] - } - } - }, - "comparator": "GE", - "rhs": { - "type": "Fixed", - "value": 1 - } - }, - "optional_targeting": false, - "optional": false, - "target_choice_timing": "Resolution", - "forward_result": false, - "sub_link": "SequentialSibling", - "sibling_condition": "ReplicatedOrBranch" - }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": { - "type": "QuantityCheck", - "lhs": { - "type": "Ref", - "qty": { - "type": "ObjectCount", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "InZone", - "zone": "Graveyard" - }, - { - "type": "WithKeyword", - "value": "Menace" - } - ] - } - } - }, - "comparator": "GE", - "rhs": { - "type": "Fixed", - "value": 1 - } - }, - "optional_targeting": false, - "optional": false, - "target_choice_timing": "Resolution", - "forward_result": false, - "sub_link": "SequentialSibling", - "sibling_condition": "ReplicatedOrBranch" - }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": { - "type": "QuantityCheck", - "lhs": { - "type": "Ref", - "qty": { - "type": "ObjectCount", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "InZone", - "zone": "Graveyard" - }, - { - "type": "WithKeyword", - "value": "Lifelink" - } - ] - } - } - }, - "comparator": "GE", - "rhs": { - "type": "Fixed", - "value": 1 - } - }, - "optional_targeting": false, - "optional": false, - "target_choice_timing": "Resolution", - "forward_result": false, - "sub_link": "SequentialSibling", - "sibling_condition": "ReplicatedOrBranch" - }, "duration": null, - "description": null, - "target_prompt": null, - "condition": { - "type": "QuantityCheck", - "lhs": { - "type": "Ref", - "qty": { - "type": "ObjectCount", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "InZone", - "zone": "Graveyard" - }, - { - "type": "WithKeyword", - "value": "Indestructible" - } - ] - } - } - }, - "comparator": "GE", - "rhs": { - "type": "Fixed", - "value": 1 - } - }, - "optional_targeting": false, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, "optional": false, - "target_choice_timing": "Resolution", - "forward_result": false, - "sub_link": "SequentialSibling", - "sibling_condition": "ReplicatedOrBranch" + "unless_pay": null }, - "duration": null, - "description": null, - "target_prompt": null, + "boundary": "Sentence", "condition": { "type": "QuantityCheck", "lhs": { @@ -527,7 +118,7 @@ expression: "&ir" }, { "type": "WithKeyword", - "value": "Hexproof" + "value": "Flying" } ] } @@ -539,193 +130,162 @@ expression: "&ir" "value": 1 } }, - "optional_targeting": false, - "optional": false, - "target_choice_timing": "Resolution", - "forward_result": false, - "sub_link": "SequentialSibling", - "sibling_condition": "ReplicatedOrBranch" + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": { - "type": "QuantityCheck", - "lhs": { - "type": "Ref", - "qty": { - "type": "ObjectCount", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "InZone", - "zone": "Graveyard" - }, - { - "type": "WithKeyword", - "value": "Deathtouch" - } - ] - } - } + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 98, + "end_byte": 236, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "Repeat this process for first strike, double strike, deathtouch, hexproof, indestructible, lifelink, menace, reach, trample, and vigilance" }, - "comparator": "GE", - "rhs": { - "type": "Fixed", - "value": 1 - } - }, - "optional_targeting": false, - "optional": false, - "target_choice_timing": "Resolution", - "forward_result": false, - "sub_link": "SequentialSibling", - "sibling_condition": "ReplicatedOrBranch" - }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": { - "type": "QuantityCheck", - "lhs": { - "type": "Ref", - "qty": { - "type": "ObjectCount", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" + "disposition": { + "ReplicatePerKeyword": { + "keywords": [ + "FirstStrike", + "DoubleStrike", + "Deathtouch", + "Hexproof", + "Indestructible", + "Lifelink", + "Menace", + "Reach", + "Trample", + "Vigilance" ], - "controller": "You", - "properties": [ - { - "type": "InZone", - "zone": "Graveyard" - }, - { - "type": "WithKeyword", - "value": "DoubleStrike" - } - ] + "kind": "CounterPlacement" } - } + }, + "parsed": { + "effect": { + "type": "Unimplemented", + "name": "repeat_process_for_keywords_placeholder", + "description": null + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "comparator": "GE", - "rhs": { - "type": "Fixed", - "value": 1 - } - }, - "optional_targeting": false, - "optional": false, - "target_choice_timing": "Resolution", - "forward_result": false, - "sub_link": "SequentialSibling", - "sibling_condition": "ReplicatedOrBranch" - }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": { - "type": "QuantityCheck", - "lhs": { - "type": "Ref", - "qty": { - "type": "ObjectCount", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "InZone", - "zone": "Graveyard" + { + "id": 2, + "source": { + "id": { + "item": 0, + "ordinal": 3 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 243, + "end_byte": 311, + "precision": "ChainRelative", + "ordinal_within_span": 2 + }, + "fragment": "put a +1/+1 counter on ~ for each counter put on a creature this way" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "PutCounter", + "counter_type": "P1P1", + "count": { + "type": "Ref", + "qty": { + "type": "TrackedSetSize" + } }, - { - "type": "WithKeyword", - "value": "FirstStrike" + "target": { + "type": "SelfRef" } - ] - } - } - }, - "comparator": "GE", - "rhs": { - "type": "Fixed", - "value": 1 - } - }, - "optional_targeting": false, - "optional": false, - "target_choice_timing": "Resolution", - "forward_result": false, - "sub_link": "SequentialSibling", - "sibling_condition": "ReplicatedOrBranch" - }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": { - "type": "QuantityCheck", - "lhs": { - "type": "Ref", - "qty": { - "type": "ObjectCount", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "InZone", - "zone": "Graveyard" }, - { - "type": "WithKeyword", - "value": "Flying" - } - ] + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } - } - }, - "comparator": "GE", - "rhs": { - "type": "Fixed", - "value": 1 + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null } }, - "optional_targeting": false, - "optional": false, - "target_choice_timing": "Resolution", - "forward_result": false - }, - "valid_card": { - "type": "SelfRef" - }, - "origin": null, - "destination": "Battlefield", - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "When ~ enters, put a flying counter on any creature you control if a creature card in your graveyard has flying. Repeat this process for first strike, double strike, deathtouch, hexproof, indestructible, lifelink, menace, reach, trample, and vigilance. Then put a +1/+1 counter on ~ for each counter put on a creature this way.", - "constraint": null, - "condition": null, - "batched": false + "modifiers": { + "optional": false, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "SelfRef" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "put a flying counter on any creature you control if a creature card in your graveyard has flying. repeat this process for first strike, double strike, deathtouch, hexproof, indestructible, lifelink, menace, reach, trample, and vigilance. then put a +1/+1 counter on ~ for each counter put on a creature this way.", + "relative_player_scope": null + }, + "source_text": "When ~ enters, put a flying counter on any creature you control if a creature card in your graveyard has flying. Repeat this process for first strike, double strike, deathtouch, hexproof, indestructible, lifelink, menace, reach, trample, and vigilance. Then put a +1/+1 counter on ~ for each counter put on a creature this way.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__kroxa_titan_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__kroxa_titan_ir.snap index 3019cc11fc..651f21a37f 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__kroxa_titan_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__kroxa_titan_ir.snap @@ -22,54 +22,121 @@ expression: "&ir" "fragment": "When ~ enters, sacrifice it unless it escaped." }, "node": { - "PreLoweredTrigger": { - "mode": "ChangesZone", - "execute": { - "kind": "Spell", - "effect": { - "type": "Sacrifice", - "target": { + "Trigger": { + "Parsed": { + "condition": "ChangesZone", + "partial_def": { + "mode": "ChangesZone", + "execute": null, + "valid_card": { "type": "SelfRef" }, - "count": { - "type": "Fixed", - "value": 1 + "origin": null, + "destination": "Battlefield", + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 12, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "sacrifice it" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Sacrifice", + "target": { + "type": "SelfRef" + }, + "count": { + "type": "Fixed", + "value": 1 + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null } }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": { - "type": "SelfRef" - }, - "origin": null, - "destination": "Battlefield", - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "When ~ enters, sacrifice it unless it escaped.", - "constraint": null, - "condition": { - "type": "Not", - "condition": { - "type": "CastVariantPaid", - "variant": "Escape" - } - }, - "batched": false + "modifiers": { + "optional": false, + "unless_pay": null, + "intervening_if": { + "type": "Not", + "condition": { + "type": "CastVariantPaid", + "variant": "Escape" + } + }, + "trigger_subject": { + "type": "SelfRef" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "sacrifice it unless it escaped.", + "relative_player_scope": null + }, + "source_text": "When ~ enters, sacrifice it unless it escaped.", + "die_results": [] + } } } }, @@ -91,99 +158,199 @@ expression: "&ir" "fragment": "Whenever ~ enters or attacks, each opponent discards a card, then each opponent who didn't discard a nonland card this way loses 3 life." }, "node": { - "PreLoweredTrigger": { - "mode": "EntersOrAttacks", - "execute": { - "kind": "Spell", - "effect": { - "type": "Discard", - "count": { - "type": "Fixed", - "value": 1 - }, - "target": { - "type": "Controller" - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "LoseLife", - "amount": { - "type": "Fixed", - "value": 3 - }, - "target": { - "type": "ScopedPlayer" - } + "Trigger": { + "Parsed": { + "condition": "EntersOrAttacks", + "partial_def": { + "mode": "EntersOrAttacks", + "execute": null, + "valid_card": { + "type": "SelfRef" }, - "cost": null, - "sub_ability": null, - "duration": null, + "origin": null, + "destination": "Battlefield", + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, "description": null, - "target_prompt": null, - "condition": { - "type": "And", - "conditions": [ + "constraint": null, + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ { - "type": "Not", - "condition": { - "type": "ZoneChangedThisWay", - "filter": { - "type": "Typed", - "type_filters": [ - "Card", - { - "Non": "Land" - } - ], - "controller": null, - "properties": [] + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 29, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "each opponent discards a card" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null } - } + }, + "parsed": { + "effect": { + "type": "Discard", + "count": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "Controller" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Then", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": { + "type": "Opponent" + }, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, { - "type": "ScopedPlayerMatches", - "filter": { - "type": "Opponent" - } + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 36, + "end_byte": 105, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "each opponent who didn't discard a nonland card this way loses 3 life" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "LoseLife", + "amount": { + "type": "Fixed", + "value": 3 + }, + "target": { + "type": "ScopedPlayer" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": { + "type": "And", + "conditions": [ + { + "type": "Not", + "condition": { + "type": "ZoneChangedThisWay", + "filter": { + "type": "Typed", + "type_filters": [ + "Card", + { + "Non": "Land" + } + ], + "controller": null, + "properties": [] + } + } + }, + { + "type": "ScopedPlayerMatches", + "filter": { + "type": "Opponent" + } + } + ] + }, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } - ] - }, - "optional_targeting": false, + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null + } + }, + "modifiers": { "optional": false, - "forward_result": false + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "SelfRef" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "each opponent discards a card, then each opponent who didn't discard a nonland card this way loses 3 life.", + "relative_player_scope": null }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "player_scope": { - "type": "Opponent" - } - }, - "valid_card": { - "type": "SelfRef" - }, - "origin": null, - "destination": "Battlefield", - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "Whenever ~ enters or attacks, each opponent discards a card, then each opponent who didn't discard a nonland card this way loses 3 life.", - "constraint": null, - "condition": null, - "batched": false + "source_text": "Whenever ~ enters or attacks, each opponent discards a card, then each opponent who didn't discard a nonland card this way loses 3 life.", + "die_results": [] + } } } }, diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__liliana_the_repentant_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__liliana_the_repentant_ir.snap index 3f3d6f5e2c..1728343bb2 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__liliana_the_repentant_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__liliana_the_repentant_ir.snap @@ -22,75 +22,168 @@ expression: "&ir" "fragment": "Whenever another creature or planeswalker you control enters, mill two cards." }, "node": { - "PreLoweredTrigger": { - "mode": "ChangesZone", - "execute": { - "kind": "Spell", - "effect": { - "type": "Mill", - "count": { - "type": "Fixed", - "value": 2 - }, - "target": { - "type": "Controller" - }, - "destination": "Graveyard" - }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": { - "type": "Or", - "filters": [ - { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ + "Trigger": { + "Parsed": { + "condition": "ChangesZone", + "partial_def": { + "mode": "ChangesZone", + "execute": null, + "valid_card": { + "type": "Or", + "filters": [ { - "type": "Another" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [ + { + "type": "Another" + } + ] + }, + { + "type": "Typed", + "type_filters": [ + "Planeswalker" + ], + "controller": "You", + "properties": [ + { + "type": "Another" + } + ] } ] }, - { - "type": "Typed", - "type_filters": [ - "Planeswalker" + "origin": null, + "destination": "Battlefield", + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 14, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "mill two cards" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Mill", + "count": { + "type": "Fixed", + "value": 2 + }, + "target": { + "type": "Controller" + }, + "destination": "Graveyard" + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } ], - "controller": "You", - "properties": [ + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null + } + }, + "modifiers": { + "optional": false, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "Or", + "filters": [ { - "type": "Another" + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [ + { + "type": "Another" + } + ] + }, + { + "type": "Typed", + "type_filters": [ + "Planeswalker" + ], + "controller": "You", + "properties": [ + { + "type": "Another" + } + ] } ] - } - ] - }, - "origin": null, - "destination": "Battlefield", - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "Whenever another creature or planeswalker you control enters, mill two cards.", - "constraint": null, - "condition": null, - "batched": false + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "mill two cards.", + "relative_player_scope": null + }, + "source_text": "Whenever another creature or planeswalker you control enters, mill two cards.", + "die_results": [] + } } } }, diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__luminarch_aspirant_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__luminarch_aspirant_ir.snap index a78b425c33..ba86aca22e 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__luminarch_aspirant_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__luminarch_aspirant_ir.snap @@ -22,54 +22,121 @@ expression: "&ir" "fragment": "At the beginning of combat on your turn, put a +1/+1 counter on target creature you control." }, "node": { - "PreLoweredTrigger": { - "mode": "Phase", - "execute": { - "kind": "Spell", - "effect": { - "type": "PutCounter", - "counter_type": "P1P1", - "count": { - "type": "Fixed", - "value": 1 + "Trigger": { + "Parsed": { + "condition": "Phase", + "partial_def": { + "mode": "Phase", + "execute": null, + "valid_card": null, + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": "BeginCombat", + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": { + "type": "OnlyDuringYourTurn" }, - "target": { - "type": "Typed", - "type_filters": [ - "Creature" + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 50, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "put a +1/+1 counter on target creature you control" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "PutCounter", + "counter_type": "P1P1", + "count": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } ], - "controller": "You", - "properties": [] + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null } }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": null, - "origin": null, - "destination": null, - "trigger_zones": [ - "Battlefield" - ], - "phase": "BeginCombat", - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "At the beginning of combat on your turn, put a +1/+1 counter on target creature you control.", - "constraint": { - "type": "OnlyDuringYourTurn" - }, - "condition": null, - "batched": false + "modifiers": { + "optional": false, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "Any" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "put a +1/+1 counter on target creature you control.", + "relative_player_scope": null + }, + "source_text": "At the beginning of combat on your turn, put a +1/+1 counter on target creature you control.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__mishra_eminent_one_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__mishra_eminent_one_ir.snap index 196f061236..a2091ea838 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__mishra_eminent_one_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__mishra_eminent_one_ir.snap @@ -22,168 +22,282 @@ expression: "&ir" "fragment": "At the beginning of combat on your turn, create a token that's a copy of target noncreature artifact you control, except its name is ~'s Warform and it's a 4/4 Construct artifact creature in addition to its other types. It gains haste until end of turn. Sacrifice it at the beginning of the next end step." }, "node": { - "PreLoweredTrigger": { - "mode": "Phase", - "execute": { - "kind": "Spell", - "effect": { - "type": "CopyTokenOf", - "target": { - "type": "Typed", - "type_filters": [ - "Artifact", - { - "Non": "Creature" - } - ], - "controller": "You", - "properties": [] - }, - "owner": { - "type": "Controller" - }, - "enters_attacking": false, - "tapped": false, - "count": { - "type": "Fixed", - "value": 1 + "Trigger": { + "Parsed": { + "condition": "Phase", + "partial_def": { + "mode": "Phase", + "execute": null, + "valid_card": null, + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": "BeginCombat", + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": { + "type": "OnlyDuringYourTurn" }, - "additional_modifications": [ - { - "type": "SetPower", - "value": 4 - }, - { - "type": "SetToughness", - "value": 4 - }, - { - "type": "AddSubtype", - "subtype": "Construct" - }, - { - "type": "AddType", - "core_type": "Artifact" - }, - { - "type": "AddType", - "core_type": "Creature" - }, - { - "type": "SetName", - "name": "Mishra's Warform" - } - ] + "condition": null, + "batched": false }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "GenericEffect", - "static_abilities": [ + "body": { + "EffectChain": { + "clauses": [ { - "mode": "Continuous", - "affected": { - "type": "LastCreated" + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 177, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "create a token that's a copy of target noncreature artifact you control, except its name is ~'s Warform and it's a 4/4 Construct artifact creature in addition to its other types" }, - "modifications": [ - { - "type": "AddKeyword", - "keyword": "Haste" + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null } - ], + }, + "parsed": { + "effect": { + "type": "CopyTokenOf", + "target": { + "type": "Typed", + "type_filters": [ + "Artifact", + { + "Non": "Creature" + } + ], + "controller": "You", + "properties": [] + }, + "owner": { + "type": "Controller" + }, + "enters_attacking": false, + "tapped": false, + "count": { + "type": "Fixed", + "value": 1 + }, + "additional_modifications": [ + { + "type": "SetPower", + "value": 4 + }, + { + "type": "SetToughness", + "value": 4 + }, + { + "type": "AddSubtype", + "subtype": "Construct" + }, + { + "type": "AddType", + "core_type": "Artifact" + }, + { + "type": "AddType", + "core_type": "Creature" + }, + { + "type": "SetName", + "name": "Mishra's Warform" + } + ] + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", "condition": null, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "gain haste" - } - ], - "duration": "UntilEndOfTurn", - "target": { - "type": "LastCreated" - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "CreateDelayedTrigger", - "condition": { - "type": "AtNextPhase", - "phase": "End" + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "effect": { - "kind": "Spell", - "effect": { - "type": "Sacrifice", - "target": { - "type": "LastCreated" + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 }, - "count": { - "type": "Fixed", - "value": 1 + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 179, + "end_byte": 211, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "It gains haste until end of turn" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null } }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, + "parsed": { + "effect": { + "type": "GenericEffect", + "static_abilities": [ + { + "mode": "Continuous", + "affected": { + "type": "SelfRef" + }, + "modifications": [ + { + "type": "AddKeyword", + "keyword": "Haste" + } + ], + "condition": null, + "affected_zone": null, + "effect_zone": null, + "active_zones": [], + "characteristic_defining": false, + "description": "gain haste" + } + ], + "duration": "UntilEndOfTurn", + "target": null + }, + "duration": "UntilEndOfTurn", + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "uses_tracked_set": false - }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "sub_link": "SequentialSibling" - }, - "duration": "UntilEndOfTurn", - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, + { + "id": 2, + "source": { + "id": { + "item": 0, + "ordinal": 3 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 213, + "end_byte": 263, + "precision": "ChainRelative", + "ordinal_within_span": 2 + }, + "fragment": "Sacrifice it at the beginning of the next end step" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Sacrifice", + "target": { + "type": "SelfRef" + }, + "count": { + "type": "Fixed", + "value": 1 + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": { + "type": "AtNextPhase", + "phase": "End" + }, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null + } + }, + "modifiers": { "optional": false, - "forward_result": false, - "sub_link": "SequentialSibling" + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "Any" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "create a token that's a copy of target noncreature artifact you control, except its name is ~'s warform and it's a 4/4 construct artifact creature in addition to its other types. it gains haste until end of turn. sacrifice it at the beginning of the next end step.", + "relative_player_scope": null }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": null, - "origin": null, - "destination": null, - "trigger_zones": [ - "Battlefield" - ], - "phase": "BeginCombat", - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "At the beginning of combat on your turn, create a token that's a copy of target noncreature artifact you control, except its name is ~'s Warform and it's a 4/4 Construct artifact creature in addition to its other types. It gains haste until end of turn. Sacrifice it at the beginning of the next end step.", - "constraint": { - "type": "OnlyDuringYourTurn" - }, - "condition": null, - "batched": false + "source_text": "At the beginning of combat on your turn, create a token that's a copy of target noncreature artifact you control, except its name is ~'s Warform and it's a 4/4 Construct artifact creature in addition to its other types. It gains haste until end of turn. Sacrifice it at the beginning of the next end step.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__murderous_rider_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__murderous_rider_ir.snap index 6fa1865365..6fb914c217 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__murderous_rider_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__murderous_rider_ir.snap @@ -50,51 +50,118 @@ expression: "&ir" "fragment": "When ~ dies, put it on the bottom of its owner's library." }, "node": { - "PreLoweredTrigger": { - "mode": "ChangesZone", - "execute": { - "kind": "Spell", - "effect": { - "type": "PutAtLibraryPosition", - "target": { - "type": "ParentTarget" + "Trigger": { + "Parsed": { + "condition": "ChangesZone", + "partial_def": { + "mode": "ChangesZone", + "execute": null, + "valid_card": { + "type": "SelfRef" }, - "count": { - "type": "Fixed", - "value": 1 - }, - "position": { - "type": "Bottom" + "origin": "Battlefield", + "destination": "Graveyard", + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 43, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "put it on the bottom of its owner's library" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "PutAtLibraryPosition", + "target": { + "type": "ParentTarget" + }, + "count": { + "type": "Fixed", + "value": 1 + }, + "position": { + "type": "Bottom" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null } }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": { - "type": "SelfRef" - }, - "origin": "Battlefield", - "destination": "Graveyard", - "trigger_zones": [ - "Graveyard" - ], - "phase": null, - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "When ~ dies, put it on the bottom of its owner's library.", - "constraint": null, - "condition": null, - "batched": false + "modifiers": { + "optional": false, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "SelfRef" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "put it on the bottom of its owner's library.", + "relative_player_scope": null + }, + "source_text": "When ~ dies, put it on the bottom of its owner's library.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__nashi_moon_sages_scion_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__nashi_moon_sages_scion_ir.snap index 40a7dd75ae..50c8d1f03d 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__nashi_moon_sages_scion_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__nashi_moon_sages_scion_ir.snap @@ -1,6 +1,5 @@ --- source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs -assertion_line: 723 expression: "&ir" --- { @@ -52,87 +51,235 @@ expression: "&ir" "fragment": "Whenever ~ deals combat damage to a player, exile the top card of each player's library. Until end of turn, you may play one of those cards. If you cast a spell this way, pay life equal to its mana value rather than paying its mana cost." }, "node": { - "PreLoweredTrigger": { - "mode": "DamageDone", - "execute": { - "kind": "Spell", - "effect": { - "type": "ExileTop", - "player": { - "type": "Controller" - }, - "count": { - "type": "Fixed", - "value": 1 + "Trigger": { + "Parsed": { + "condition": "DamageDone", + "partial_def": { + "mode": "DamageDone", + "execute": null, + "valid_card": null, + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "CombatOnly", + "secondary": false, + "valid_target": { + "type": "Player" }, - "position": { - "type": "Top" - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "CastFromZone", - "target": { - "type": "TrackedSet", - "id": 0 - }, - "without_paying_mana_cost": false, - "mode": "Play", - "alt_ability_cost": { - "type": "PayLife", - "amount": { - "type": "Ref", - "qty": { - "type": "SelfManaValue" - } - } - }, - "duration": "UntilEndOfTurn" + "valid_source": { + "type": "SelfRef" }, - "cost": null, - "sub_ability": null, - "duration": "UntilEndOfTurn", "description": null, - "target_prompt": null, + "constraint": null, "condition": null, - "optional_targeting": false, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 43, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "exile the top card of each player's library" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "ExileTop", + "player": { + "type": "Controller" + }, + "count": { + "type": "Fixed", + "value": 1 + }, + "position": { + "type": "Top" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": { + "type": "All" + }, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 45, + "end_byte": 95, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "Until end of turn, you may play one of those cards" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "CastFromZone", + "target": { + "type": "ParentTarget" + }, + "without_paying_mana_cost": false, + "mode": "Play", + "duration": "UntilEndOfTurn" + }, + "duration": "UntilEndOfTurn", + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": true, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 2, + "source": { + "id": { + "item": 0, + "ordinal": 3 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 97, + "end_byte": 192, + "precision": "ChainRelative", + "ordinal_within_span": 2 + }, + "fragment": "If you cast a spell this way, pay life equal to its mana value rather than paying its mana cost" + }, + "disposition": { + "ModifyPrior": { + "modifier": { + "AltCost": { + "type": "PayLife", + "amount": { + "type": "Ref", + "qty": { + "type": "SelfManaValue" + } + } + } + } + } + }, + "parsed": { + "effect": { + "type": "Unimplemented", + "name": "alt_cost_rider_placeholder", + "description": null + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null + } + }, + "modifiers": { "optional": false, - "forward_result": false, - "sub_link": "SequentialSibling" + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "SelfRef" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "exile the top card of each player's library. until end of turn, you may play one of those cards. if you cast a spell this way, pay life equal to its mana value rather than paying its mana cost.", + "relative_player_scope": "TargetPlayer" }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "player_scope": { - "type": "All" - } - }, - "valid_card": null, - "origin": null, - "destination": null, - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": false, - "damage_kind": "CombatOnly", - "secondary": false, - "valid_target": { - "type": "Player" - }, - "valid_source": { - "type": "SelfRef" - }, - "description": "Whenever ~ deals combat damage to a player, exile the top card of each player's library. Until end of turn, you may play one of those cards. If you cast a spell this way, pay life equal to its mana value rather than paying its mana cost.", - "constraint": null, - "condition": null, - "batched": false + "source_text": "Whenever ~ deals combat damage to a player, exile the top card of each player's library. Until end of turn, you may play one of those cards. If you cast a spell this way, pay life equal to its mana value rather than paying its mana cost.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__odric_lunarch_marshal_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__odric_lunarch_marshal_ir.snap index fa14a62cba..811eca9597 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__odric_lunarch_marshal_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__odric_lunarch_marshal_ir.snap @@ -22,537 +22,222 @@ expression: "&ir" "fragment": "At the beginning of each combat, creatures you control gain first strike until end of turn if a creature you control has first strike. The same is true for flying, deathtouch, double strike, haste, hexproof, indestructible, lifelink, menace, reach, skulk, trample, and vigilance." }, "node": { - "PreLoweredTrigger": { - "mode": "Phase", - "execute": { - "kind": "Spell", - "effect": { - "type": "GenericEffect", - "static_abilities": [ - { - "mode": "Continuous", - "affected": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - }, - "modifications": [ - { - "type": "AddKeyword", - "keyword": "FirstStrike" - } - ], - "condition": { - "type": "IsPresent", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "WithKeyword", - "value": "FirstStrike" - } - ] - } - }, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "gain first strike" - }, - { - "mode": "Continuous", - "affected": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - }, - "modifications": [ - { - "type": "AddKeyword", - "keyword": "Flying" - } - ], - "condition": { - "type": "IsPresent", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "WithKeyword", - "value": "Flying" - } - ] - } - }, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "gain first strike" - }, - { - "mode": "Continuous", - "affected": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - }, - "modifications": [ - { - "type": "AddKeyword", - "keyword": "Deathtouch" - } - ], - "condition": { - "type": "IsPresent", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "WithKeyword", - "value": "Deathtouch" - } - ] - } - }, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "gain first strike" - }, - { - "mode": "Continuous", - "affected": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - }, - "modifications": [ - { - "type": "AddKeyword", - "keyword": "DoubleStrike" - } - ], - "condition": { - "type": "IsPresent", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "WithKeyword", - "value": "DoubleStrike" - } - ] - } - }, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "gain first strike" - }, - { - "mode": "Continuous", - "affected": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - }, - "modifications": [ - { - "type": "AddKeyword", - "keyword": "Haste" - } - ], - "condition": { - "type": "IsPresent", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "WithKeyword", - "value": "Haste" - } - ] - } - }, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "gain first strike" - }, - { - "mode": "Continuous", - "affected": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - }, - "modifications": [ - { - "type": "AddKeyword", - "keyword": "Hexproof" - } - ], - "condition": { - "type": "IsPresent", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "WithKeyword", - "value": "Hexproof" - } - ] - } - }, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "gain first strike" - }, - { - "mode": "Continuous", - "affected": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - }, - "modifications": [ - { - "type": "AddKeyword", - "keyword": "Indestructible" - } - ], - "condition": { - "type": "IsPresent", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "WithKeyword", - "value": "Indestructible" - } - ] - } - }, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "gain first strike" - }, - { - "mode": "Continuous", - "affected": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - }, - "modifications": [ - { - "type": "AddKeyword", - "keyword": "Lifelink" - } - ], - "condition": { - "type": "IsPresent", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "WithKeyword", - "value": "Lifelink" - } - ] - } - }, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "gain first strike" - }, - { - "mode": "Continuous", - "affected": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - }, - "modifications": [ - { - "type": "AddKeyword", - "keyword": "Menace" - } - ], - "condition": { - "type": "IsPresent", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "WithKeyword", - "value": "Menace" - } - ] - } - }, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "gain first strike" - }, - { - "mode": "Continuous", - "affected": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - }, - "modifications": [ - { - "type": "AddKeyword", - "keyword": "Reach" - } - ], - "condition": { - "type": "IsPresent", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "WithKeyword", - "value": "Reach" - } - ] - } - }, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "gain first strike" - }, - { - "mode": "Continuous", - "affected": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - }, - "modifications": [ - { - "type": "AddKeyword", - "keyword": "Skulk" - } - ], - "condition": { - "type": "IsPresent", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "WithKeyword", - "value": "Skulk" - } - ] - } - }, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "gain first strike" - }, - { - "mode": "Continuous", - "affected": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - }, - "modifications": [ - { - "type": "AddKeyword", - "keyword": "Trample" - } - ], - "condition": { - "type": "IsPresent", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "WithKeyword", - "value": "Trample" - } - ] - } - }, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "gain first strike" - }, - { - "mode": "Continuous", - "affected": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - }, - "modifications": [ - { - "type": "AddKeyword", - "keyword": "Vigilance" - } - ], - "condition": { - "type": "IsPresent", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "WithKeyword", - "value": "Vigilance" - } - ] - } - }, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "gain first strike" - } + "Trigger": { + "Parsed": { + "condition": "Phase", + "partial_def": { + "mode": "Phase", + "execute": null, + "valid_card": null, + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" ], - "duration": "UntilEndOfTurn", - "target": null + "phase": "BeginCombat", + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 100, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "creatures you control gain first strike until end of turn if a creature you control has first strike" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "GenericEffect", + "static_abilities": [ + { + "mode": "Continuous", + "affected": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] + }, + "modifications": [ + { + "type": "AddKeyword", + "keyword": "FirstStrike" + } + ], + "condition": null, + "affected_zone": null, + "effect_zone": null, + "active_zones": [], + "characteristic_defining": false, + "description": "gain first strike" + } + ], + "duration": "UntilEndOfTurn", + "target": null + }, + "duration": "UntilEndOfTurn", + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": { + "type": "QuantityCheck", + "lhs": { + "type": "Ref", + "qty": { + "type": "ObjectCount", + "filter": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [ + { + "type": "WithKeyword", + "value": "FirstStrike" + } + ] + } + } + }, + "comparator": "GE", + "rhs": { + "type": "Fixed", + "value": 1 + } + }, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 102, + "end_byte": 245, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "The same is true for flying, deathtouch, double strike, haste, hexproof, indestructible, lifelink, menace, reach, skulk, trample, and vigilance" + }, + "disposition": { + "ReplicatePerKeyword": { + "keywords": [ + "Flying", + "Deathtouch", + "DoubleStrike", + "Haste", + "Hexproof", + "Indestructible", + "Lifelink", + "Menace", + "Reach", + "Skulk", + "Trample", + "Vigilance" + ], + "kind": "StaticGrant" + } + }, + "parsed": { + "effect": { + "type": "Unimplemented", + "name": "same_is_true_for_placeholder", + "description": null + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null + } + }, + "modifiers": { + "optional": false, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "Any" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "creatures you control gain first strike until end of turn if a creature you control has first strike. the same is true for flying, deathtouch, double strike, haste, hexproof, indestructible, lifelink, menace, reach, skulk, trample, and vigilance.", + "relative_player_scope": null }, - "cost": null, - "sub_ability": null, - "duration": "UntilEndOfTurn", - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": null, - "origin": null, - "destination": null, - "trigger_zones": [ - "Battlefield" - ], - "phase": "BeginCombat", - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "At the beginning of each combat, creatures you control gain first strike until end of turn if a creature you control has first strike. The same is true for flying, deathtouch, double strike, haste, hexproof, indestructible, lifelink, menace, reach, skulk, trample, and vigilance.", - "constraint": null, - "condition": null, - "batched": false + "source_text": "At the beginning of each combat, creatures you control gain first strike until end of turn if a creature you control has first strike. The same is true for flying, deathtouch, double strike, haste, hexproof, indestructible, lifelink, menace, reach, skulk, trample, and vigilance.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__questing_beast_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__questing_beast_ir.snap index d50a3765b4..f57223e754 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__questing_beast_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__questing_beast_ir.snap @@ -206,60 +206,127 @@ expression: "&ir" "fragment": "Whenever ~ deals combat damage to an opponent, it deals that much damage to target planeswalker that player controls." }, "node": { - "PreLoweredTrigger": { - "mode": "DamageDone", - "execute": { - "kind": "Spell", - "effect": { - "type": "DealDamage", - "amount": { - "type": "Ref", - "qty": { - "type": "EventContextAmount" - } - }, - "target": { + "Trigger": { + "Parsed": { + "condition": "DamageDone", + "partial_def": { + "mode": "DamageDone", + "execute": null, + "valid_card": null, + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "CombatOnly", + "secondary": false, + "valid_target": { "type": "Typed", - "type_filters": [ - "Planeswalker" - ], - "controller": "TargetPlayer", + "type_filters": [], + "controller": "Opponent", "properties": [] + }, + "valid_source": { + "type": "SelfRef" + }, + "description": null, + "constraint": null, + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 69, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "it deals that much damage to target planeswalker that player controls" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "DealDamage", + "amount": { + "type": "Ref", + "qty": { + "type": "EventContextAmount" + } + }, + "target": { + "type": "Typed", + "type_filters": [ + "Planeswalker" + ], + "controller": "TargetPlayer", + "properties": [] + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null } }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": null, - "origin": null, - "destination": null, - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": false, - "damage_kind": "CombatOnly", - "secondary": false, - "valid_target": { - "type": "Typed", - "type_filters": [], - "controller": "Opponent", - "properties": [] - }, - "valid_source": { - "type": "SelfRef" - }, - "description": "Whenever ~ deals combat damage to an opponent, it deals that much damage to target planeswalker that player controls.", - "constraint": null, - "condition": null, - "batched": false + "modifiers": { + "optional": false, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "SelfRef" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "it deals that much damage to target planeswalker that player controls.", + "relative_player_scope": "TargetPlayer" + }, + "source_text": "Whenever ~ deals combat damage to an opponent, it deals that much damage to target planeswalker that player controls.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__reckless_bushwhacker_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__reckless_bushwhacker_ir.snap index 3beaed1b4c..c2b744b9f5 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__reckless_bushwhacker_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__reckless_bushwhacker_ir.snap @@ -79,83 +79,150 @@ expression: "&ir" "fragment": "When ~ enters, if its surge cost was paid, other creatures you control get +1/+0 and gain haste until end of turn." }, "node": { - "PreLoweredTrigger": { - "mode": "ChangesZone", - "execute": { - "kind": "Spell", - "effect": { - "type": "GenericEffect", - "static_abilities": [ - { - "mode": "Continuous", - "affected": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [ - { - "type": "Another" + "Trigger": { + "Parsed": { + "condition": "ChangesZone", + "partial_def": { + "mode": "ChangesZone", + "execute": null, + "valid_card": { + "type": "SelfRef" + }, + "origin": null, + "destination": "Battlefield", + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 70, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "other creatures you control get +1/+0 and gain haste until end of turn" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null } - ] - }, - "modifications": [ - { - "type": "AddPower", - "value": 1 }, - { - "type": "AddToughness", - "value": 0 + "parsed": { + "effect": { + "type": "GenericEffect", + "static_abilities": [ + { + "mode": "Continuous", + "affected": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [ + { + "type": "Another" + } + ] + }, + "modifications": [ + { + "type": "AddPower", + "value": 1 + }, + { + "type": "AddToughness", + "value": 0 + }, + { + "type": "AddKeyword", + "keyword": "Haste" + } + ], + "condition": null, + "affected_zone": null, + "effect_zone": null, + "active_zones": [], + "characteristic_defining": false, + "description": "get +1/+0 and gain haste" + } + ], + "duration": "UntilEndOfTurn", + "target": null + }, + "duration": "UntilEndOfTurn", + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null }, - { - "type": "AddKeyword", - "keyword": "Haste" - } - ], - "condition": null, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "get +1/+0 and gain haste" - } - ], - "duration": "UntilEndOfTurn", - "target": null + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null + } }, - "cost": null, - "sub_ability": null, - "duration": "UntilEndOfTurn", - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": { - "type": "SelfRef" - }, - "origin": null, - "destination": "Battlefield", - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "When ~ enters, if its surge cost was paid, other creatures you control get +1/+0 and gain haste until end of turn.", - "constraint": null, - "condition": { - "type": "CastVariantPaid", - "variant": "Surge" - }, - "batched": false + "modifiers": { + "optional": false, + "unless_pay": null, + "intervening_if": { + "type": "CastVariantPaid", + "variant": "Surge" + }, + "trigger_subject": { + "type": "SelfRef" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "if its surge cost was paid, other creatures you control get +1/+0 and gain haste until end of turn.", + "relative_player_scope": null + }, + "source_text": "When ~ enters, if its surge cost was paid, other creatures you control get +1/+0 and gain haste until end of turn.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__smugglers_copter_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__smugglers_copter_ir.snap index 68c1f950d0..58b10c8b0e 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__smugglers_copter_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__smugglers_copter_ir.snap @@ -50,73 +50,173 @@ expression: "&ir" "fragment": "Whenever ~ attacks or blocks, you may draw a card. If you do, discard a card." }, "node": { - "PreLoweredTrigger": { - "mode": "AttacksOrBlocks", - "execute": { - "kind": "Spell", - "effect": { - "type": "Draw", - "count": { - "type": "Fixed", - "value": 1 + "Trigger": { + "Parsed": { + "condition": "AttacksOrBlocks", + "partial_def": { + "mode": "AttacksOrBlocks", + "execute": null, + "valid_card": { + "type": "SelfRef" }, - "target": { - "type": "Controller" + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 19, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "you may draw a card" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Draw", + "count": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "Controller" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": true, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 21, + "end_byte": 46, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "If you do, discard a card" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Discard", + "count": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "Controller" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": { + "type": "EffectOutcome", + "signal": "OptionalEffectPerformed" + }, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null } }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "Discard", - "count": { - "type": "Fixed", - "value": 1 - }, - "target": { - "type": "Controller" - } - }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": { - "type": "EffectOutcome", - "signal": "OptionalEffectPerformed" + "modifiers": { + "optional": true, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "SelfRef" }, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "sub_link": "SequentialSibling" + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "you may draw a card. if you do, discard a card.", + "relative_player_scope": null }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": true, - "forward_result": false - }, - "valid_card": { - "type": "SelfRef" - }, - "origin": null, - "destination": null, - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": true, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "Whenever ~ attacks or blocks, you may draw a card. If you do, discard a card.", - "constraint": null, - "condition": null, - "batched": false + "source_text": "Whenever ~ attacks or blocks, you may draw a card. If you do, discard a card.", + "die_results": [] + } } } }, diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__snapcaster_mage_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__snapcaster_mage_ir.snap index 6ae7d87be7..6122ff6606 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__snapcaster_mage_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__snapcaster_mage_ir.snap @@ -50,100 +50,216 @@ expression: "&ir" "fragment": "When ~ enters, target instant or sorcery card in your graveyard gains flashback until end of turn. The flashback cost is equal to its mana cost. (You may cast that card from your graveyard for its flashback cost. Then exile it.)" }, "node": { - "PreLoweredTrigger": { - "mode": "ChangesZone", - "execute": { - "kind": "Spell", - "effect": { - "type": "GenericEffect", - "static_abilities": [ - { - "mode": "Continuous", - "affected": { - "type": "ParentTarget" - }, - "modifications": [ - { - "type": "AddKeyword", - "keyword": { - "Flashback": { - "type": "Mana", - "data": { - "type": "SelfManaCost" - } - } - } - } - ], - "condition": null, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "gain flashback" - } + "Trigger": { + "Parsed": { + "condition": "ChangesZone", + "partial_def": { + "mode": "ChangesZone", + "execute": null, + "valid_card": { + "type": "SelfRef" + }, + "origin": null, + "destination": "Battlefield", + "trigger_zones": [ + "Battlefield" ], - "duration": "UntilEndOfTurn", - "target": { - "type": "Or", - "filters": [ + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ { - "type": "Typed", - "type_filters": [ - "Instant" - ], - "controller": "You", - "properties": [ - { - "type": "InZone", - "zone": "Graveyard" + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 82, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "target instant or sorcery card in your graveyard gains flashback until end of turn" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null } - ] + }, + "parsed": { + "effect": { + "type": "GenericEffect", + "static_abilities": [ + { + "mode": "Continuous", + "affected": { + "type": "ParentTarget" + }, + "modifications": [ + { + "type": "AddKeyword", + "keyword": { + "Flashback": { + "type": "Mana", + "data": { + "type": "SelfManaCost" + } + } + } + } + ], + "condition": null, + "affected_zone": null, + "effect_zone": null, + "active_zones": [], + "characteristic_defining": false, + "description": "gain flashback" + } + ], + "duration": "UntilEndOfTurn", + "target": { + "type": "Or", + "filters": [ + { + "type": "Typed", + "type_filters": [ + "Instant" + ], + "controller": "You", + "properties": [ + { + "type": "InZone", + "zone": "Graveyard" + } + ] + }, + { + "type": "Typed", + "type_filters": [ + "Sorcery" + ], + "controller": "You", + "properties": [ + { + "type": "InZone", + "zone": "Graveyard" + } + ] + } + ] + } + }, + "duration": "UntilEndOfTurn", + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, { - "type": "Typed", - "type_filters": [ - "Sorcery" - ], - "controller": "You", - "properties": [ - { - "type": "InZone", - "zone": "Graveyard" + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 84, + "end_byte": 128, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "The flashback cost is equal to its mana cost" + }, + "disposition": { + "Continue": { + "continuation": "SelfCostKeywordCostClarification" } - ] + }, + "parsed": { + "effect": { + "type": "Unimplemented", + "name": "the", + "description": "The flashback cost is equal to its mana cost" + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } - ] + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null } }, - "cost": null, - "sub_ability": null, - "duration": "UntilEndOfTurn", - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": { - "type": "SelfRef" - }, - "origin": null, - "destination": "Battlefield", - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "When ~ enters, target instant or sorcery card in your graveyard gains flashback until end of turn. The flashback cost is equal to its mana cost.", - "constraint": null, - "condition": null, - "batched": false + "modifiers": { + "optional": false, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "SelfRef" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "target instant or sorcery card in your graveyard gains flashback until end of turn. the flashback cost is equal to its mana cost.", + "relative_player_scope": null + }, + "source_text": "When ~ enters, target instant or sorcery card in your graveyard gains flashback until end of turn. The flashback cost is equal to its mana cost.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__stoneforge_mystic_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__stoneforge_mystic_ir.snap index c2eb38dbad..1c1048c211 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__stoneforge_mystic_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__stoneforge_mystic_ir.snap @@ -22,97 +22,183 @@ expression: "&ir" "fragment": "When ~ enters, you may search your library for an Equipment card, reveal it, put it into your hand, then shuffle." }, "node": { - "PreLoweredTrigger": { - "mode": "ChangesZone", - "execute": { - "kind": "Spell", - "effect": { - "type": "SearchLibrary", - "filter": { - "type": "Typed", - "type_filters": [ - "Artifact", + "Trigger": { + "Parsed": { + "condition": "ChangesZone", + "partial_def": { + "mode": "ChangesZone", + "execute": null, + "valid_card": { + "type": "SelfRef" + }, + "origin": null, + "destination": "Battlefield", + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ { - "Subtype": "Equipment" + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 83, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "you may search your library for an Equipment card, reveal it, put it into your hand" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": { + "SearchDestination": { + "destination": "Hand", + "enter_tapped": false, + "enters_under": null, + "reveal": true, + "attach_host": null + } + } + } + }, + "parsed": { + "effect": { + "type": "SearchLibrary", + "filter": { + "type": "Typed", + "type_filters": [ + "Artifact", + { + "Subtype": "Equipment" + } + ], + "controller": null, + "properties": [] + }, + "count": { + "type": "Fixed", + "value": 1 + }, + "reveal": true + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Then", + "condition": null, + "is_optional": true, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 90, + "end_byte": 97, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "shuffle" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Shuffle", + "target": { + "type": "Controller" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } ], - "controller": null, - "properties": [] - }, - "count": { - "type": "Fixed", - "value": 1 - }, - "reveal": true - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "ChangeZone", - "origin": "Library", - "destination": "Hand", - "target": { - "type": "Any" - }, - "owner_library": false, - "enter_transformed": false, - "enter_tapped": false, - "enters_attacking": false - }, - "cost": null, - "sub_ability": { "kind": "Spell", - "effect": { - "type": "Shuffle", - "target": { - "type": "Controller" - } - }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null + } + }, + "modifiers": { + "optional": true, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "SelfRef" }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "you may search your library for an equipment card, reveal it, put it into your hand, then shuffle.", + "relative_player_scope": null }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": true, - "forward_result": false - }, - "valid_card": { - "type": "SelfRef" - }, - "origin": null, - "destination": "Battlefield", - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": true, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "When ~ enters, you may search your library for an Equipment card, reveal it, put it into your hand, then shuffle.", - "constraint": null, - "condition": null, - "batched": false + "source_text": "When ~ enters, you may search your library for an Equipment card, reveal it, put it into your hand, then shuffle.", + "die_results": [] + } } } }, diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__sylvan_library_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__sylvan_library_ir.snap index 0b61b1daa3..5f0cc5f6d1 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__sylvan_library_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__sylvan_library_ir.snap @@ -1,6 +1,5 @@ --- source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs -assertion_line: 799 expression: "&ir" --- { @@ -23,77 +22,229 @@ expression: "&ir" "fragment": "At the beginning of your draw step, you may draw two additional cards. If you do, choose two cards in your hand drawn this turn. For each of those cards, pay 4 life or put the card on top of your library." }, "node": { - "PreLoweredTrigger": { - "mode": "Phase", - "execute": { - "kind": "Spell", - "effect": { - "type": "Draw", - "count": { - "type": "Fixed", - "value": 2 + "Trigger": { + "Parsed": { + "condition": "Phase", + "partial_def": { + "mode": "Phase", + "execute": null, + "valid_card": null, + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": "Draw", + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": { + "type": "OnlyDuringYourTurn" }, - "target": { - "type": "Controller" + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 33, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "you may draw two additional cards" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Draw", + "count": { + "type": "Fixed", + "value": 2 + }, + "target": { + "type": "Controller" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": true, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 35, + "end_byte": 91, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "If you do, choose two cards in your hand drawn this turn" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "ChooseDrawnThisTurnPayOrTopdeck", + "count": { + "type": "Fixed", + "value": 2 + }, + "life_payment": { + "type": "Fixed", + "value": 4 + }, + "player": { + "type": "Controller" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": { + "type": "EffectOutcome", + "signal": "OptionalEffectPerformed" + }, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 2, + "source": { + "id": { + "item": 0, + "ordinal": 3 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 93, + "end_byte": 167, + "precision": "ChainRelative", + "ordinal_within_span": 2 + }, + "fragment": "For each of those cards, pay 4 life or put the card on top of your library" + }, + "disposition": { + "DrawnThisTurnFollowup": { + "life_payment": { + "type": "Fixed", + "value": 4 + } + } + }, + "parsed": { + "effect": { + "type": "Unimplemented", + "name": "drawn_this_turn_pay_or_topdeck_placeholder", + "description": null + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null } }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "ChooseDrawnThisTurnPayOrTopdeck", - "count": { - "type": "Fixed", - "value": 2 - }, - "life_payment": { - "type": "Fixed", - "value": 4 - }, - "player": { - "type": "Controller" - } + "modifiers": { + "optional": true, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "Any" }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": { - "type": "EffectOutcome", - "signal": "OptionalEffectPerformed" - }, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "sub_link": "SequentialSibling" + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "you may draw two additional cards. if you do, choose two cards in your hand drawn this turn. for each of those cards, pay 4 life or put the card on top of your library.", + "relative_player_scope": null }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": true, - "forward_result": false - }, - "valid_card": null, - "origin": null, - "destination": null, - "trigger_zones": [ - "Battlefield" - ], - "phase": "Draw", - "optional": true, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "At the beginning of your draw step, you may draw two additional cards. If you do, choose two cards in your hand drawn this turn. For each of those cards, pay 4 life or put the card on top of your library.", - "constraint": { - "type": "OnlyDuringYourTurn" - }, - "condition": null, - "batched": false + "source_text": "At the beginning of your draw step, you may draw two additional cards. If you do, choose two cards in your hand drawn this turn. For each of those cards, pay 4 life or put the card on top of your library.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__themberchaud_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__themberchaud_ir.snap index fbfd793b31..206ab80c23 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__themberchaud_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__themberchaud_ir.snap @@ -22,76 +22,134 @@ expression: "&ir" "fragment": "When ~ enters, he deals X damage to each other creature without flying and each player, where X is the number of Mountains you control." }, "node": { - "PreLoweredTrigger": { - "mode": "ChangesZone", - "execute": { - "kind": "Spell", - "effect": { - "type": "DamageAll", - "amount": { - "type": "Ref", - "qty": { - "type": "ObjectCount", - "filter": { - "type": "Typed", - "type_filters": [ - { - "Subtype": "Mountain" - } - ], - "controller": "You", - "properties": [] - } - } + "Trigger": { + "Parsed": { + "condition": "ChangesZone", + "partial_def": { + "mode": "ChangesZone", + "execute": null, + "valid_card": { + "type": "SelfRef" }, - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": null, - "properties": [ - { - "type": "Another" - }, + "origin": null, + "destination": "Battlefield", + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ { - "type": "WithoutKeyword", - "value": "Flying" + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 119, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "he deals X damage to each other creature without flying and each player, where X is the number of Mountains you control" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "DamageAll", + "amount": { + "type": "Ref", + "qty": { + "type": "Variable", + "name": "X" + } + }, + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [ + { + "type": "Another" + }, + { + "type": "WithoutKeyword", + "value": "Flying" + } + ] + }, + "player_filter": { + "type": "All" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": "the number of Mountains you control", + "unless_pay": null } - ] - }, - "player_filter": { - "type": "All" + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null } }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": { - "type": "SelfRef" - }, - "origin": null, - "destination": "Battlefield", - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "When ~ enters, he deals X damage to each other creature without flying and each player, where X is the number of Mountains you control.", - "constraint": null, - "condition": null, - "batched": false + "modifiers": { + "optional": false, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "SelfRef" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "he deals x damage to each other creature without flying and each player, where x is the number of mountains you control.", + "relative_player_scope": null + }, + "source_text": "When ~ enters, he deals X damage to each other creature without flying and each player, where X is the number of Mountains you control.", + "die_results": [] + } } } }, diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__tireless_tracker_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__tireless_tracker_ir.snap index 0a307c71e8..afee1dd095 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__tireless_tracker_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__tireless_tracker_ir.snap @@ -22,46 +22,118 @@ expression: "&ir" "fragment": "Landfall — Whenever a land you control enters, investigate. (Create a Clue token. It's an artifact with \"{2}, Sacrifice ~: Draw a card.\")" }, "node": { - "PreLoweredTrigger": { - "mode": "ChangesZone", - "execute": { - "kind": "Spell", - "effect": { - "type": "Investigate" + "Trigger": { + "Parsed": { + "condition": "ChangesZone", + "partial_def": { + "mode": "ChangesZone", + "execute": null, + "valid_card": { + "type": "Typed", + "type_filters": [ + "Land" + ], + "controller": "You", + "properties": [] + }, + "origin": null, + "destination": "Battlefield", + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + }, + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 11, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "investigate" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Investigate" + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null + } + }, + "modifiers": { + "optional": false, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "Typed", + "type_filters": [ + "Land" + ], + "controller": "You", + "properties": [] + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "investigate.", + "relative_player_scope": null }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": { - "type": "Typed", - "type_filters": [ - "Land" - ], - "controller": "You", - "properties": [] - }, - "origin": null, - "destination": "Battlefield", - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "Whenever a land you control enters, investigate.", - "constraint": null, - "condition": null, - "batched": false + "source_text": "Whenever a land you control enters, investigate.", + "die_results": [] + } } } }, @@ -83,56 +155,123 @@ expression: "&ir" "fragment": "Whenever you sacrifice a Clue, put a +1/+1 counter on ~." }, "node": { - "PreLoweredTrigger": { - "mode": "Sacrificed", - "execute": { - "kind": "Spell", - "effect": { - "type": "PutCounter", - "counter_type": "P1P1", - "count": { - "type": "Fixed", - "value": 1 + "Trigger": { + "Parsed": { + "condition": "Sacrificed", + "partial_def": { + "mode": "Sacrificed", + "execute": null, + "valid_card": { + "type": "Typed", + "type_filters": [ + { + "Subtype": "Clue" + } + ], + "controller": "You", + "properties": [] }, - "target": { - "type": "SelfRef" - } + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": { - "type": "Typed", - "type_filters": [ - { - "Subtype": "Clue" + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 24, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "put a +1/+1 counter on ~" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "PutCounter", + "counter_type": "P1P1", + "count": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "SelfRef" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null } - ], - "controller": "You", - "properties": [] - }, - "origin": null, - "destination": null, - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": null, - "valid_source": null, - "description": "Whenever you sacrifice a Clue, put a +1/+1 counter on ~.", - "constraint": null, - "condition": null, - "batched": false + }, + "modifiers": { + "optional": false, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "Controller" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "put a +1/+1 counter on ~.", + "relative_player_scope": null + }, + "source_text": "Whenever you sacrifice a Clue, put a +1/+1 counter on ~.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__young_pyromancer_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__young_pyromancer_ir.snap index 102243bde3..fe900cd6ea 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__young_pyromancer_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__young_pyromancer_ir.snap @@ -22,87 +22,154 @@ expression: "&ir" "fragment": "Whenever you cast an instant or sorcery spell, create a 1/1 red Elemental creature token." }, "node": { - "PreLoweredTrigger": { - "mode": "SpellCast", - "execute": { - "kind": "Spell", - "effect": { - "type": "Token", - "name": "Elemental", - "power": { - "type": "Fixed", - "value": 1 + "Trigger": { + "Parsed": { + "condition": "SpellCast", + "partial_def": { + "mode": "SpellCast", + "execute": null, + "valid_card": { + "type": "Or", + "filters": [ + { + "type": "Typed", + "type_filters": [ + "Instant" + ], + "controller": null, + "properties": [] + }, + { + "type": "Typed", + "type_filters": [ + "Sorcery" + ], + "controller": null, + "properties": [] + } + ] }, - "toughness": { - "type": "Fixed", - "value": 1 - }, - "types": [ - "Creature", - "Elemental" - ], - "colors": [ - "Red" + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" ], - "keywords": [], - "tapped": false, - "count": { - "type": "Fixed", - "value": 1 - }, - "owner": { + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": { "type": "Controller" }, - "enters_attacking": false + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "valid_card": { - "type": "Or", - "filters": [ - { - "type": "Typed", - "type_filters": [ - "Instant" - ], - "controller": null, - "properties": [] - }, - { - "type": "Typed", - "type_filters": [ - "Sorcery" + "body": { + "EffectChain": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 41, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "create a 1/1 red Elemental creature token" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Token", + "name": "Elemental", + "power": { + "type": "Fixed", + "value": 1 + }, + "toughness": { + "type": "Fixed", + "value": 1 + }, + "types": [ + "Creature", + "Elemental" + ], + "colors": [ + "Red" + ], + "keywords": [], + "tapped": false, + "count": { + "type": "Fixed", + "value": 1 + }, + "owner": { + "type": "Controller" + }, + "enters_attacking": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } ], - "controller": null, - "properties": [] + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": true, + "repeat_until": null } - ] - }, - "origin": null, - "destination": null, - "trigger_zones": [ - "Battlefield" - ], - "phase": null, - "optional": false, - "damage_kind": "Any", - "secondary": false, - "valid_target": { - "type": "Controller" - }, - "valid_source": null, - "description": "Whenever you cast an instant or sorcery spell, create a 1/1 red Elemental creature token.", - "constraint": null, - "condition": null, - "batched": false + }, + "modifiers": { + "optional": false, + "unless_pay": null, + "intervening_if": null, + "trigger_subject": { + "type": "Controller" + }, + "first_time_limit": null, + "constraint": null, + "has_up_to": false, + "effect_lower": "create a 1/1 red elemental creature token.", + "relative_player_scope": null + }, + "source_text": "Whenever you cast an instant or sorcery spell, create a 1/1 red Elemental creature token.", + "die_results": [] + } } } } diff --git a/crates/engine/src/parser/oracle_ir/trigger.rs b/crates/engine/src/parser/oracle_ir/trigger.rs index 25064bfecf..1207baec44 100644 --- a/crates/engine/src/parser/oracle_ir/trigger.rs +++ b/crates/engine/src/parser/oracle_ir/trigger.rs @@ -8,7 +8,7 @@ use serde::Serialize; use super::ast::parsed_clause; use super::context::ParseContext; -use super::effect_chain::EffectChainIr; +use super::effect_chain::{DieResultBranchIr, EffectChainIr}; use crate::types::ability::{ AbilityCost, AbilityDefinition, AbilityKind, ChoiceType, ControllerRef, Effect, ModalChoice, TargetFilter, TargetSelectionMode, TriggerCondition, TriggerConstraint, TriggerDefinition, @@ -39,19 +39,14 @@ use crate::types::triggers::TriggerMode; /// mechanism that RETIRES that debt. Reusing it would make the burn-down metric /// count the fix as debt, so the number would rise as the debt fell. /// -/// One variant today, deliberately. The IR-native sibling (`Parsed`, boxing a -/// `TriggerIr`) is added by the tranche that lands its first producer, not -/// before: adding it now would need a `#[allow(dead_code)]` and a CR 707.9a -/// slot-stamp arm that cannot be written until lowering runs (the stamp targets -/// `execute`, which does not exist pre-lowering), leaving a latent panic behind. -/// Adding the variant later instead turns every match site into a compile -/// error, which is the stronger forcing function. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub(crate) enum TriggerNodeIr { + /// Native parsed trigger decomposition, lowered only at the document seam. + Parsed(Box), /// Already-assembled definition from a recognizer that builds its own. /// `lower_trigger_node_ir` returns it untouched. Assembled { - definition: TriggerDefinition, + definition: Box, /// The recognizer's own input text — provenance only. Document lowering /// derives spans and fragments from the emitting line, never from here. source_text: String, @@ -62,21 +57,10 @@ impl TriggerNodeIr { /// Wrap a recognizer-produced trigger definition for source-ordered emission. pub(crate) fn from_definition(source_text: &str, definition: TriggerDefinition) -> Self { Self::Assembled { - definition, + definition: Box::new(definition), source_text: source_text.to_string(), } } - - /// The assembled definition, when one exists. - /// - /// Returns `Option` rather than `&TriggerDefinition` because a future - /// IR-native variant has no definition before lowering — CR 607.2d relation - /// discovery must see `None` there, not a fabricated definition. - pub(crate) fn definition(&self) -> Option<&TriggerDefinition> { - match self { - Self::Assembled { definition, .. } => Some(definition), - } - } } /// Trigger-level IR: the complete parsed representation of a trigger line @@ -98,6 +82,27 @@ pub(crate) struct TriggerIr { pub(crate) modifiers: TriggerModifiers, /// Original oracle text for description/provenance. pub(crate) source_text: String, + /// CR 706.3b result-table rows for the terminal die roll in this trigger. + /// They remain IR until trigger-body lowering attaches them before finalization. + pub(crate) die_results: Vec, +} + +impl TriggerIr { + /// Whether the body ends in the typed die-roll node that owns a result table. + pub(crate) fn has_terminal_roll_die(&self) -> bool { + let chain = match &self.body { + Some(TriggerBody::EffectChain(chain)) => chain, + Some(TriggerBody::ReflexivePayment(reflexive)) => &reflexive.effect_chain, + Some(TriggerBody::Modal(_)) + | Some(TriggerBody::Vote(_)) + | Some(TriggerBody::Pile(_)) + | None => return false, + }; + let Some(clause) = chain.clauses.last() else { + return false; + }; + matches!(clause.parsed.effect, Effect::RollDie { .. }) + } } /// The body of a trigger. Whole-body recognizers retain their typed payloads diff --git a/crates/engine/src/parser/oracle_special.rs b/crates/engine/src/parser/oracle_special.rs index e22bfde70b..bfe1f0f827 100644 --- a/crates/engine/src/parser/oracle_special.rs +++ b/crates/engine/src/parser/oracle_special.rs @@ -269,23 +269,18 @@ pub(crate) fn find_terminal_roll_die(def: &mut AbilityDefinition) -> Option<&mut None } -/// CR 706.3b: The die-roll instruction and its associated results table are -/// part of one ability, so this consumes the table lines into the header's IR. -/// CR 706.2: Also extracts an optional "and add/subtract X" modifier -/// from the header line so the resolver can shift the natural result before -/// branch lookup (Deck of Many Things, Diviner's Portent, Gale's Redirection). -pub(super) fn try_parse_die_roll_table( +/// CR 706.3b: Parse contiguous result-table rows into typed branch IR. +/// +/// A non-table first line is not consumed, so the ordinary document dispatcher +/// can still classify it. Empty separator rows become consumed only when they +/// actually precede at least one result row. +pub(crate) fn parse_die_result_branches_ir( lines: &[&str], - i: usize, - line: &str, + start_line: usize, kind: AbilityKind, -) -> Option<(AbilityDefinition, usize)> { - let lower = line.to_lowercase(); - let (sides, modifier) = parse_roll_die_sides_with_modifier(&lower)?; - +) -> (Vec, usize) { let mut branches = Vec::new(); - let mut has_branches = false; - let mut j = i + 1; + let mut j = start_line; while j < lines.len() { let table_line = strip_reminder_text(lines[j].trim()); if table_line.is_empty() { @@ -299,12 +294,30 @@ pub(super) fn try_parse_die_roll_table( max, effect: Box::new(parse_ability_ir_standalone(effect_text, kind)), }); - has_branches = true; j += 1; } else { break; } } + let next_line = if branches.is_empty() { start_line } else { j }; + (branches, next_line) +} + +/// CR 706.3b: The die-roll instruction and its associated results table are +/// part of one ability, so this consumes the table lines into the header's IR. +/// CR 706.2: Also extracts an optional "and add/subtract X" modifier +/// from the header line so the resolver can shift the natural result before +/// branch lookup (Deck of Many Things, Diviner's Portent, Gale's Redirection). +pub(super) fn try_parse_die_roll_table( + lines: &[&str], + i: usize, + line: &str, + kind: AbilityKind, +) -> Option<(AbilityDefinition, usize)> { + let lower = line.to_lowercase(); + let (sides, modifier) = parse_roll_die_sides_with_modifier(&lower)?; + + let (branches, next_line) = parse_die_result_branches_ir(lines, i + 1, kind); let ir = AbilityIr { source_text: line.to_string(), @@ -328,7 +341,7 @@ pub(super) fn try_parse_die_roll_table( }, die_results: branches, }; - Some((lower_ability_ir(&ir), if has_branches { j } else { i + 1 })) + Some((lower_ability_ir(&ir), next_line)) } /// CR 706.1a + CR 706.2: Parse the header line of a die-roll table, returning diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index 452d9ec3be..b3f0c37884 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -9,13 +9,14 @@ use nom::Parser; use super::oracle_effect::conditions::source_saddled_filter; use super::oracle_effect::{ - condition_text_is_rehomeable, lower_effect_chain_ir, parse_effect_chain_ir, - try_parse_reanimator_aura_etb_effect_ir, try_parse_reanimator_aura_grant_etb_effect_ir, + attach_die_result_branches_before_finalization, condition_text_is_rehomeable, + lower_effect_chain_ir, parse_effect_chain_ir, try_parse_reanimator_aura_etb_effect_ir, + try_parse_reanimator_aura_grant_etb_effect_ir, }; use super::oracle_ir::ast::parsed_clause; use super::oracle_ir::context::ParseContext; use super::oracle_ir::doc::PrintedTriggerIndex; -use super::oracle_ir::effect_chain::EffectChainIr; +use super::oracle_ir::effect_chain::{DieResultBranchIr, EffectChainIr}; use super::oracle_ir::trigger::{ FirstTimeLimit, ReflexivePaymentIr, TriggerBody, TriggerIr, TriggerModifiers, TriggerNodeIr, }; @@ -1474,6 +1475,7 @@ pub(crate) fn parse_trigger_line_with_index_ir( relative_player_scope, }, source_text: text.to_string(), + die_results: Vec::new(), } } @@ -1533,8 +1535,10 @@ fn mode_exposes_subject_batch(mode: &TriggerMode) -> bool { fn lower_trigger_effect_chain( chain_ir: &EffectChainIr, modifiers: &TriggerModifiers, + die_results: &[DieResultBranchIr], ) -> AbilityDefinition { let mut ability = lower_effect_chain_ir(chain_ir); + attach_die_result_branches_before_finalization(&mut ability, die_results); crate::parser::oracle_effect::finalize_effect_chain(&mut ability); if effect_adds_mana_to_triggering_player(&modifiers.effect_lower) && matches!( @@ -1583,7 +1587,8 @@ fn lower_trigger_effect_chain( /// which nothing added to `lower_trigger_ir` can invalidate. pub(crate) fn lower_trigger_node_ir(ir: &TriggerNodeIr) -> TriggerDefinition { match ir { - TriggerNodeIr::Assembled { definition, .. } => definition.clone(), + TriggerNodeIr::Parsed(trigger) => lower_trigger_ir(trigger), + TriggerNodeIr::Assembled { definition, .. } => (**definition).clone(), } } @@ -1593,12 +1598,14 @@ pub(crate) fn lower_trigger_ir(ir: &TriggerIr) -> TriggerDefinition { // Lower the body let execute = match &ir.body { - Some(TriggerBody::EffectChain(chain_ir)) => { - Some(Box::new(lower_trigger_effect_chain(chain_ir, modifiers))) - } + Some(TriggerBody::EffectChain(chain_ir)) => Some(Box::new(lower_trigger_effect_chain( + chain_ir, + modifiers, + &ir.die_results, + ))), Some(TriggerBody::ReflexivePayment(reflexive)) => { let mut reflexive_ability = - lower_trigger_effect_chain(&reflexive.effect_chain, modifiers); + lower_trigger_effect_chain(&reflexive.effect_chain, modifiers, &ir.die_results); reflexive_ability.condition = Some(AbilityCondition::WhenYouDo); let mut pay_ability = AbilityDefinition::new( @@ -1614,16 +1621,18 @@ pub(crate) fn lower_trigger_ir(ir: &TriggerIr) -> TriggerDefinition { Some(Box::new(pay_ability)) } Some(TriggerBody::Modal(modal)) => Some(Box::new( - lower_trigger_effect_chain(&modal.marker, modifiers) + lower_trigger_effect_chain(&modal.marker, modifiers, &[]) .with_modal(modal.choice.clone(), modal.mode_abilities.clone()), )), Some(TriggerBody::Vote(vote)) => Some(Box::new(lower_trigger_effect_chain( &vote.effect_chain(AbilityKind::Spell), modifiers, + &[], ))), Some(TriggerBody::Pile(pile)) => Some(Box::new(lower_trigger_effect_chain( &pile.effect_chain(AbilityKind::Spell), modifiers, + &[], ))), None => None, }; From f077471569d8a48cd2905a6016ef5b3693b9bc81 Mon Sep 17 00:00:00 2001 From: rsnetworkinginc Date: Wed, 29 Jul 2026 07:57:28 -0700 Subject: [PATCH 11/63] fix(engine): require distinct players for a multi-target player requirement (#6572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(engine): require distinct players for a multi-target player requirement CR 115.1b + CR 601.2c: a single instruction that chooses two or more target players requires them to be different — the same player can't fill more than one of the instruction's target slots. Scheming Symmetry ("Choose two target players.", issue #6459) let the same player be chosen for both slots. Modal cards attach DifferentTargetPlayers from "each mode must target a different player"; a plain multi-target player requirement had no equivalent, so the runtime distinctness authority (validate_target_constraints) never saw a constraint to enforce. A post-lowering pass now attaches TargetSelectionConstraint::DifferentTargetPlayers to every non-modal multi-target Player/Opponent requirement, reusing the existing enforcement. Closes #6459 * fix(engine): widen per-instance target distinctness to players Relocate the issue #6459 fix from a parser-lowering pass to the existing per-instance distinctness authority in legal_targets_for_selected_slot (CR 601.2c + CR 115.3): `already_in_instance` was narrowed to HashSet, so TargetRef::Player fell through and the same player could fill two slots of one instance of "target" (Scheming Symmetry, "Choose two target players."). Widening it to HashSet keeps objects AND players distinct within an instance while still permitting reuse across separate instances, and covers both the selection and validation paths, which route through the same helper. - drop the ensure_distinct_player_multi_targets parser pass and its parse-level test: the runtime authority now enforces the rule with no card-data churn, so the parse-diff is empty - correct the CR citation from CR 115.1b (the Aura rule) to the repo's standard CR 601.2c + CR 115.3 pair at every site - fix the TargetInstanceId doc, which restated CR 115.3 as "mutually distinct objects" — the rule covers objects or players - document TargetSelectionConstraint::DifferentTargetPlayers (modal path) with its CR reference The runtime regression test (real CastSpell -> ChooseTarget in a 3-player game: same player rejected, slot stays open, different player completes) is unchanged and passes against the relocated fix. --------- Co-authored-by: rsnetworkinginc Co-authored-by: matthewevans --- crates/engine/src/game/ability_utils.rs | 28 ++-- crates/engine/src/types/game_state.rs | 8 +- .../issue_6459_scheming_symmetry.rs | 133 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 4 files changed, 154 insertions(+), 16 deletions(-) create mode 100644 crates/engine/tests/integration/issue_6459_scheming_symmetry.rs diff --git a/crates/engine/src/game/ability_utils.rs b/crates/engine/src/game/ability_utils.rs index ed77913be4..b0395dcde7 100644 --- a/crates/engine/src/game/ability_utils.rs +++ b/crates/engine/src/game/ability_utils.rs @@ -1048,8 +1048,9 @@ pub enum TargetSelectionAdvance { /// CR 601.2c + CR 115.3: Identifies one instance of the word "target" on an /// ability. Slots sharing a `TargetInstanceId` are the SAME "target" (all slots /// of one `multi_target` "up to N target creatures" run) and must be mutually -/// distinct objects; slots with DIFFERENT ids are separate instances that may -/// reuse the same object ("Destroy target artifact and target land"). +/// distinct objects or players; slots with DIFFERENT ids are separate instances +/// that may reuse the same object or player ("Destroy target artifact and +/// target land"). #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct TargetInstanceId(usize); @@ -5368,21 +5369,22 @@ fn legal_targets_for_selected_slot( }); } - // CR 601.2c + CR 115.3: within one instance of "target", the same object - // can't be chosen twice. Remove objects already chosen in prior slots of - // THIS instance. Prior slots of a DIFFERENT instance (separate "target") do - // not constrain this slot — they may legally reuse the same object - // (CR 601.2c "Destroy target artifact and target land" Example). - let already_in_instance: std::collections::HashSet = prior_specs + // CR 601.2c + CR 115.3: within one instance of "target", the same target — + // object OR player — can't be chosen twice. Remove targets already chosen + // in prior slots of THIS instance (issue #6459: Scheming Symmetry's + // "Choose two target players." accepted the same player for both slots + // because this set was narrowed to `ObjectId` and dropped + // `TargetRef::Player`). Prior slots of a DIFFERENT instance (separate + // "target") do not constrain this slot — they may legally reuse the same + // object or player (CR 601.2c "Destroy target artifact and target land" + // Example). + let already_in_instance: std::collections::HashSet = prior_specs .iter() .zip(selected_slots) .filter(|(prior, _)| prior.instance == spec.instance) - .filter_map(|(_, sel)| match sel { - Some(TargetRef::Object(id)) => Some(*id), - _ => None, - }) + .filter_map(|(_, sel)| sel.clone()) .collect(); - legal.retain(|t| !matches!(t, TargetRef::Object(id) if already_in_instance.contains(id))); + legal.retain(|t| !already_in_instance.contains(t)); // CR 115.4: "other target" / "another target" is a separate instance of // "target" but must differ from every target already chosen for this diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 3e3d089566..5b76725066 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -6457,14 +6457,16 @@ impl PublicStateDirty { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type")] pub enum TargetSelectionConstraint { + /// CR 601.2c + CR 115.3: every chosen player target must be a different + /// player. Attached by the modal path ("each mode must target a different + /// player"), where distinctness spans the ability's modes rather than one + /// instance of the word "target". DifferentTargetPlayers, /// CR 115.1 + CR 601.2c: Object targets must be controlled by different players. DifferentObjectControllers, /// CR 115.1 + CR 601.2c + CR 400.1: Object targets must come from the same /// player-owned zone of the given kind, e.g. "from a single graveyard". - SameZoneOwner { - zone: Zone, - }, + SameZoneOwner { zone: Zone }, /// CR 202.3 + CR 601.2c: the chosen target set's combined mana value must /// satisfy `comparator` against `value`. `value` is a `QuantityExpr` (not /// `i32` like `SearchSelectionConstraint::TotalManaValue`) because the bound diff --git a/crates/engine/tests/integration/issue_6459_scheming_symmetry.rs b/crates/engine/tests/integration/issue_6459_scheming_symmetry.rs new file mode 100644 index 0000000000..23240755ee --- /dev/null +++ b/crates/engine/tests/integration/issue_6459_scheming_symmetry.rs @@ -0,0 +1,133 @@ +//! Scheming Symmetry — "Choose two target players." must require TWO DIFFERENT +//! players (CR 601.2c + CR 115.3: the same target — object or player — can't be +//! chosen multiple times for any one instance of the word "target"). +//! +//! Regression for issue #6459: in a multiplayer game the same player could be +//! chosen for both slots. The per-instance distinctness filter in +//! `legal_targets_for_selected_slot` (`game/ability_utils.rs`) excluded +//! already-chosen OBJECTS but dropped `TargetRef::Player`, so player targets +//! within one instance of "target" were never kept distinct. The fix widens +//! that set from `HashSet` to `HashSet`. +//! +//! Proof is end-to-end at runtime: after the first player is chosen, choosing +//! that SAME player for the second slot is rejected (and the slot stays open), +//! while a DIFFERENT player is accepted (the discriminating behaviour). + +use engine::game::scenario::GameScenario; +use engine::types::ability::TargetRef; +use engine::types::actions::GameAction; +use engine::types::game_state::{CastPaymentMode, WaitingFor}; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; + +const P0: PlayerId = PlayerId(0); +const P1: PlayerId = PlayerId(1); +const P2: PlayerId = PlayerId(2); + +const SCHEMING: &str = + "Choose two target players. Each of them searches their library for a card, \ +then shuffles and puts that card on top."; + +#[test] +fn scheming_symmetry_rejects_choosing_the_same_player_twice() { + let mut scenario = GameScenario::new_n_player(3, 42); + scenario.at_phase(Phase::PreCombatMain); + for &pid in &[P0, P1, P2] { + scenario.with_library_top(pid, &["Lib A", "Lib B"]); + } + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Scheming Symmetry", true, SCHEMING) + .with_mana_cost(ManaCost::zero()) + .id(); + let mut runner = scenario.build(); + + let card_id = runner.state().objects[&spell].card_id; + runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("casting the sorcery must be accepted"); + + // First slot: all three players are legal. Choose P1. + let WaitingFor::TargetSelection { + target_slots, + selection, + .. + } = runner.state().waiting_for.clone() + else { + panic!( + "expected a per-slot TargetSelection, got {}", + runner.waiting_for_kind() + ); + }; + let slot0 = &target_slots[selection.current_slot]; + for pid in [P0, P1, P2] { + assert!( + slot0.legal_targets.contains(&TargetRef::Player(pid)), + "{pid:?} must be a legal first-slot target, slot = {slot0:?}" + ); + } + runner + .act(GameAction::ChooseTarget { + target: Some(TargetRef::Player(P1)), + }) + .expect("choosing P1 for the first slot must succeed"); + + // Second slot: choosing the ALREADY-CHOSEN player P1 must be rejected + // (CR 601.2c + CR 115.3), while the state stays on the same target slot. + let WaitingFor::TargetSelection { + target_slots, + selection, + .. + } = runner.state().waiting_for.clone() + else { + panic!( + "expected the second target slot, got {}", + runner.waiting_for_kind() + ); + }; + assert_eq!( + selection.current_slot, 1, + "the duplicate-target check must run against the second target slot" + ); + let slot1 = &target_slots[selection.current_slot]; + assert!( + slot1.legal_targets.contains(&TargetRef::Player(P2)), + "P2 must be a legal alternative in the second target slot, slot = {slot1:?}" + ); + let reselect_same = runner.act(GameAction::ChooseTarget { + target: Some(TargetRef::Player(P1)), + }); + assert!( + reselect_same.is_err(), + "CR 601.2c + CR 115.3 (issue #6459): choosing the already-chosen player \ + P1 for the second slot must be rejected" + ); + assert!( + matches!( + runner.state().waiting_for, + WaitingFor::TargetSelection { .. } + ), + "after the rejected reselection the second target slot must still be open" + ); + + // A DIFFERENT player (P2) is accepted, so the requirement is satisfiable — + // proving the rejection is the distinctness rule, not a dead slot. + runner + .act(GameAction::ChooseTarget { + target: Some(TargetRef::Player(P2)), + }) + .expect("choosing a different player (P2) for the second slot must succeed"); + assert!( + !matches!( + runner.state().waiting_for, + WaitingFor::TargetSelection { .. } + ), + "with two distinct players chosen the spell must leave target selection, got {}", + runner.waiting_for_kind() + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 0b221f2498..1fe781fb5d 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -621,6 +621,7 @@ mod issue_6431_lava_dart_flashback_control_turn; mod issue_6435_mosswort_bridge_hideaway_play; mod issue_6437_fight_rigging_exiled_card_target; mod issue_6440_mockingbird_uncast_copy_ceiling; +mod issue_6459_scheming_symmetry; mod issue_6498_portent_of_calamity; mod issue_6499_flickering_ward_protection_exemption; mod issue_6500_loreseekers_stone_hand_cost; From 9f5ed5c662b92416b5b5cd1b1120ff8324c4879a Mon Sep 17 00:00:00 2001 From: minion1227 Date: Wed, 29 Jul 2026 08:13:46 -0700 Subject: [PATCH 12/63] feat(phase-ai): add discard-matters deck-feature axis + DiscardPayoffPolicy (#6786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(phase-ai): add discard-matters deck-feature axis + DiscardPayoffPolicy CR 701.9: a deck built on Archfiend of Ifnir, Bone Miser, Waste Not or Containment Construct *wants* to discard — every card pitched is a repeatable value trigger. The AI has the opposite instinct: `card_advantage` scores a card leaving hand as a loss, and nothing credits the trigger it fires. So the AI declines its own engine, routing around rummaging outlets and treating a discard cost as pure downside even when the discard IS the payoff. Feature (`features/discard_matters.rs`), structural over `CardFace` AST: - `source_count` — self-discard outlets. BOTH enabler spellings are read: `Effect::Discard` (rich form) and `Effect::DiscardCard` (older simple form). They are separate `Effect` variants with separate resolver paths and real cards use each, so classifying only one would silently drop half the class. - `payoff_count` — `TriggerMode::Discarded` / `DiscardedAll` engines, keeping only controller-scoped ones (a "whenever an opponent discards" punisher is a different deck) and excluding `SelfRef` triggers, so a pile of madness cards cannot masquerade as an engine base. - `commitment` — geometric mean over (source, payoff): both pillars mandatory. Discard with no engine is pure card disadvantage and the AI is RIGHT to avoid it; this axis must not push it to. Policy (`policies/discard_payoff.rs`, `CastSpell` + `ActivateAbility`) credits a discard only when a LIVE engine is on the battlefield, preflighted through the engine's `hypothetical_trigger_fireable` authority so a rate-limited, off-timing or no-legal-target engine is not credited value it cannot produce. The card-local check runs first, so every non-discard candidate is rejected after reading one card's AST — no board sweep, no affordability query in the search inner loop. Boundaries, stated in the module docs so they are reviewable rather than inferred: - `hand_disruption` scores OPPONENT discard (`TargetFilter::Opponent`); this axis is the disjoint half (`Controller`). The scope that qualifies here is exactly the one that disqualifies there, so the two never read the same card. - `cycling_discipline` owns when to pay a cycling cost. `TriggerMode:: CycledOrDiscarded` is deliberately NOT read here — counting it would double-score one card across two policies with different intents. Calibration anchors are computed, not asserted from intuition: 10 outlets + 4 engines over 36 nonland → 0.878; 6 + 2 → 0.481; incidental 2 + 1 → 0.196, below the 0.35 floor. Each is pinned by a test to the value the formula returns. Both scoring scalars land in `UNTUNED_POLICY_PENALTY_FIELDS` pending a paired-seed calibration. 60 tests. Five are proven RED by breaking the specific invariant each guards: dropping the `Effect::DiscardCard` sibling (fails at BOTH the deck-time and live seams), dropping the opponent-scope guard, admitting cycling triggers, and dropping the `SelfRef` madness exclusion. Co-Authored-By: Claude Opus 5 (1M context) * fix(phase-ai): classify discard paid as an ability COST, not only as an effect Addresses the blocker on #6786. The axis was built for the rummaging class — Wild Mongrel, Anje Falkenrath, flashback-discard — and then never reached it. Those cards spell the discard as `AbilityCost::Discard` (ability.rs:8309), while the source predicate read only `Effect::Discard` / `Effect::DiscardCard`. So activating a real outlet stayed neutral even with a live engine on the battlefield: exactly the case the policy exists to fix. Worse, the regression that claimed to cover it did not. `activated_rummaging_ outlet_is_credited` built an ability whose *effect* was a discard — a shape no printed card has — so it exercised the effect path a second time and witnessed nothing about costs. A test that constructs its own subject can confirm a behavior that never occurs. The fix extends the SHARED `is_discard_source_parts`, so deck detection and activation scoring pick it up from one place rather than growing a second cost-only path that could drift from the effect one: - `ability_cost_discards` gates on the engine's own `AbilityDefinition::cost_categories()` authority, which already flattens `Composite`/`OneOf` nesting, before walking anything. - `cost_discards` then walks for a discard the payer actually makes, reusing the existing `AbilityScope` parameter for the branch question. CR 601.2h: every component of a `Composite` is paid, so a discard inside it is guaranteed. CR 118.12a: a `OneOf` resolves to one branch, so its discard is something the DECK can plan around (`Potential`) but not something a live candidate is committed to (`Unconditional`) — crediting it there would score a discard the player may never make. - Count positivity flows through the existing `DiscardQuantity` parameter, so a zero-count cost is not credited at the live seam. - Non-recursive cost forms defer to `categories()` rather than a hand-written match arm: a newly added discarding cost variant is then picked up automatically, and the count check that path skips can only UNDER-credit. A discard cost needs no controller filter — it is always paid by the activating player, so there is no opponent-scoped case to exclude the way there is for `Effect::Discard`. Tests 60 → 68. Six of the eight new ones fail without the cost axis (three deck-time, three live), including the real Wild Mongrel shape. The two negatives — `one_of_cost_is_not_credited_at_the_live_seam` and `zero_count_discard_cost_is_not_credited` — would pass vacuously against the old code, so they were separately proven by mutating the `OneOf` branch gate and the count check; both fail under that mutation. Verified: `cargo test -p phase-ai` 1853 passed, `cargo clippy -p phase-ai -p phase-engine --all-targets --features proptest -- -D warnings` exit 0, `check-engine-authorities.sh` exit 0. Co-Authored-By: Claude Opus 5 (1M context) * fix(PR-6786): score guaranteed discard alternatives * test(phase-ai): cover nested optional discard costs and assert the score deltas Addresses the two CodeRabbit findings on #6786, on top of the maintainer's `OneOf` fixup. 1. Nested `OneOf` inside a `Composite` was unproven. A top-level `OneOf` and a flat `Composite` do not show that recursion carries the "not guaranteed" answer UP through the composite, so `Composite([Tap, OneOf([Discard, PayLife])])` could have been credited by treating any nested discard as certain. Added that case plus its positive twin, `Composite([Tap, OneOf([Discard, Discard])])`, so the boundary is pinned from both sides rather than only the rejecting one. 2. Several cost-path tests asserted only `reason.kind`, which would stay green if the guaranteed-composite path returned 0.0 or a neutral path returned a nonzero score. They now capture and assert `delta` too — including the maintainer's `one_of_cost_with_only_discard_branches_is_credited_at_the_live_seam`, which promised positive credit without checking it. `empty_one_of_cost_is_not_credited` is deliberately NOT claimed as a guard on the `!costs.is_empty()` precondition. Mutation testing showed it stays green with that check deleted: an empty `OneOf` never reaches the walk, because the engine's `cost_categories()` gate reports no `Discards` category for a branch list with nothing in it. The test is kept because the outcome is worth pinning at whichever layer enforces it, and its comment now says exactly that instead of implying coverage it does not provide. Tests 69 → 72. `composite_wrapping_a_mixed_one_of_is_not_credited` is proven RED by flipping the `OneOf` live-seam quantifier from `all` to `any`. Verified: `cargo test -p phase-ai` 1857 passed, `cargo clippy -p phase-ai -p phase-engine --all-targets --features proptest -- -D warnings` exit 0. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: matthewevans --- crates/phase-ai/src/config.rs | 12 + .../phase-ai/src/features/discard_matters.rs | 354 +++++++++ crates/phase-ai/src/features/mod.rs | 6 + .../src/features/tests/discard_matters.rs | 405 +++++++++++ crates/phase-ai/src/features/tests/mod.rs | 1 + .../phase-ai/src/policies/discard_payoff.rs | 299 ++++++++ crates/phase-ai/src/policies/mod.rs | 1 + crates/phase-ai/src/policies/registry.rs | 4 + .../src/policies/tests/discard_payoff.rs | 687 ++++++++++++++++++ crates/phase-ai/src/policies/tests/mod.rs | 1 + 10 files changed, 1770 insertions(+) create mode 100644 crates/phase-ai/src/features/discard_matters.rs create mode 100644 crates/phase-ai/src/features/tests/discard_matters.rs create mode 100644 crates/phase-ai/src/policies/discard_payoff.rs create mode 100644 crates/phase-ai/src/policies/tests/discard_payoff.rs diff --git a/crates/phase-ai/src/config.rs b/crates/phase-ai/src/config.rs index 13f99c6ae0..dc30557a9f 100644 --- a/crates/phase-ai/src/config.rs +++ b/crates/phase-ai/src/config.rs @@ -541,6 +541,10 @@ pub struct PolicyPenalties { /// reducer — the discount should be deployed first. #[serde(default = "default_cost_reduction_defer_penalty")] pub cost_reduction_defer_penalty: f64, + /// CR 701.9: card-equivalent value of discarding into one active "whenever + /// you discard" engine (preference band, per engine). + #[serde(default = "default_discard_payoff_bonus")] + pub discard_payoff_bonus: f64, } impl Default for PolicyPenalties { @@ -618,6 +622,7 @@ impl Default for PolicyPenalties { draw_payoff_bonus: default_draw_payoff_bonus(), cost_reduction_deploy_bonus: default_cost_reduction_deploy_bonus(), cost_reduction_defer_penalty: default_cost_reduction_defer_penalty(), + discard_payoff_bonus: default_discard_payoff_bonus(), } } } @@ -752,6 +757,9 @@ fn default_cost_reduction_deploy_bonus() -> f64 { fn default_cost_reduction_defer_penalty() -> f64 { -0.25 } +fn default_discard_payoff_bonus() -> f64 { + 0.6 +} fn default_sacrifice_token_cost() -> f64 { 0.5 } @@ -909,6 +917,10 @@ pub const UNTUNED_POLICY_PENALTY_FIELDS: &[(&str, &str)] = &[ "CR 601.2f sequencing nudge for casting past a cheaper unplayed reducer — \ awaiting a paired-seed ai-gate calibration.", ), + ( + "discard_payoff_bonus", + "CR 701.9 per-engine discard-payoff weight — awaiting a paired-seed ai-gate calibration.", + ), ( "poison_clock_pressure", "CR 104.3d win-detector weight — a critical-band term whose magnitude is \ diff --git a/crates/phase-ai/src/features/discard_matters.rs b/crates/phase-ai/src/features/discard_matters.rs new file mode 100644 index 0000000000..d4e41c5605 --- /dev/null +++ b/crates/phase-ai/src/features/discard_matters.rs @@ -0,0 +1,354 @@ +//! Discard-matters feature — structural detection of a deck that discards its +//! OWN cards on purpose, as fuel for a payoff engine. +//! +//! Parser AST verification — VERIFIED against engine source: +//! - `Effect::Discard { count, target, .. }` at +//! `crates/engine/src/types/ability.rs:11384` — the rich enabler form +//! (`QuantityExpr` count, `CardSelectionMode`, unless-filter). +//! - `Effect::DiscardCard { count, target }` at `ability.rs:10311` — the older +//! simple enabler form (`u32` count). BOTH are live in the resolver +//! (`game/effects/discard.rs` and `game/effects/mod.rs`), so a classifier +//! that reads only one silently misses part of the class. +//! - `AbilityCost::Discard { count, .. }` at `ability.rs:8309` — the rummaging +//! class (Wild Mongrel / Anje / flashback) spells its discard as a COST, not +//! an effect, so the source predicate reads both axes. Nesting is resolved +//! through the engine's `AbilityDefinition::cost_categories()` authority. +//! - `TriggerMode::Discarded` at `crates/engine/src/types/triggers.rs:321` and +//! `TriggerMode::DiscardedAll` at `triggers.rs:322` (CR 701.9) — the payoffs. +//! - `TriggerDefinition.valid_target` — used to keep only YOUR-discard engines, +//! excluding the "whenever an opponent discards" punisher shape. +//! +//! No parser remediation required — every axis is expressible over existing +//! typed AST. +//! +//! ## Why this axis exists +//! +//! CR 701.9: a deck built on Archfiend of Ifnir, Bone Miser, Waste Not or +//! Containment Construct *wants* to discard — each card pitched is a repeatable +//! value trigger. The AI has the opposite instinct: discarding is a cost, and +//! `card_advantage` scores the card leaving hand as a loss. Nothing values the +//! trigger it fires, so a deck whose entire engine is self-discard reads as +//! having no plan at all. +//! +//! ## Boundary with `hand_disruption` +//! +//! `hand_disruption` scores making an OPPONENT discard (`Effect::Discard` / +//! `Effect::DiscardCard` scoped to `TargetFilter::Opponent`) — stripping their +//! resources. This axis is the disjoint half: YOUR OWN discard +//! (`TargetFilter::Controller`) as fuel. The two never read the same card, +//! because the scope that qualifies here is exactly the one that disqualifies +//! there. +//! +//! ## Boundary with `cycling_discipline` +//! +//! `CyclingDisciplinePolicy` governs *when to pay a cycling cost* — patience +//! about spending a card for a replacement draw. That is a cost-discipline +//! question about one activation. This axis is a deck-composition question: +//! does a discard EVENT fire an engine on the battlefield? `TriggerMode:: +//! CycledOrDiscarded` is deliberately NOT read here — a cycling trigger is +//! `cycling_discipline`'s subject, and counting it would double-score the same +//! card across two policies with different intents. Only the discard-specific +//! modes qualify. + +use engine::game::ability_utils::ability_definition_supported; +use engine::game::quantity::resolve_quantity; +use engine::game::DeckEntry; +use engine::types::ability::{ + AbilityCost, AbilityDefinition, CostCategory, Effect, QuantityExpr, TargetFilter, + TriggerDefinition, +}; +use engine::types::card_type::CoreType; +use engine::types::game_state::GameState; +use engine::types::identifiers::ObjectId; +use engine::types::player::PlayerId; +use engine::types::triggers::TriggerMode; + +use crate::ability_chain::collect_scoped_effects; +pub(crate) use crate::ability_chain::AbilityScope; +use crate::features::commitment; + +/// Commitment at or above which self-discard is a deliberate engine rather than +/// incidental rummaging. Gates `DiscardPayoffPolicy::activation`. +pub const DISCARD_MATTERS_FLOOR: f32 = 0.35; + +/// CR 701.9: per-deck discard-matters classification. +/// +/// Populated once per game from `DeckEntry` data. Detection is structural over +/// `CardFace.abilities` and `CardFace.triggers` — never by card name. +#[derive(Debug, Clone, Default)] +pub struct DiscardMattersFeature { + /// Cards that discard YOU cards — the enablers that feed the payoff engine. + pub source_count: u32, + /// Permanents carrying a "whenever you discard a card" engine trigger + /// (CR 701.9), controller-scoped and not self-referential — the payoffs that + /// make pitching cards actively good. + pub payoff_count: u32, + /// `0.0..=1.0` — how central discarding-as-a-payoff is to this deck. + /// Consumed by `DiscardPayoffPolicy::activation` as the single scaling knob. + pub commitment: f32, +} + +/// Structural detection over each `DeckEntry`'s `CardFace` AST. +pub fn detect(deck: &[DeckEntry]) -> DiscardMattersFeature { + if deck.is_empty() { + return DiscardMattersFeature::default(); + } + + let mut source_count = 0u32; + let mut payoff_count = 0u32; + let mut total_nonland = 0u32; + + for entry in deck { + let face = &entry.card; + if !face.card_type.core_types.contains(&CoreType::Land) { + total_nonland = total_nonland.saturating_add(entry.count); + } + + // Deck-time: a modal card whose discard lives in a branch still counts as + // an enabler for the archetype, so scan the full potential tree. + if is_discard_source_parts( + &face.abilities, + AbilityScope::Potential, + &DiscardQuantity::Any, + ) { + source_count = source_count.saturating_add(entry.count); + } + if is_discard_payoff_parts(&face.triggers) { + payoff_count = payoff_count.saturating_add(entry.count); + } + } + + let commitment = compute_commitment(source_count, payoff_count, total_nonland); + + DiscardMattersFeature { + source_count, + payoff_count, + commitment, + } +} + +/// Whether a discard instruction's COUNT must be established positive. +/// +/// CR 701.9 + CR 107.1b: "discard N cards" resolves its quantity at resolution, +/// so a count of zero moves no card and emits no discard event — it fires no +/// "whenever you discard" engine. Deck classification and live candidate scoring +/// want different answers about that, so the requirement is a parameter of the +/// one classifier rather than a second forked copy of it. +pub(crate) enum DiscardQuantity<'a> { + /// Deck-time: any discard instruction marks the card regardless of count. A + /// "discard X" or "discard your hand" card is still an enabler for archetype + /// classification — its count is unknowable at deck-build time. + Any, + /// Live candidate: the count must resolve to at least one card *now*. + /// + /// Delegates to the engine's `resolve_quantity` authority rather than + /// re-deriving quantity semantics, so this agrees with the resolver by + /// construction. That also yields the correct conservative behavior for an + /// unbound `X`: it reads `cost_x_paid` off the source and falls back to 0 + /// when X has not been announced yet, so an unbound dynamic discard stays + /// neutral until it is known positive. + ResolvesPositive { + state: &'a GameState, + controller: PlayerId, + source: ObjectId, + }, +} + +impl DiscardQuantity<'_> { + /// CR 701.9: does this discard move at least one card under this requirement? + fn expr_is_satisfied(&self, count: &QuantityExpr) -> bool { + match self { + DiscardQuantity::Any => true, + DiscardQuantity::ResolvesPositive { + state, + controller, + source, + } => resolve_quantity(state, count, *controller, *source) >= 1, + } + } + + /// The `Effect::DiscardCard` sibling carries a plain `u32`, so its count is + /// already concrete and needs no game state to settle. + fn fixed_is_satisfied(&self, count: u32) -> bool { + match self { + DiscardQuantity::Any => true, + DiscardQuantity::ResolvesPositive { .. } => count >= 1, + } + } +} + +/// CR 701.9: the abilities discard YOU one or more cards — a repeatable enabler +/// for the payoff engine. Parts-based so it classifies both a deck-time +/// `CardFace.abilities` slice and the action's runtime effect chain. +/// +/// The caller chooses the `scope`: `Potential` for deck-time (a modal discard +/// mode still marks the card), `Unconditional` for a live candidate before its +/// mode is selected (CR 700.2 — a modal "choose one — discard / …" must NOT be +/// credited a discard until that mode is actually chosen). +/// +/// Both `Effect` spellings are read. They are separate variants with separate +/// resolver paths, and real cards use each, so classifying only the richer one +/// would drop half the class. +pub(crate) fn is_discard_source_parts<'a>( + abilities: impl IntoIterator, + scope: AbilityScope, + quantity: &DiscardQuantity<'_>, +) -> bool { + abilities.into_iter().any(|ability| { + ability_cost_discards(ability, scope, quantity) + || collect_scoped_effects(ability, scope) + .iter() + .any(|effect| match effect { + Effect::Discard { target, count, .. } => { + discards_controller(target) && quantity.expr_is_satisfied(count) + } + Effect::DiscardCard { target, count } => { + discards_controller(target) && quantity.fixed_is_satisfied(*count) + } + _ => false, + }) + }) +} + +/// CR 118.3 + CR 601.2h: does PAYING this ability's cost discard a card? +/// +/// The rummaging class this axis exists for — Wild Mongrel, Anje Falkenrath, +/// Faithless Looting's flashback — spells the discard as an `AbilityCost`, not as +/// an `Effect`. Reading only the effect chain classifies none of them, so the +/// shared source predicate has to look at both axes. +/// +/// A discard COST has no target filter because it is always paid by the +/// activating player, i.e. by you — there is no opponent-scoped cost to exclude +/// the way there is for `Effect::Discard`. +fn ability_cost_discards( + ability: &AbilityDefinition, + scope: AbilityScope, + quantity: &DiscardQuantity<'_>, +) -> bool { + // Cheap gate through the engine's own cost-category authority, which already + // flattens `Composite`/`OneOf` nesting. If it says no discard exists + // anywhere in the tree, there is nothing to walk. + if !ability.cost_categories().contains(&CostCategory::Discards) { + return false; + } + ability + .cost + .as_ref() + .is_some_and(|cost| cost_discards(cost, scope, quantity)) +} + +/// Walk a cost tree for a discard the payer will actually make. +/// +/// The `scope` distinction is the same one the effect walk uses, applied to +/// costs: `Potential` asks "could paying this discard a card?", `Unconditional` +/// asks "will it?". That matters for `AbilityCost::OneOf` (CR 118.12a) — a +/// "discard a card OR pay 2 life" cost is a discard the deck can plan around, +/// but NOT one a live candidate is committed to, so crediting it at the live +/// seam would score a discard the player may never make. +fn cost_discards(cost: &AbilityCost, scope: AbilityScope, quantity: &DiscardQuantity<'_>) -> bool { + match cost { + AbilityCost::Discard { count, .. } => quantity.expr_is_satisfied(count), + // CR 601.2h: every component of a composite cost is paid, so a discard + // anywhere inside it is guaranteed. + AbilityCost::Composite { costs } => costs + .iter() + .any(|inner| cost_discards(inner, scope, quantity)), + // CR 118.12a: only one branch is chosen. A discard is guaranteed only + // when every legal branch discards; otherwise it is merely possible. + AbilityCost::OneOf { costs } => { + !costs.is_empty() + && match scope { + AbilityScope::Potential => costs + .iter() + .any(|inner| cost_discards(inner, scope, quantity)), + AbilityScope::Unconditional => costs + .iter() + .all(|inner| cost_discards(inner, scope, quantity)), + } + } + AbilityCost::PerCounter { base, .. } => cost_discards(base, scope, quantity), + // Every other cost form: defer to the engine's category authority rather + // than enumerating here. That is deliberate — a newly added discarding + // cost variant is picked up automatically instead of being silently + // dropped by a stale match arm, and the count check it skips can only + // make this UNDER-credit, never over-credit. + other => other.categories().contains(&CostCategory::Discards), + } +} + +/// CR 701.9: the triggers carry a "whenever you discard a card" engine — a +/// repeatable payoff. Parts-based so it classifies both a deck-time +/// `CardFace.triggers` slice and a live `GameObject.trigger_definitions` +/// iterator (the runtime trigger authority). +pub(crate) fn is_discard_payoff_parts<'a>( + triggers: impl IntoIterator, +) -> bool { + triggers.into_iter().any(is_discard_payoff_trigger) +} + +/// Single-trigger structural classifier (mode + scope), exposed so the policy +/// can pair it with live per-turn firing eligibility per trigger entry. +pub(crate) fn is_discard_payoff_trigger(t: &TriggerDefinition) -> bool { + // 1. Mode fires on a discard event (CR 701.9). `DiscardedAll` is the + // "discards their hand" shape (Anje / Containment Construct class) and is + // the same event class. `CycledOrDiscarded` is deliberately excluded — + // see the module docs' `cycling_discipline` boundary. + if !matches!(t.mode, TriggerMode::Discarded | TriggerMode::DiscardedAll) { + return false; + } + // 2. Your-discard only: "whenever an opponent discards" is a punisher for a + // different deck, not a reason for YOU to pitch cards. + if !matches!(&t.valid_target, None | Some(TargetFilter::Controller)) { + return false; + } + // 3. Exclude a self-referential "when this card is discarded" trigger — + // madness and Obsidian-Charmaw-style recursion fire from the card being + // discarded itself, not from a battlefield engine. Those are the + // enabler's own payoff, already priced by the card, and counting them + // would let a pile of madness cards masquerade as an engine base. + if matches!(&t.valid_card, Some(TargetFilter::SelfRef)) { + return false; + } + // 4. The payoff must resolve to a real effect. A missing execute or an + // unsupported one (`TriggerNoExecute` / `Effect::Unimplemented`) produces + // no value, so it is not an engine — the same shared support authority the + // live fireability preflight consults. + t.execute + .as_deref() + .is_some_and(ability_definition_supported) +} + +/// True when the discard instruction makes the controller (you) discard, not an +/// opponent. `TargetFilter::Any` is the serde default on both variants and is +/// NOT treated as "you": an unscoped discard is exactly the ambiguous case, and +/// crediting it would pull `hand_disruption`'s opponent-facing cards into this +/// axis. +fn discards_controller(target: &TargetFilter) -> bool { + matches!(target, TargetFilter::Controller) +} + +/// Calibration (computed, not asserted from intuition — the anchors below are +/// the values `compute_commitment` actually returns): +/// - Realistic Rakdos pitch shell — 10 self-discard outlets + 4 engines +/// (Archfiend of Ifnir / Bone Miser / Waste Not) over 36 nonland → **0.878**. +/// - A lighter build, 6 outlets + 2 engines → 0.481: still a real plan, still +/// above `DISCARD_MATTERS_FLOOR`. +/// +/// Anti-calibration: +/// - Incidental rummaging, 2 outlets + 1 engine over 36 nonland → **0.196**, +/// comfortably below the floor. +/// - Outlets with no engine, or an engine with no outlet → 0.0. +/// +/// Geometric mean over (source, payoff): BOTH pillars are mandatory. Discard +/// with no engine is pure card disadvantage — the AI is RIGHT to avoid it, and +/// this axis must not push it to. An engine with no outlet only ever fires on a +/// cleanup-step discard. +fn compute_commitment(source_count: u32, payoff_count: u32, total_nonland: u32) -> f32 { + // ~18 self-discard outlets per 60 nonland is a fully-committed pitch shell. + let source_density = (commitment::density_per_60(source_count, total_nonland) / 18.0).min(1.0); + // ~8 engine payoffs per 60 nonland is a fully-committed payoff base. Engines + // are scarcer than outlets, but not as scarce as a draw engine: several are + // cheap enchantments/creatures a pitch deck runs in multiples. + let payoff_density = (commitment::density_per_60(payoff_count, total_nonland) / 8.0).min(1.0); + commitment::geometric_mean(&[source_density, payoff_density]) +} diff --git a/crates/phase-ai/src/features/mod.rs b/crates/phase-ai/src/features/mod.rs index 41c0a1414f..278aa182de 100644 --- a/crates/phase-ai/src/features/mod.rs +++ b/crates/phase-ai/src/features/mod.rs @@ -14,6 +14,7 @@ pub mod commitment; pub mod control; pub mod cost_reduction; pub mod devotion; +pub mod discard_matters; pub mod draw_matters; pub mod enchantments; pub mod energy; @@ -40,6 +41,7 @@ pub use blink::BlinkFeature; pub use control::ControlFeature; pub use cost_reduction::CostReductionFeature; pub use devotion::DevotionFeature; +pub use discard_matters::DiscardMattersFeature; pub use draw_matters::DrawMattersFeature; pub use enchantments::EnchantmentsFeature; pub use energy::EnergyFeature; @@ -98,6 +100,9 @@ pub struct DeckFeatures { pub graveyard_types: GraveyardTypesFeature, /// CR 121.1: "whenever you draw" payoff density (draw sources + engines). pub draw_matters: DrawMattersFeature, + /// CR 701.9: "whenever you discard" payoff density (self-discard outlets + + /// engines). Disjoint from `hand_disruption`, which scores OPPONENT discard. + pub discard_matters: DiscardMattersFeature, /// Declaration-derived: the deck's declared bracket tier. Unlike the /// other fields here, this is not structurally detected from card text — /// it is a per-deck declaration set at deck-analysis time from deck @@ -147,6 +152,7 @@ impl DeckFeatures { poison: poison::detect(deck), graveyard_types: graveyard_types::detect(deck), draw_matters: draw_matters::detect(deck), + discard_matters: discard_matters::detect(deck), bracket_tier: tier, } } diff --git a/crates/phase-ai/src/features/tests/discard_matters.rs b/crates/phase-ai/src/features/tests/discard_matters.rs new file mode 100644 index 0000000000..1021f0b50c --- /dev/null +++ b/crates/phase-ai/src/features/tests/discard_matters.rs @@ -0,0 +1,405 @@ +//! Unit tests for `features::discard_matters` — CR 701.9 "whenever you discard" +//! detection. No `#[cfg(test)]` in SOURCE files; tests live here. + +use engine::game::DeckEntry; +use engine::types::ability::CardSelectionMode; +use engine::types::ability::{ + AbilityCost, AbilityDefinition, AbilityKind, DiscardSelfScope, Effect, QuantityExpr, + TargetFilter, TriggerDefinition, +}; +use engine::types::card::CardFace; +use engine::types::card_type::{CardType, CoreType}; +use engine::types::triggers::TriggerMode; + +use crate::features::discard_matters::*; + +fn face(name: &str, core: CoreType) -> CardFace { + CardFace { + name: name.to_string(), + card_type: CardType { + supertypes: Vec::new(), + core_types: vec![core], + subtypes: Vec::new(), + }, + ..Default::default() + } +} + +fn entry(card: CardFace, count: u32) -> DeckEntry { + DeckEntry { card, count } +} + +/// The rich enabler form: "discard two cards" scoped to you. +fn discard_source(name: &str) -> CardFace { + let mut f = face(name, CoreType::Sorcery); + f.abilities = vec![AbilityDefinition::new( + AbilityKind::Spell, + Effect::Discard { + count: QuantityExpr::Fixed { value: 2 }, + target: TargetFilter::Controller, + selection: CardSelectionMode::Chosen, + unless_filter: None, + filter: None, + }, + )]; + f +} + +/// The older simple enabler form — a separate `Effect` variant with its own +/// resolver path. Reading only `Effect::Discard` would drop this half. +fn discard_card_source(name: &str) -> CardFace { + let mut f = face(name, CoreType::Sorcery); + f.abilities = vec![AbilityDefinition::new( + AbilityKind::Spell, + Effect::DiscardCard { + count: 1, + target: TargetFilter::Controller, + }, + )]; + f +} + +/// Opponent-facing discard — `hand_disruption`'s subject, not this axis. +fn opponent_discard(name: &str) -> CardFace { + let mut f = face(name, CoreType::Sorcery); + f.abilities = vec![AbilityDefinition::new( + AbilityKind::Spell, + Effect::DiscardCard { + count: 1, + target: TargetFilter::Opponent, + }, + )]; + f +} + +fn payoff_trigger( + mode: TriggerMode, + valid_card: Option, + valid_target: Option, +) -> TriggerDefinition { + let mut t = TriggerDefinition::new(mode); + if let Some(vc) = valid_card { + t = t.valid_card(vc); + } + if let Some(vt) = valid_target { + t = t.valid_target(vt); + } + t.execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 1 }, + player: TargetFilter::Controller, + }, + )) +} + +/// The Archfiend of Ifnir / Bone Miser shape. +fn engine_card(name: &str) -> CardFace { + let mut f = face(name, CoreType::Creature); + f.triggers = vec![payoff_trigger(TriggerMode::Discarded, None, None)]; + f +} + +fn vanilla(name: &str) -> CardFace { + face(name, CoreType::Creature) +} + +/// `sources` outlets + `engines` payoffs, padded to 36 nonland. +fn deck(sources: u32, engines: u32) -> Vec { + let filler = 36u32.saturating_sub(sources + engines); + vec![ + entry(discard_source("Outlet"), sources), + entry(engine_card("Engine"), engines), + entry(vanilla("Filler"), filler), + ] +} + +#[test] +fn empty_deck_produces_defaults() { + let f = detect(&[]); + assert_eq!(f.source_count, 0); + assert_eq!(f.payoff_count, 0); + assert_eq!(f.commitment, 0.0); +} + +#[test] +fn vanilla_creature_not_registered() { + let f = detect(&[entry(vanilla("Bear"), 4)]); + assert_eq!(f.source_count, 0); + assert_eq!(f.payoff_count, 0); +} + +#[test] +fn detects_rich_discard_effect_source() { + let f = detect(&deck(18, 5)); + assert_eq!(f.source_count, 18); +} + +#[test] +fn detects_simple_discard_card_effect_source() { + // Sibling-variant coverage: `Effect::DiscardCard` is a distinct enum variant + // with its own resolver path, and must count as an enabler too. + let f = detect(&[ + entry(discard_card_source("Simple Outlet"), 18), + entry(engine_card("Engine"), 5), + entry(vanilla("Filler"), 13), + ]); + assert_eq!(f.source_count, 18); + assert!(f.commitment > 0.0); +} + +#[test] +fn detects_discarded_all_payoff() { + let mut e = face("Hand Dumper", CoreType::Creature); + e.triggers = vec![payoff_trigger(TriggerMode::DiscardedAll, None, None)]; + let f = detect(&[ + entry(discard_source("Outlet"), 18), + entry(e, 5), + entry(vanilla("Filler"), 13), + ]); + assert_eq!(f.payoff_count, 5); +} + +#[test] +fn opponent_discard_is_not_a_source() { + // `hand_disruption`'s domain — the axes must never read the same card. + let f = detect(&[ + entry(opponent_discard("Coercion"), 18), + entry(engine_card("Engine"), 5), + entry(vanilla("Filler"), 13), + ]); + assert_eq!(f.source_count, 0); + assert_eq!(f.commitment, 0.0); +} + +#[test] +fn opponent_scoped_payoff_is_not_an_engine() { + // "Whenever an opponent discards" is a punisher for a different deck. + let mut e = face("Punisher", CoreType::Creature); + e.triggers = vec![payoff_trigger( + TriggerMode::Discarded, + None, + Some(TargetFilter::Opponent), + )]; + let f = detect(&[ + entry(discard_source("Outlet"), 18), + entry(e, 5), + entry(vanilla("Filler"), 13), + ]); + assert_eq!(f.payoff_count, 0); + assert_eq!(f.commitment, 0.0); +} + +#[test] +fn self_referential_discard_trigger_is_not_an_engine() { + // Madness / "when this is discarded" fires from the card being pitched, not + // from a battlefield engine — counting it would let a pile of madness cards + // masquerade as an engine base. + let mut e = face("Madness Card", CoreType::Creature); + e.triggers = vec![payoff_trigger( + TriggerMode::Discarded, + Some(TargetFilter::SelfRef), + None, + )]; + let f = detect(&[ + entry(discard_source("Outlet"), 18), + entry(e, 5), + entry(vanilla("Filler"), 13), + ]); + assert_eq!(f.payoff_count, 0); +} + +#[test] +fn cycling_trigger_is_not_counted() { + // Boundary with `cycling_discipline`: `CycledOrDiscarded` is that policy's + // subject; counting it here would double-score one card across two policies. + let mut e = face("Cycler Payoff", CoreType::Creature); + e.triggers = vec![payoff_trigger(TriggerMode::CycledOrDiscarded, None, None)]; + let f = detect(&[ + entry(discard_source("Outlet"), 18), + entry(e, 5), + entry(vanilla("Filler"), 13), + ]); + assert_eq!(f.payoff_count, 0); +} + +#[test] +fn payoff_without_a_resolvable_execute_is_not_an_engine() { + let mut e = face("No Execute", CoreType::Creature); + e.triggers = vec![TriggerDefinition::new(TriggerMode::Discarded)]; + let f = detect(&[ + entry(discard_source("Outlet"), 18), + entry(e, 5), + entry(vanilla("Filler"), 13), + ]); + assert_eq!(f.payoff_count, 0); +} + +#[test] +fn outlets_without_an_engine_collapse_commitment() { + let f = detect(&[ + entry(discard_source("Outlet"), 18), + entry(vanilla("Filler"), 18), + ]); + assert_eq!(f.payoff_count, 0); + assert_eq!(f.commitment, 0.0); +} + +#[test] +fn engine_without_outlets_collapses_commitment() { + let f = detect(&[ + entry(engine_card("Engine"), 5), + entry(vanilla("Filler"), 31), + ]); + assert_eq!(f.source_count, 0); + assert_eq!(f.commitment, 0.0); +} + +#[test] +fn pitch_shell_hits_calibration_anchor() { + // Docstring anchor: 10 outlets + 4 engines over 36 nonland → 0.878. + let f = detect(&deck(10, 4)); + assert!( + (f.commitment - 0.878).abs() < 0.01, + "expected ≈0.878, got {}", + f.commitment + ); + assert!(f.commitment >= DISCARD_MATTERS_FLOOR); +} + +#[test] +fn light_pitch_build_still_clears_the_floor() { + // Docstring anchor: 6 outlets + 2 engines → 0.481, a real but lean plan. + let f = detect(&deck(6, 2)); + assert!( + (f.commitment - 0.481).abs() < 0.01, + "expected ≈0.481, got {}", + f.commitment + ); + assert!(f.commitment >= DISCARD_MATTERS_FLOOR); +} + +#[test] +fn incidental_rummaging_stays_below_floor() { + // Docstring anti-anchor: 2 outlets + 1 engine over 36 nonland → 0.196. + let f = detect(&deck(2, 1)); + assert!( + (f.commitment - 0.196).abs() < 0.01, + "expected ≈0.196, got {}", + f.commitment + ); + assert!( + f.commitment < DISCARD_MATTERS_FLOOR, + "expected below floor, got {}", + f.commitment + ); +} + +#[test] +fn commitment_is_format_size_neutral() { + let sixty = detect(&deck(18, 5)); + let commander = detect(&[ + entry(discard_source("Outlet"), 31), + entry(engine_card("Engine"), 9), + entry(vanilla("Filler"), 23), + ]); + assert!( + (sixty.commitment - commander.commitment).abs() < 0.05, + "{} vs {}", + sixty.commitment, + commander.commitment + ); +} + +#[test] +fn commitment_clamps_to_one() { + let f = detect(&[ + entry(discard_source("Outlet"), 30), + entry(engine_card("Engine"), 10), + ]); + assert!(f.commitment <= 1.0); +} + +#[test] +fn lands_are_excluded_from_the_denominator() { + let with_lands = detect(&[ + entry(discard_source("Outlet"), 18), + entry(engine_card("Engine"), 5), + entry(vanilla("Filler"), 13), + entry(face("Swamp", CoreType::Land), 24), + ]); + let without = detect(&deck(18, 5)); + assert_eq!(with_lands.commitment, without.commitment); +} + +// ─── review #6786: the discard-as-COST path at deck time ──────────────────── + +fn discard_cost(count: i32) -> AbilityCost { + AbilityCost::Discard { + count: QuantityExpr::Fixed { value: count }, + filter: None, + selection: CardSelectionMode::Chosen, + self_scope: DiscardSelfScope::FromHand, + } +} + +/// Wild Mongrel: an activated ability whose COST is the discard. +fn cost_outlet(name: &str, cost: AbilityCost) -> CardFace { + let mut f = face(name, CoreType::Creature); + f.abilities = vec![AbilityDefinition::new( + AbilityKind::Activated, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 1 }, + player: TargetFilter::Controller, + }, + ) + .cost(cost)]; + f +} + +#[test] +fn detects_discard_cost_outlet() { + let f = detect(&[ + entry(cost_outlet("Wild Mongrel", discard_cost(1)), 10), + entry(engine_card("Engine"), 4), + entry(vanilla("Filler"), 22), + ]); + assert_eq!(f.source_count, 10, "a discard COST must count as an outlet"); + assert!(f.commitment >= DISCARD_MATTERS_FLOOR); +} + +#[test] +fn deck_time_counts_a_one_of_discard_cost() { + // Deck-time asks "could this discard?", so an optional branch still marks the + // card for archetype classification — the live seam is the strict one. + let f = detect(&[ + entry( + cost_outlet( + "Optional Outlet", + AbilityCost::OneOf { + costs: vec![ + discard_cost(1), + AbilityCost::PayLife { + amount: QuantityExpr::Fixed { value: 2 }, + }, + ], + }, + ), + 10, + ), + entry(engine_card("Engine"), 4), + entry(vanilla("Filler"), 22), + ]); + assert_eq!(f.source_count, 10); +} + +#[test] +fn zero_count_discard_cost_is_still_a_deck_time_outlet() { + // `DiscardQuantity::Any` at deck time: the count is unknowable when building. + let f = detect(&[ + entry(cost_outlet("Weird", discard_cost(0)), 10), + entry(engine_card("Engine"), 4), + entry(vanilla("Filler"), 22), + ]); + assert_eq!(f.source_count, 10); +} diff --git a/crates/phase-ai/src/features/tests/mod.rs b/crates/phase-ai/src/features/tests/mod.rs index 00b8926771..3ab65d79cf 100644 --- a/crates/phase-ai/src/features/tests/mod.rs +++ b/crates/phase-ai/src/features/tests/mod.rs @@ -5,6 +5,7 @@ pub mod artifacts; pub mod blink; pub mod cost_reduction; pub mod devotion; +pub mod discard_matters; pub mod draw_matters; pub mod enchantments; pub mod energy; diff --git a/crates/phase-ai/src/policies/discard_payoff.rs b/crates/phase-ai/src/policies/discard_payoff.rs new file mode 100644 index 0000000000..0330d1909a --- /dev/null +++ b/crates/phase-ai/src/policies/discard_payoff.rs @@ -0,0 +1,299 @@ +//! `DiscardPayoffPolicy` — makes an on-battlefield "whenever you discard" engine +//! a reason the AI can see to pitch cards WILLINGLY. +//! +//! ## The gap this closes +//! +//! CR 701.9: with Archfiend of Ifnir, Bone Miser, Waste Not or Containment +//! Construct on the battlefield, every card the AI discards is a repeatable +//! value trigger. The AI's default instinct is the opposite one — `card_advantage` +//! scores a card leaving hand as a loss, and nothing credits the trigger it +//! fires. So the AI declines its own engine: it routes around rummaging outlets +//! and treats a discard cost as pure downside even when the discard IS the +//! payoff. This policy adds that positive signal. +//! +//! It is deliberately narrow in one direction: it only ever ADDS value for a +//! discard that is about to fire a live engine. It never encourages discarding +//! without one, because without a payoff the AI's instinct is correct. +//! +//! ## Performance +//! +//! `verdict()` runs per candidate per search node. The card-local check — does +//! this action actually discard the controller a card (its own `CastFacts` +//! primary effects, or the activated ability's effects) — runs FIRST and rejects +//! every non-discard action. Only a confirmed discard pays for the battlefield +//! engine scan (a structural trigger match over each permanent's live +//! `trigger_definitions`), and only in a deck whose `activation` floor is already +//! cleared. No affordability sweep, no `find_legal_targets`. + +use engine::game::triggers::hypothetical_trigger_fireable; +use engine::types::actions::GameAction; +use engine::types::game_state::GameState; +use engine::types::player::PlayerId; + +use crate::features::discard_matters::{ + is_discard_payoff_trigger, is_discard_source_parts, AbilityScope, DiscardQuantity, + DISCARD_MATTERS_FLOOR, +}; +use crate::features::DeckFeatures; + +use super::context::PolicyContext; +use super::registry::{DecisionKind, PolicyId, PolicyReason, PolicyVerdict, TacticalPolicy}; + +pub struct DiscardPayoffPolicy; + +/// Cap on how many simultaneous engines are rewarded, so a stacked board can't +/// push a single discard into the critical band. +/// +/// `pub(crate)` so the bounded-score regression asserts against this constant +/// rather than a copied literal — raising the cap must move the test with it. +pub(crate) const MAX_REWARDED_ENGINES: usize = 3; + +impl TacticalPolicy for DiscardPayoffPolicy { + fn id(&self) -> PolicyId { + PolicyId::DiscardPayoff + } + + fn decision_kinds(&self) -> &'static [DecisionKind] { + &[DecisionKind::CastSpell, DecisionKind::ActivateAbility] + } + + fn activation( + &self, + features: &DeckFeatures, + _state: &GameState, + _player: PlayerId, + ) -> Option { + if features.discard_matters.commitment < DISCARD_MATTERS_FLOOR { + None + } else { + Some(features.discard_matters.commitment) + } + } + + fn verdict(&self, ctx: &PolicyContext<'_>) -> PolicyVerdict { + // Card-local first: does this action actually discard the controller a card? + if !candidate_discards_controller(ctx) { + return PolicyVerdict::neutral(PolicyReason::new("discard_payoff_na")); + } + + // Only now pay for the battlefield scan. A permanent counts only when it + // carries a "whenever you discard" trigger (CR 701.9) that is actually + // LIVE: the engine's `hypothetical_trigger_fireable` authority preflights + // the trigger's constraint AND its execution target legality (CR 603.3d), + // so a rate-limited, off-timing, conditional, or no-legal-target engine is + // not credited value it cannot produce. + let engines = ctx + .state + .battlefield + .iter() + .filter(|id| { + ctx.state.objects.get(id).is_some_and(|obj| { + obj.controller == ctx.ai_player + && obj.trigger_definitions.iter_unchecked().any(|entry| { + is_discard_payoff_trigger(&entry.definition) + && hypothetical_trigger_fireable(ctx.state, obj, entry) + }) + }) + }) + .count(); + if engines == 0 { + return PolicyVerdict::neutral(PolicyReason::new("discard_payoff_no_engine")); + } + + // Each active engine turns this discard into a value trigger — roughly a + // card-equivalent apiece, capped so one discard stays a preference. + let rewarded = engines.min(MAX_REWARDED_ENGINES) as f64; + PolicyVerdict::score( + ctx.config.policy_penalties.discard_payoff_bonus * rewarded, + PolicyReason::new("discard_payoff_engine_active").with_fact("engines", engines as i64), + ) + } +} + +/// CR 701.9: the live-candidate quantity requirement — this discard must resolve +/// to at least one card, or it moves nothing and fires no engine. `source` is the +/// object whose `cost_x_paid` binds an announced `X`, so an un-announced X +/// resolves to zero and the candidate stays neutral. +fn positive_discard_quantity<'a>( + ctx: &PolicyContext<'a>, + source: engine::types::identifiers::ObjectId, +) -> DiscardQuantity<'a> { + DiscardQuantity::ResolvesPositive { + state: ctx.state, + controller: ctx.ai_player, + source, + } +} + +/// Card-local structural test: does this candidate's own AST discard its +/// controller a card, in a quantity that actually moves one? +/// +/// * `CastSpell` → the spell's own resolution chain (`CastFacts::primary_effects`). +/// * `ActivateAbility` → the ability at the runtime-enumerated index, which is +/// where the rummaging outlets live (Anje, Wild Mongrel, Faithless-Looting +/// style activated pitch). +/// +/// CR 700.2: a live candidate is scored before its modes are chosen, so only an +/// UNCONDITIONAL discard counts — a modal "choose one — discard / …" must not be +/// credited a discard here. +fn candidate_discards_controller(ctx: &PolicyContext<'_>) -> bool { + match &ctx.candidate.action { + GameAction::CastSpell { .. } => ctx.cast_facts().is_some_and(|facts| { + is_discard_source_parts( + facts.primary_effects.iter().copied(), + AbilityScope::Unconditional, + &positive_discard_quantity(ctx, facts.object.id), + ) + }), + GameAction::ActivateAbility { source_id, .. } => { + ctx.effective_activated_ability().is_some_and(|ability| { + is_discard_source_parts( + std::iter::once(&ability), + AbilityScope::Unconditional, + &positive_discard_quantity(ctx, *source_id), + ) + }) + } + // CR 601.2 + CR 702.34a: cast-shaped siblings of the plain `CastSpell` + // seam. `PolicyContext::cast_facts` is populated only for the `CastSpell` + // announcement seam, so this policy has no AST to classify for these and + // must report neutral rather than guess. + // + // `CastSpellAsMadness` is listed here deliberately despite belonging to + // this archetype thematically: a madness cast is the payoff the DISCARD + // already earned, not a new discard. Crediting it would double-count one + // event. Listed explicitly, not wildcarded: if `cast_facts` later covers + // one of these, this arm is where the decision to credit it gets made. + GameAction::Foretell { .. } + | GameAction::PlayFaceDown { .. } + | GameAction::ActivateNinjutsu { .. } + | GameAction::CastSpellAsSneak { .. } + | GameAction::CastSpellAsWebSlinging { .. } + | GameAction::CastSpellForFree { .. } + | GameAction::CastSpellAsMiracle { .. } + | GameAction::CastSpellAsMadness { .. } + | GameAction::CastPreparedCopy { .. } + | GameAction::CastParadigmCopy { .. } => false, + // Every remaining action: not a spell cast or ability activation, so it + // cannot discard its controller a card as part of the candidate itself. + // Enumerated rather than wildcarded so a newly added `GameAction` fails + // this match at compile time and forces an intentional classification + // instead of silently bypassing the discard payoff (CR 701.9). + GameAction::PassPriority + | GameAction::ChooseMeldPair { .. } + | GameAction::ChooseEntryAttackTarget { .. } + | GameAction::PlayLand { .. } + | GameAction::DeclareAttackers { .. } + | GameAction::DeclareBlockers { .. } + | GameAction::ChooseUntap { .. } + | GameAction::ChooseExert { .. } + | GameAction::ChooseEnlist { .. } + | GameAction::ChooseClashOpponent { .. } + | GameAction::ChooseZoneOpponentChooser { .. } + | GameAction::ChoosePileOpponent { .. } + | GameAction::ChooseAnnouncingOpponent { .. } + | GameAction::ChooseGiftRecipient { .. } + | GameAction::ChooseAssistPlayer { .. } + | GameAction::CommitAssistPayment { .. } + | GameAction::MulliganDecision { .. } + | GameAction::ReorderHand { .. } + | GameAction::TapLandForMana { .. } + | GameAction::UntapLandForMana { .. } + | GameAction::SpendPoolMana { .. } + | GameAction::UnspendPoolMana { .. } + | GameAction::SelectCards { .. } + | GameAction::ChooseRemoveCounterCostDistribution { .. } + | GameAction::SelectCoinFlips { .. } + | GameAction::ChooseOutsideGameCards { .. } + | GameAction::SelectTargets { .. } + | GameAction::ChooseTarget { .. } + | GameAction::ChooseReplacement { .. } + | GameAction::OrderTriggers { .. } + | GameAction::CancelCast + | GameAction::Equip { .. } + | GameAction::CrewVehicle { .. } + | GameAction::ActivateStation { .. } + | GameAction::SaddleMount { .. } + | GameAction::Transform { .. } + | GameAction::TurnFaceUp { .. } + | GameAction::SubmitSideboard { .. } + | GameAction::ChoosePlayDraw { .. } + | GameAction::ChooseOption { .. } + | GameAction::SubmitVoteCandidate { .. } + | GameAction::SubmitSpellbookDraft { .. } + | GameAction::SubmitPilePartition { .. } + | GameAction::ChoosePile { .. } + | GameAction::ChooseBranch { .. } + | GameAction::SubmitLifeRedistribution { .. } + | GameAction::ChooseDamageSource { .. } + | GameAction::SelectModes { .. } + | GameAction::DecideOptionalCost { .. } + | GameAction::ChooseAdventureFace { .. } + | GameAction::ChooseModalFace { .. } + | GameAction::ChooseAlternativeCast { .. } + | GameAction::ChooseCastingVariant { .. } + | GameAction::KeepAllCopyTargets + | GameAction::ChoosePermanentTypeSlot { .. } + | GameAction::DecideOptionalEffect { .. } + | GameAction::RespondToSpliceOffer { .. } + | GameAction::DecideOptionalEffectAndRemember { .. } + | GameAction::PayUnlessCost { .. } + | GameAction::ChooseUnlessCostBranch { .. } + | GameAction::ChooseActivationCostBranch { .. } + | GameAction::PayCombatTax { .. } + | GameAction::ChooseRingBearer { .. } + | GameAction::ChoosePair { .. } + | GameAction::ChooseDungeon { .. } + | GameAction::ChooseDungeonRoom { .. } + | GameAction::UnlockRoomDoor { .. } + | GameAction::RollPlanarDie + | GameAction::ChooseRoomDoor { .. } + | GameAction::TapForConvoke { .. } + | GameAction::HarmonizeTap { .. } + | GameAction::DeclareCompanion { .. } + | GameAction::CompanionToHand + | GameAction::DiscoverChoice { .. } + | GameAction::GraveyardPaidCastChoice { .. } + | GameAction::CascadeChoice { .. } + | GameAction::RippleChoice { .. } + | GameAction::FreeCastWindowChoice { .. } + | GameAction::ChooseTopOrBottom { .. } + | GameAction::ChooseMutateMergeSide { .. } + | GameAction::CipherEncode { .. } + | GameAction::ChooseLegend { .. } + | GameAction::ChooseBattleProtector { .. } + | GameAction::SetAutoPass { .. } + | GameAction::CancelAutoPass + | GameAction::SetPhaseStops { .. } + | GameAction::SetPriorityPassingMode { .. } + | GameAction::SetPriorityYield { .. } + | GameAction::SetMayTriggerAutoChoice { .. } + | GameAction::SetTriggerOrderTemplate { .. } + | GameAction::AssignCombatDamage { .. } + | GameAction::AssignBlockerDamage { .. } + | GameAction::DistributeAmong { .. } + | GameAction::ChooseCounterMoveDistribution { .. } + | GameAction::ChooseCountersToRemove { .. } + | GameAction::SubmitPayAmount { .. } + | GameAction::RetargetSpell { .. } + | GameAction::LearnDecision { .. } + | GameAction::SelectCategoryPermanents { .. } + | GameAction::ChooseKeptCreatures { .. } + | GameAction::ChooseKeptPermanents { .. } + | GameAction::ChooseX { .. } + | GameAction::SubmitPhyrexianChoices { .. } + | GameAction::ChooseManaColor { .. } + | GameAction::PayManaAbilityMana { .. } + | GameAction::ChooseSpecializeColor { .. } + | GameAction::PassParadigmOffer + | GameAction::Debug(..) + | GameAction::GrantDebugPermission { .. } + | GameAction::RevokeDebugPermission { .. } + | GameAction::Concede { .. } + | GameAction::DeclareShortcut { .. } + | GameAction::RespondToShortcut { .. } + | GameAction::DeclineShortcut + | GameAction::PrecastCopyShortcut { .. } + | GameAction::EndContinuousEffect { .. } => false, + } +} diff --git a/crates/phase-ai/src/policies/mod.rs b/crates/phase-ai/src/policies/mod.rs index ca085f93de..63ed9ce62e 100644 --- a/crates/phase-ai/src/policies/mod.rs +++ b/crates/phase-ai/src/policies/mod.rs @@ -18,6 +18,7 @@ mod cost_reduction; mod crew_timing; mod cycling_discipline; mod devotion; +mod discard_payoff; mod downside_awareness; mod draw_payoff; pub(crate) mod effect_classify; diff --git a/crates/phase-ai/src/policies/registry.rs b/crates/phase-ai/src/policies/registry.rs index cfcdca48f3..375205ccd0 100644 --- a/crates/phase-ai/src/policies/registry.rs +++ b/crates/phase-ai/src/policies/registry.rs @@ -156,6 +156,9 @@ pub enum PolicyId { CostReduction, /// CR 121.1: reward drawing into an on-battlefield "whenever you draw" engine. DrawPayoff, + /// CR 701.9: reward discarding into an on-battlefield "whenever you discard" + /// engine — disjoint from `HandDisruption`, which scores OPPONENT discard. + DiscardPayoff, } /// Coarse routing kind for a candidate decision. Each policy declares which @@ -414,6 +417,7 @@ impl Default for PolicyRegistry { Box::new(super::self_bounce_target::SelfBounceTargetPolicy), Box::new(super::cost_reduction::CostReductionPolicy), Box::new(super::draw_payoff::DrawPayoffPolicy), + Box::new(super::discard_payoff::DiscardPayoffPolicy), ]; let mut by_kind: HashMap> = HashMap::new(); for (idx, policy) in policies.iter().enumerate() { diff --git a/crates/phase-ai/src/policies/tests/discard_payoff.rs b/crates/phase-ai/src/policies/tests/discard_payoff.rs new file mode 100644 index 0000000000..f7ce903d4e --- /dev/null +++ b/crates/phase-ai/src/policies/tests/discard_payoff.rs @@ -0,0 +1,687 @@ +//! Unit tests for `policies::discard_payoff` — CR 701.9 "whenever you discard" +//! payoff policy. No `#[cfg(test)]` in SOURCE files; tests live here. +//! +//! Direct-`verdict` tests cover each branch; a registry-routed regression +//! exercises the production seam (registration + routing). + +use std::sync::Arc; + +use engine::ai_support::{ActionMetadata, AiDecisionContext, CandidateAction, TacticalClass}; +use engine::game::zones::create_object; +use engine::types::ability::{ + AbilityCost, AbilityDefinition, AbilityKind, CardSelectionMode, DiscardSelfScope, Effect, + QuantityExpr, TargetFilter, TriggerDefinition, +}; +use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; +use engine::types::format::FormatConfig; +use engine::types::game_state::{CastPaymentMode, GameState, WaitingFor}; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::player::PlayerId; +use engine::types::triggers::TriggerMode; +use engine::types::zones::Zone; + +use crate::config::AiConfig; +use crate::context::AiContext; +use crate::features::discard_matters::{DiscardMattersFeature, DISCARD_MATTERS_FLOOR}; +use crate::features::DeckFeatures; +use crate::policies::context::{PolicyContext, SearchDepth}; +use crate::policies::discard_payoff::*; +use crate::policies::registry::{ + PolicyId, PolicyReason, PolicyRegistry, PolicyVerdict, TacticalPolicy, +}; +use crate::session::AiSession; + +const AI: PlayerId = PlayerId(0); + +fn state() -> GameState { + GameState::new(FormatConfig::standard(), 2, 42) +} + +/// "Discard N cards" scoped to the caster (the rich `Effect::Discard` form). +fn discard_effect(count: i32, target: TargetFilter) -> Effect { + Effect::Discard { + count: QuantityExpr::Fixed { value: count }, + target, + selection: CardSelectionMode::Chosen, + unless_filter: None, + filter: None, + } +} + +/// A hand spell whose resolution runs `effect`. +fn spell(state: &mut GameState, effect: Effect) -> (ObjectId, CardId) { + let card_id = CardId(state.next_object_id); + let id = create_object(state, card_id, AI, "Pitch Spell".to_string(), Zone::Hand); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Sorcery); + Arc::make_mut(&mut obj.abilities).push(AbilityDefinition::new(AbilityKind::Spell, effect)); + (id, card_id) +} + +/// A battlefield permanent whose activated ability at index 0 runs `effect` — +/// the rummaging-outlet shape (Wild Mongrel / Anje). +fn activated_permanent(state: &mut GameState, effect: Effect) -> ObjectId { + let card_id = CardId(state.next_object_id); + let id = create_object(state, card_id, AI, "Outlet".to_string(), Zone::Battlefield); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + Arc::make_mut(&mut obj.abilities).push(AbilityDefinition::new(AbilityKind::Activated, effect)); + id +} + +/// The Archfiend of Ifnir shape: a no-target on-discard payoff, so target +/// legality never blocks it. +fn discarded_engine_trigger() -> TriggerDefinition { + TriggerDefinition::new(TriggerMode::Discarded).execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 1 }, + player: TargetFilter::Controller, + }, + )) +} + +fn engine_on_battlefield(state: &mut GameState, trigger: Option) { + let card_id = CardId(state.next_object_id); + let id = create_object( + state, + card_id, + AI, + "Archfiend".to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + if let Some(t) = trigger { + obj.trigger_definitions.push(t); + } +} + +fn feature(commitment: f32) -> DiscardMattersFeature { + DiscardMattersFeature { + source_count: 10, + payoff_count: 4, + commitment, + } +} + +fn session(commitment: f32) -> AiSession { + let features = DeckFeatures { + discard_matters: feature(commitment), + ..Default::default() + }; + let mut session = AiSession::empty(); + session.features.insert(AI, features); + session +} + +fn context(config: &AiConfig, session: AiSession) -> AiContext { + let mut context = AiContext::empty(&config.weights); + context.session = Arc::new(session); + context.player = AI; + context +} + +fn cast(object_id: ObjectId, card_id: CardId) -> CandidateAction { + CandidateAction { + action: GameAction::CastSpell { + object_id, + card_id, + targets: Vec::new(), + payment_mode: CastPaymentMode::default(), + }, + metadata: ActionMetadata::for_actor(Some(AI), TacticalClass::Spell), + } +} + +/// The REAL Wild Mongrel / Anje shape: the discard is the ability's COST, not +/// its effect. This is the class the axis exists for, and the effect-only +/// classifier never reached it. +fn discard_cost(count: i32) -> AbilityCost { + AbilityCost::Discard { + count: QuantityExpr::Fixed { value: count }, + filter: None, + selection: CardSelectionMode::Chosen, + self_scope: DiscardSelfScope::FromHand, + } +} + +/// A battlefield permanent whose activated ability PAYS `cost` and does +/// something unrelated (pump) on resolution. +fn cost_paying_permanent(state: &mut GameState, cost: AbilityCost) -> ObjectId { + let card_id = CardId(state.next_object_id); + let id = create_object(state, card_id, AI, "Mongrel".to_string(), Zone::Battlefield); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + let ability = AbilityDefinition::new( + AbilityKind::Activated, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 1 }, + player: TargetFilter::Controller, + }, + ) + .cost(cost); + Arc::make_mut(&mut obj.abilities).push(ability); + id +} + +fn activate(source_id: ObjectId, ability_index: usize) -> CandidateAction { + CandidateAction { + action: GameAction::ActivateAbility { + source_id, + ability_index, + }, + metadata: ActionMetadata::for_actor(Some(AI), TacticalClass::Ability), + } +} + +fn ctx<'a>( + state: &'a GameState, + candidate: &'a CandidateAction, + decision: &'a AiDecisionContext, + context: &'a AiContext, + config: &'a AiConfig, +) -> PolicyContext<'a> { + PolicyContext { + state, + decision, + candidate, + ai_player: AI, + config, + context, + cast_facts: None, + search_depth: SearchDepth::Root, + } +} + +fn priority_decision(candidate: &CandidateAction) -> AiDecisionContext { + AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + } +} + +fn score_of(verdict: PolicyVerdict) -> (f64, PolicyReason) { + match verdict { + PolicyVerdict::Score { delta, reason } => (delta, reason), + PolicyVerdict::Reject { reason } => panic!("unexpected Reject: {reason:?}"), + } +} + +// ─── activation ────────────────────────────────────────────────────────────── + +#[test] +fn activation_opts_out_below_floor() { + let features = DeckFeatures { + discard_matters: feature(DISCARD_MATTERS_FLOOR - 0.01), + ..Default::default() + }; + assert!(DiscardPayoffPolicy + .activation(&features, &state(), AI) + .is_none()); +} + +#[test] +fn activation_opts_in_above_floor() { + let features = DeckFeatures { + discard_matters: feature(0.8), + ..Default::default() + }; + assert_eq!( + DiscardPayoffPolicy.activation(&features, &state(), AI), + Some(0.8) + ); +} + +// ─── verdict ───────────────────────────────────────────────────────────────── + +#[test] +fn discarding_with_an_engine_out_scores_positive() { + let mut st = state(); + engine_on_battlefield(&mut st, Some(discarded_engine_trigger())); + let (obj, card) = spell(&mut st, discard_effect(2, TargetFilter::Controller)); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(obj, card); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(DiscardPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "discard_payoff_engine_active"); + assert!( + delta > 0.0, + "expected a positive payoff credit, got {delta}" + ); +} + +#[test] +fn discarding_without_an_engine_is_neutral() { + // Without a payoff the AI's instinct to avoid discarding is CORRECT, so this + // policy must stay silent rather than push it to pitch cards. + let mut st = state(); + let (obj, card) = spell(&mut st, discard_effect(2, TargetFilter::Controller)); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(obj, card); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(DiscardPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "discard_payoff_no_engine"); + assert_eq!(delta, 0.0); +} + +#[test] +fn opponent_discard_is_not_credited() { + // `hand_disruption`'s subject — making THEM discard fires no engine of mine. + let mut st = state(); + engine_on_battlefield(&mut st, Some(discarded_engine_trigger())); + let (obj, card) = spell(&mut st, discard_effect(2, TargetFilter::Opponent)); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(obj, card); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(DiscardPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "discard_payoff_na"); + assert_eq!(delta, 0.0); +} + +#[test] +fn zero_count_discard_is_not_credited() { + // CR 701.9 + CR 107.1b: a discard that moves no card emits no event. + let mut st = state(); + engine_on_battlefield(&mut st, Some(discarded_engine_trigger())); + let (obj, card) = spell(&mut st, discard_effect(0, TargetFilter::Controller)); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(obj, card); + let decision = priority_decision(&candidate); + let (_, reason) = + score_of(DiscardPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "discard_payoff_na"); +} + +#[test] +fn non_discard_spell_is_not_applicable() { + let mut st = state(); + engine_on_battlefield(&mut st, Some(discarded_engine_trigger())); + let (obj, card) = spell( + &mut st, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 3 }, + player: TargetFilter::Controller, + }, + ); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(obj, card); + let decision = priority_decision(&candidate); + let (_, reason) = + score_of(DiscardPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "discard_payoff_na"); +} + +#[test] +fn activated_rummaging_outlet_is_credited() { + // The Wild Mongrel / Anje shape: the outlet is an activated ability, which + // is where most real self-discard lives. + let mut st = state(); + engine_on_battlefield(&mut st, Some(discarded_engine_trigger())); + let outlet = activated_permanent(&mut st, discard_effect(1, TargetFilter::Controller)); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = activate(outlet, 0); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(DiscardPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "discard_payoff_engine_active"); + assert!(delta > 0.0); +} + +#[test] +fn simple_discard_card_variant_is_credited() { + // Sibling-variant coverage at the LIVE seam: `Effect::DiscardCard` must be + // classified as an enabler exactly like `Effect::Discard`. + let mut st = state(); + engine_on_battlefield(&mut st, Some(discarded_engine_trigger())); + let outlet = activated_permanent( + &mut st, + Effect::DiscardCard { + count: 1, + target: TargetFilter::Controller, + }, + ); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = activate(outlet, 0); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(DiscardPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "discard_payoff_engine_active"); + assert!(delta > 0.0); +} + +#[test] +fn name_only_impostor_is_not_an_engine() { + // A permanent with no live discard trigger must not be credited, even though + // it sits on the battlefield under our control. + let mut st = state(); + engine_on_battlefield(&mut st, None); + let (obj, card) = spell(&mut st, discard_effect(1, TargetFilter::Controller)); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(obj, card); + let decision = priority_decision(&candidate); + let (_, reason) = + score_of(DiscardPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "discard_payoff_no_engine"); +} + +#[test] +fn credit_is_bounded_by_the_engine_cap() { + let mut st = state(); + for _ in 0..8 { + engine_on_battlefield(&mut st, Some(discarded_engine_trigger())); + } + let (obj, card) = spell(&mut st, discard_effect(1, TargetFilter::Controller)); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(obj, card); + let decision = priority_decision(&candidate); + let (delta, _) = + score_of(DiscardPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + let ceiling = config.policy_penalties.discard_payoff_bonus * MAX_REWARDED_ENGINES as f64; + assert!( + delta <= ceiling + f64::EPSILON, + "delta {delta} exceeded ceiling {ceiling}" + ); +} + +// ─── production seam ───────────────────────────────────────────────────────── + +#[test] +fn registry_routes_cast_spell_to_this_policy() { + let mut st = state(); + engine_on_battlefield(&mut st, Some(discarded_engine_trigger())); + let (obj, card) = spell(&mut st, discard_effect(1, TargetFilter::Controller)); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(obj, card); + let decision = priority_decision(&candidate); + let verdicts = + PolicyRegistry::default().verdicts(&ctx(&st, &candidate, &decision, &context, &config)); + + let found = verdicts + .iter() + .find(|(id, _)| *id == PolicyId::DiscardPayoff) + .map(|(_, v)| v.clone()) + .expect("DiscardPayoffPolicy must be registered and routed for CastSpell"); + let (delta, reason) = score_of(found); + assert_eq!(reason.kind, "discard_payoff_engine_active"); + assert!(delta > 0.0); +} + +#[test] +fn registry_stays_silent_below_the_activation_floor() { + let mut st = state(); + engine_on_battlefield(&mut st, Some(discarded_engine_trigger())); + let (obj, card) = spell(&mut st, discard_effect(1, TargetFilter::Controller)); + + let config = AiConfig::default(); + let context = context(&config, session(DISCARD_MATTERS_FLOOR - 0.01)); + let candidate = cast(obj, card); + let decision = priority_decision(&candidate); + let verdicts = + PolicyRegistry::default().verdicts(&ctx(&st, &candidate, &decision, &context, &config)); + + assert!( + !verdicts + .iter() + .any(|(id, _)| *id == PolicyId::DiscardPayoff), + "policy must not contribute below its activation floor" + ); +} + +// ─── review #6786: the discard-as-COST path (the real rummaging class) ─────── + +#[test] +fn activated_discard_cost_outlet_is_credited() { + // The blocker this PR was returned for: Wild Mongrel pays `AbilityCost:: + // Discard`, so the effect-only classifier scored it neutral even with a live + // engine out. Fails without the cost-axis classification. + let mut st = state(); + engine_on_battlefield(&mut st, Some(discarded_engine_trigger())); + let outlet = cost_paying_permanent(&mut st, discard_cost(1)); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = activate(outlet, 0); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(DiscardPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "discard_payoff_engine_active"); + assert!(delta > 0.0, "a discard COST must be credited, got {delta}"); +} + +#[test] +fn composite_cost_containing_a_discard_is_credited() { + // CR 601.2h: every component of a composite cost is paid, so the discard is + // guaranteed — tap AND discard. + let mut st = state(); + engine_on_battlefield(&mut st, Some(discarded_engine_trigger())); + let outlet = cost_paying_permanent( + &mut st, + AbilityCost::Composite { + costs: vec![AbilityCost::Tap, discard_cost(1)], + }, + ); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = activate(outlet, 0); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(DiscardPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "discard_payoff_engine_active"); + assert!( + delta > 0.0, + "a guaranteed composite discard must earn positive credit, got {delta}" + ); +} + +#[test] +fn one_of_cost_is_not_credited_at_the_live_seam() { + // CR 118.12a: only one branch is chosen. "Discard a card OR pay 2 life" is a + // discard the DECK can plan around, but not one this candidate is committed + // to — crediting it would score a discard the player may never make. + let mut st = state(); + engine_on_battlefield(&mut st, Some(discarded_engine_trigger())); + let outlet = cost_paying_permanent( + &mut st, + AbilityCost::OneOf { + costs: vec![ + discard_cost(1), + AbilityCost::PayLife { + amount: QuantityExpr::Fixed { value: 2 }, + }, + ], + }, + ); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = activate(outlet, 0); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(DiscardPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "discard_payoff_na"); + assert_eq!(delta, 0.0); +} + +#[test] +fn one_of_cost_with_only_discard_branches_is_credited_at_the_live_seam() { + // CR 118.12a: only one branch is paid, but this ability discards no matter + // which branch is selected. It is therefore a guaranteed live discard, + // unlike the mixed discard-or-life sibling above. + let mut st = state(); + engine_on_battlefield(&mut st, Some(discarded_engine_trigger())); + let outlet = cost_paying_permanent( + &mut st, + AbilityCost::OneOf { + costs: vec![discard_cost(1), discard_cost(2)], + }, + ); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = activate(outlet, 0); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(DiscardPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "discard_payoff_engine_active"); + assert!(delta > 0.0, "expected positive credit, got {delta}"); +} + +#[test] +fn zero_count_discard_cost_is_not_credited() { + let mut st = state(); + engine_on_battlefield(&mut st, Some(discarded_engine_trigger())); + let outlet = cost_paying_permanent(&mut st, discard_cost(0)); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = activate(outlet, 0); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(DiscardPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "discard_payoff_na"); + assert_eq!(delta, 0.0); +} + +#[test] +fn discard_cost_without_an_engine_is_neutral() { + let mut st = state(); + let outlet = cost_paying_permanent(&mut st, discard_cost(1)); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = activate(outlet, 0); + let decision = priority_decision(&candidate); + let (_, reason) = + score_of(DiscardPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "discard_payoff_no_engine"); +} + +#[test] +fn composite_wrapping_a_mixed_one_of_is_not_credited() { + // CR 601.2h + CR 118.12a: the composite is fully paid, but its discard sits + // inside a mixed `OneOf`, so the player can settle the cost without ever + // discarding. Recursion must carry the "not guaranteed" answer up through + // the composite rather than treating any nested discard as certain. + let mut st = state(); + engine_on_battlefield(&mut st, Some(discarded_engine_trigger())); + let outlet = cost_paying_permanent( + &mut st, + AbilityCost::Composite { + costs: vec![ + AbilityCost::Tap, + AbilityCost::OneOf { + costs: vec![ + discard_cost(1), + AbilityCost::PayLife { + amount: QuantityExpr::Fixed { value: 2 }, + }, + ], + }, + ], + }, + ); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = activate(outlet, 0); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(DiscardPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "discard_payoff_na"); + assert_eq!(delta, 0.0); +} + +#[test] +fn composite_wrapping_an_all_discard_one_of_is_credited() { + // The positive twin: nesting must not lose a guaranteed discard either. + // Every branch of the inner `OneOf` discards, so the composite does too. + let mut st = state(); + engine_on_battlefield(&mut st, Some(discarded_engine_trigger())); + let outlet = cost_paying_permanent( + &mut st, + AbilityCost::Composite { + costs: vec![ + AbilityCost::Tap, + AbilityCost::OneOf { + costs: vec![discard_cost(1), discard_cost(2)], + }, + ], + }, + ); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = activate(outlet, 0); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(DiscardPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "discard_payoff_engine_active"); + assert!(delta > 0.0); +} + +#[test] +fn empty_one_of_cost_is_not_credited() { + // Behavioral assertion, NOT a guard on `cost_discards`'s `!costs.is_empty()` + // precondition: an empty `OneOf` never reaches that walk, because the engine's + // `cost_categories()` gate reports no `Discards` category for a branch list + // with nothing in it. Verified by mutation — deleting the emptiness check + // leaves this green. It is kept because the OUTCOME is worth pinning (a cost + // that pays nothing must not be credited a discard) at whichever layer + // enforces it, but it must not be counted as covering that precondition. + let mut st = state(); + engine_on_battlefield(&mut st, Some(discarded_engine_trigger())); + let outlet = cost_paying_permanent(&mut st, AbilityCost::OneOf { costs: Vec::new() }); + + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = activate(outlet, 0); + let decision = priority_decision(&candidate); + let (delta, reason) = + score_of(DiscardPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + + assert_eq!(reason.kind, "discard_payoff_na"); + assert_eq!(delta, 0.0); +} diff --git a/crates/phase-ai/src/policies/tests/mod.rs b/crates/phase-ai/src/policies/tests/mod.rs index 64b74f2e9d..90515eceea 100644 --- a/crates/phase-ai/src/policies/tests/mod.rs +++ b/crates/phase-ai/src/policies/tests/mod.rs @@ -5,6 +5,7 @@ pub mod artifact_synergy; pub mod blink_payoff; pub mod cost_reduction; pub mod devotion; +pub mod discard_payoff; pub mod draw_payoff; pub mod effect_classify_snapshot; pub mod enchantments_payoff; From a8766823cd952ef280807ebb6e8af90b83d73588 Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:05:13 -0700 Subject: [PATCH 13/63] fix(client): authorize attachment interaction fan (#6778) * fix(client): authorize attachment interaction fan * fix(interaction): preserve viewer projection compatibility * test(client): include attachment fan projection * test(interaction): use attachment fan projection * fix(interaction): publish direct attachment submissions * test(interaction): cover attachment fan submissions * fix(interaction): bound direct fan submissions * test(interaction): scope attachment fan picks * fix(interaction): expose attachment ids as wire scalars * fix(interaction): preserve numeric attachment fan ids --------- Co-authored-by: matthewevans --- .../__tests__/server-draft-adapter.test.ts | 1 + client/src/adapter/engine-worker-client.ts | 8 + client/src/adapter/engine-worker.ts | 16 ++ .../adapter/generated/interaction/index.ts | 8 +- client/src/adapter/p2p-adapter.ts | 87 +++++++ client/src/adapter/server-draft-adapter.ts | 25 ++ client/src/adapter/types.ts | 9 +- client/src/adapter/wasm-adapter.ts | 22 ++ client/src/adapter/ws-adapter.ts | 22 ++ client/src/components/board/AttachmentFan.tsx | 186 ++++---------- client/src/components/board/PermanentCard.tsx | 36 +-- .../board/__tests__/PermanentCard.test.tsx | 145 +++++++++-- client/src/game/dispatch.ts | 28 +++ client/src/network/__tests__/protocol.test.ts | 6 +- client/src/network/protocol.ts | 6 +- .../GamePage.projectedManaChoices.test.ts | 1 + client/src/wasm/engine_wasm.d.ts | 2 + crates/engine-wasm/src/lib.rs | 28 ++- crates/engine/src/bin/interaction_bindings.rs | 31 +-- crates/engine/src/game/interaction.rs | 234 +++++++++++++++++- crates/engine/src/types/interaction.rs | 38 +++ .../tests/integration/interaction_contract.rs | 70 +++++- 22 files changed, 784 insertions(+), 225 deletions(-) diff --git a/client/src/adapter/__tests__/server-draft-adapter.test.ts b/client/src/adapter/__tests__/server-draft-adapter.test.ts index d99cc5901d..f3dc81039c 100644 --- a/client/src/adapter/__tests__/server-draft-adapter.test.ts +++ b/client/src/adapter/__tests__/server-draft-adapter.test.ts @@ -109,6 +109,7 @@ const viewerInteraction = { canSubmit: true, autoPassRecommended: false, opportunities: [], + attachmentFans: {}, availability: { type: "inputRequired" }, } as LegalActionsResult["viewerInteraction"]; diff --git a/client/src/adapter/engine-worker-client.ts b/client/src/adapter/engine-worker-client.ts index cc7cc7effc..c1bc4c0db4 100644 --- a/client/src/adapter/engine-worker-client.ts +++ b/client/src/adapter/engine-worker-client.ts @@ -16,6 +16,7 @@ import type { ViewerSnapshot, } from "./types"; import { AdapterError, AdapterErrorCode } from "./types"; +import type { InteractionSubmission } from "./generated/interaction"; import type { BracketDeckRequest, BracketEstimate } from "../types/bracketEstimate"; import { debugLog } from "../game/debugLog"; import { notifyEngineSlow } from "../game/engineRecovery"; @@ -238,6 +239,13 @@ export class EngineWorkerClient { ); } + async submitInteraction(actor: number, submission: InteractionSubmission): Promise { + return this.request( + { type: "submitInteraction", actor, submission }, + ENGINE_REQUEST_TIMEOUT_MS, + ); + } + async previewManaPayment(actor: number, action: GameAction): Promise { return this.request( { type: "previewManaPayment", actor, action }, diff --git a/client/src/adapter/engine-worker.ts b/client/src/adapter/engine-worker.ts index 609595bd24..4baf8591d2 100644 --- a/client/src/adapter/engine-worker.ts +++ b/client/src/adapter/engine-worker.ts @@ -9,6 +9,7 @@ import init, { take_last_panic_message, initialize_game, submit_action, + submit_interaction_js, get_game_state, get_filtered_game_state, get_ai_action, @@ -44,6 +45,7 @@ import init, { } from "@wasm/engine"; import type { GameAction } from "./types"; +import type { InteractionSubmission } from "./generated/interaction"; import type { BracketDeckRequest } from "../types/bracketEstimate"; // ── Message Protocol ───────────────────────────────────────────────────── @@ -62,6 +64,7 @@ type EngineRequest = firstPlayer?: number; } | { type: "submitAction"; id: number; actor: number; action: GameAction } + | { type: "submitInteraction"; id: number; actor: number; submission: InteractionSubmission } | { type: "previewManaPayment"; id: number; actor: number; action: GameAction } | { type: "getState"; id: number } | { type: "getFilteredState"; id: number; viewerId: number } @@ -283,6 +286,19 @@ self.onmessage = async (e: MessageEvent) => { break; } + case "submitInteraction": { + const actionResult = submit_interaction_js(msg.actor, msg.submission); + if (typeof actionResult === "string") { + error(msg.id, actionResult); + break; + } + result(msg.id, { + events: actionResult.events ?? [], + log_entries: actionResult.log_entries ?? [], + }); + break; + } + case "previewManaPayment": { const sources = preview_mana_payment_js(msg.actor, msg.action); if (typeof sources === "string") { diff --git a/client/src/adapter/generated/interaction/index.ts b/client/src/adapter/generated/interaction/index.ts index d836708684..0e71b51b46 100644 --- a/client/src/adapter/generated/interaction/index.ts +++ b/client/src/adapter/generated/interaction/index.ts @@ -11,6 +11,8 @@ export type InteractionActionId = string & { readonly __brand: "InteractionActio export type PreviewRequestId = string & { readonly __brand: "PreviewRequestId" }; +export type InteractionObjectReference = string & { readonly __brand: "InteractionObjectReference" }; + export type InteractionSlotKind = "single" | "mulligan" | "openingBottom"; export type ActiveInteractionSlot = { semanticOwner: number, slotKind: InteractionSlotKind, interactionId: InteractionId, }; @@ -99,9 +101,13 @@ export type InteractionProgress = { selected: number, minimum: number, maximum: export type InteractionOpportunity = { interactionId: InteractionId, response: InteractionOpportunityResponse, surfaces: Array, progress: InteractionProgress, }; +export type InteractionAttachmentFanChild = { objectId: number, submission: InteractionSubmission, }; + +export type InteractionAttachmentFan = { hostId: number, children: Array, }; + export type InteractionAvailability = { "type": "progressAvailable", "data": { witness: InteractionSubmission, } } | { "type": "inputRequired" } | { "type": "escapeOnly", "data": { reason: InteractionReasonCode, } } | { "type": "waiting" } | { "type": "terminal", "data": { outcome: InteractionOutcomeCode, } } | { "type": "unsupported", "data": { reason: InteractionReasonCode, } } | { "type": "stuck", "data": { reason: InteractionReasonCode, } }; -export type ViewerInteraction = { waitingForKind: InteractionWaitingForKind, authorizedSubmitters: Array, canSubmit: boolean, autoPassRecommended: boolean, opportunities: Array, availability: InteractionAvailability, }; +export type ViewerInteraction = { waitingForKind: InteractionWaitingForKind, authorizedSubmitters: Array, canSubmit: boolean, autoPassRecommended: boolean, opportunities: Array, attachmentFans: Record, availability: InteractionAvailability, }; export type AmountAssignment = { choiceId: InteractionChoiceId, amount: number, }; diff --git a/client/src/adapter/p2p-adapter.ts b/client/src/adapter/p2p-adapter.ts index 6817a5fc4a..c87d8cfcda 100644 --- a/client/src/adapter/p2p-adapter.ts +++ b/client/src/adapter/p2p-adapter.ts @@ -17,6 +17,7 @@ import type { SubmitResult, WaitingFor, } from "./types"; +import type { InteractionSubmission } from "./generated/interaction"; import type { BracketDeckRequest, BracketEstimate } from "../types/bracketEstimate"; import { AdapterError, AdapterErrorCode, EMPTY_LEGAL_ACTIONS, actionRejectionError, nextSnapshotSeq } from "./types"; @@ -260,6 +261,13 @@ class NativeP2PBridge { return this.clientFor(playerId).submitAction(action, playerId); } + async submitInteraction( + submission: InteractionSubmission, + playerId: PlayerId, + ): Promise { + return this.clientFor(playerId).submitInteraction(submission, playerId); + } + async previewManaPayment(action: GameAction, playerId: PlayerId): Promise { return this.clientFor(playerId).previewManaPayment(action, playerId); } @@ -1534,6 +1542,26 @@ export class P2PHostAdapter implements EngineAdapter { return result; } + async submitInteraction( + submission: InteractionSubmission, + actor: PlayerId, + ): Promise { + if (this.gameRunState !== "running") { + throw new AdapterError( + "P2P_PAUSED", + `Cannot submit interaction while game state is ${this.gameRunState}`, + true, + ); + } + const result = this.nativeBridge + ? await this.nativeBridge.submitInteraction(submission, actor) + : await this.wasm.submitInteraction(submission, actor); + await this.broadcastStateUpdate(result.events, result.log_entries); + await this.runAiLoop(); + this.persistAuthoritativeState(); + return result; + } + async previewManaPayment(action: GameAction, actor: PlayerId): Promise { if (this.gameRunState !== "running") { throw new AdapterError( @@ -1818,6 +1846,44 @@ export class P2PHostAdapter implements EngineAdapter { } break; } + case "interaction": { + const session = this.guestSessions.get(pid); + if (!session || msg.senderPlayerId !== pid) { + if (session) session.send({ type: "action_rejected", reason: "senderPlayerId mismatch" }); + return; + } + if (this.eliminatedSeats.has(pid) || this.gameRunState !== "running") { + session.send({ + type: "action_rejected", + reason: this.eliminatedSeats.has(pid) + ? "Player has conceded and can no longer act" + : `Game ${this.gameRunState}`, + }); + return; + } + try { + const result = this.nativeBridge + ? await this.nativeBridge.submitInteraction(msg.submission, pid) + : await this.wasm.submitInteraction(msg.submission, pid); + await this.broadcastStateUpdate(result.events, result.log_entries); + await this.runAiLoop(); + this.persistAuthoritativeState(); + if (!this.nativeBridge) { + this.emit({ + type: "stateChanged", + snapshot: await this.wasm.getSnapshot(), + events: result.events, + logEntries: result.log_entries, + }); + } + } catch (err) { + session.send({ + type: "action_rejected", + reason: err instanceof Error ? err.message : String(err), + }); + } + break; + } case "preview_mana_payment": { const session = this.guestSessions.get(pid); if (!session) return; @@ -2316,6 +2382,27 @@ export class P2PGuestAdapter implements EngineAdapter { }); } + async submitInteraction( + submission: InteractionSubmission, + _actor: PlayerId, + ): Promise { + if (!this.session) { + throw new AdapterError("P2P_ERROR", "Not connected to host", true); + } + if (this.assignedPlayerId === null) { + throw new AdapterError("P2P_ERROR", "Not yet assigned a player ID", true); + } + return new Promise((resolve, reject) => { + this.pendingResolve = resolve; + this.pendingReject = reject; + this.session!.send({ + type: "interaction", + senderPlayerId: this.assignedPlayerId!, + submission, + }); + }); + } + async previewManaPayment(action: GameAction, _actor: PlayerId): Promise { if (!this.session) { throw new AdapterError("P2P_ERROR", "Not connected to host", true); diff --git a/client/src/adapter/server-draft-adapter.ts b/client/src/adapter/server-draft-adapter.ts index 70f7bacb4f..f836eb57a5 100644 --- a/client/src/adapter/server-draft-adapter.ts +++ b/client/src/adapter/server-draft-adapter.ts @@ -12,6 +12,7 @@ import type { PlayerId, SubmitResult, } from "./types"; +import type { InteractionSubmission } from "./generated/interaction"; import { actionRejectionError, AdapterError, AdapterErrorCode, EMPTY_LEGAL_ACTIONS, nextSnapshotSeq } from "./types"; import type { BracketDeckRequest, BracketEstimate } from "../types/bracketEstimate"; import { @@ -199,6 +200,30 @@ export class ServerDraftAdapter implements EngineAdapter { }); } + async submitInteraction( + submission: InteractionSubmission, + _actor: PlayerId, + ): Promise { + if (this.phase !== "match") { + throw new AdapterError("PHASE_ERROR", "Not in a match phase", false); + } + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new AdapterError("WS_ERROR", "WebSocket not connected", false); + } + + this.emit({ type: "actionPendingChanged", pending: true }); + return new Promise((resolve, reject) => { + this.pendingResolve = resolve; + this.pendingReject = reject; + if (!this.send({ type: "Interaction", data: { submission } })) { + this.pendingResolve = null; + this.pendingReject = null; + this.emit({ type: "actionPendingChanged", pending: false }); + reject(new AdapterError("WS_CLOSED", "Failed to send interaction", true)); + } + }); + } + async previewManaPayment(action: GameAction, _actor: PlayerId): Promise { if (this.phase !== "match") { throw new AdapterError("PHASE_ERROR", "Not in a match phase", false); diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 8f3e8704a4..795556b0f9 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1,5 +1,9 @@ import type { BracketDeckRequest, BracketEstimate } from "../types/bracketEstimate"; -import type { InteractionActionId, ViewerInteraction } from "./generated/interaction"; +import type { + InteractionActionId, + InteractionSubmission, + ViewerInteraction, +} from "./generated/interaction"; // ── Identifiers ────────────────────────────────────────────────────────── @@ -3233,6 +3237,7 @@ export const AdapterErrorCode = { * original dispatch. */ ENGINE_UNRESPONSIVE: "ENGINE_UNRESPONSIVE", + UNSUPPORTED: "UNSUPPORTED", WASM_ERROR: "WASM_ERROR", INVALID_ACTION: "INVALID_ACTION", DECK_REJECTED: "DECK_REJECTED", @@ -3518,6 +3523,8 @@ export interface EngineAdapter { * action payload or the UI state. */ submitAction(action: GameAction, actor: PlayerId): Promise; + /** Submit an opaque response from the engine's current interaction projection. */ + submitInteraction?(submission: InteractionSubmission, actor: PlayerId): Promise; /** * Read-only preview of the exact automatic `CastSpell` action currently * offered by the engine. Unsupported transports omit this capability. diff --git a/client/src/adapter/wasm-adapter.ts b/client/src/adapter/wasm-adapter.ts index b6eeddeabf..c264fa432f 100644 --- a/client/src/adapter/wasm-adapter.ts +++ b/client/src/adapter/wasm-adapter.ts @@ -14,6 +14,7 @@ import type { ViewerSnapshot, WaitingFor, } from "./types"; +import type { InteractionSubmission } from "./generated/interaction"; import { AdapterError, AdapterErrorCode, isStaleRejectionMessage, isStateLostMessage, nextSnapshotSeq } from "./types"; import type { BracketDeckRequest, BracketEstimate } from "../types/bracketEstimate"; import { isBracketEstimate } from "../types/bracketEstimate"; @@ -278,6 +279,19 @@ export class WasmAdapter implements EngineAdapter { } } + async submitInteraction( + submission: InteractionSubmission, + actor: PlayerId, + ): Promise { + this.assertInitialized(); + try { + if (this.engine) return await this.engine.submitInteraction(actor, submission); + return await this.fallback!.submitInteraction(submission, actor); + } catch (err) { + throw await classifyEngineErrorAsync(err, this.takePanic); + } + } + async previewManaPayment(action: GameAction, actor: PlayerId): Promise { this.assertInitialized(); try { @@ -851,6 +865,7 @@ export class WasmAdapter implements EngineAdapter { interface MainThreadFallback { ensureCardDatabase(): Promise; submitAction(action: GameAction, actor: PlayerId): Promise; + submitInteraction(submission: InteractionSubmission, actor: PlayerId): Promise; previewManaPayment(action: GameAction, actor: PlayerId): Promise; getState(): Promise; getFilteredState(viewerId: number): Promise; @@ -908,6 +923,13 @@ async function createMainThreadFallback(): Promise { return { events: r.events ?? [], log_entries: r.log_entries ?? [] }; }), + submitInteraction: (submission: InteractionSubmission, actor: PlayerId) => + enqueue(() => { + const r = wasm.submit_interaction_js(actor, submission); + if (typeof r === "string") throw new Error(r); + return { events: r.events ?? [], log_entries: r.log_entries ?? [] }; + }), + previewManaPayment: (action: GameAction, actor: PlayerId) => enqueue(() => { const sources = wasm.preview_mana_payment_js(actor, action); diff --git a/client/src/adapter/ws-adapter.ts b/client/src/adapter/ws-adapter.ts index 97a304be35..b8bfc30db9 100644 --- a/client/src/adapter/ws-adapter.ts +++ b/client/src/adapter/ws-adapter.ts @@ -14,6 +14,7 @@ import type { SubmitResult, FormatConfig, } from "./types"; +import type { InteractionSubmission } from "./generated/interaction"; import { AdapterError, AdapterErrorCode, EMPTY_LEGAL_ACTIONS, actionRejectionError, nextSnapshotSeq } from "./types"; import type { BracketDeckRequest, BracketEstimate } from "../types/bracketEstimate"; import { @@ -637,6 +638,27 @@ export class WebSocketAdapter implements EngineAdapter { }); } + async submitInteraction( + submission: InteractionSubmission, + _actor: PlayerId, + ): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new AdapterError("WS_ERROR", "WebSocket not connected", false); + } + + this.emit({ type: "actionPendingChanged", pending: true }); + return new Promise((resolve, reject) => { + this.pendingResolve = resolve; + this.pendingReject = reject; + if (!this.send({ type: "Interaction", data: { submission } })) { + this.pendingResolve = null; + this.pendingReject = null; + this.emit({ type: "actionPendingChanged", pending: false }); + reject(new AdapterError("WS_CLOSED", "Failed to send interaction", true)); + } + }); + } + async previewManaPayment(action: GameAction, _actor: PlayerId): Promise { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { throw new AdapterError("WS_ERROR", "WebSocket not connected", false); diff --git a/client/src/components/board/AttachmentFan.tsx b/client/src/components/board/AttachmentFan.tsx index ed328461f8..7e6a03d671 100644 --- a/client/src/components/board/AttachmentFan.tsx +++ b/client/src/components/board/AttachmentFan.tsx @@ -3,20 +3,12 @@ import { createPortal } from "react-dom"; import { motion } from "framer-motion"; import { useTranslation } from "react-i18next"; -import type { GameAction, ObjectId } from "../../adapter/types.ts"; -import { dispatchAction } from "../../game/dispatch.ts"; -import { usePlayerId } from "../../hooks/usePlayerId.ts"; +import type { ObjectId } from "../../adapter/types.ts"; +import { dispatchInteraction } from "../../game/dispatch.ts"; import { cardImageLookup, tokenFiltersForObject } from "../../services/cardImageLookup.ts"; +import { useAppNotificationStore } from "../../stores/appToastStore.ts"; import { useGameStore } from "../../stores/gameStore.ts"; import { useUiStore } from "../../stores/uiStore.ts"; -import { collectObjectActions } from "../../viewmodel/cardActionChoice.ts"; -import { - boardChoiceMaxSelection, - buildBoardChoiceAction, - canConfirmBoardChoice, - getBoardChoiceView, - isBoardChoiceImmediate, -} from "../../viewmodel/gameStateView.ts"; import { CardImage } from "../card/CardImage.tsx"; import { fanGeometry, spreadFactor } from "../card/fanGeometry.ts"; @@ -49,19 +41,6 @@ function fanCardSizingStyle(cardCount: number): CSSProperties { } as CSSProperties; } -/** - * Per-object selection state for one card in the fan, derived once by the - * parent from the live prompt so each card knows exactly which engine action - * (if any) its click should dispatch. Target > board-choice > activation is - * the same precedence PermanentCard uses on the battlefield. - */ -interface CardChoice { - isTarget: boolean; - boardEligible: boolean; - isSelected: boolean; - activationActions: GameAction[]; -} - /** * Centered spread of a host permanent plus every permanent attached to it * (Aura / Equipment / Fortification), fanned out at HAND size using the shared @@ -75,50 +54,42 @@ interface CardChoice { * * The fan NEVER invents a choice — each card lights up (cyan) and dispatches * only what the engine's live prompt actually offers for that object. Terminal - * picks (a target, an immediate board-choice, an activation) close the fan; - * a multi-select board-choice toggles and is finished via the Confirm button. + * One-step picks close the fan. Multi-step decisions stay in their dedicated + * engine-authored interaction surfaces instead of asking this display to build + * a response payload. * Direct clicking on the battlefield still works — this fan is an opt-in * convenience opened from the "⧉" badge, not a forced modal. */ export function AttachmentFan() { const { t } = useTranslation("game"); - const playerId = usePlayerId(); const hostId = useUiStore((s) => s.attachmentFanHostId); const setAttachmentFanHost = useUiStore((s) => s.setAttachmentFanHost); const dismissPreview = useUiStore((s) => s.dismissPreview); - const setPendingAbilityChoice = useUiStore((s) => s.setPendingAbilityChoice); - const toggleSelectedCard = useUiStore((s) => s.toggleSelectedCard); - const selectedCardIds = useUiStore((s) => s.selectedCardIds); + const showNotification = useAppNotificationStore((s) => s.showNotification); const objects = useGameStore((s) => s.gameState?.objects); - const waitingFor = useGameStore((s) => s.waitingFor); - const legalActionsByObject = useGameStore((s) => s.legalActionsByObject); - + const viewerInteraction = useGameStore((s) => s.viewerInteraction); const host = hostId != null ? objects?.[hostId] : undefined; + const interactionFan = useMemo( + () => + hostId == null + ? null + : (viewerInteraction?.attachmentFans[hostId] ?? null), + [hostId, viewerInteraction], + ); - const cardIds = host ? [host.id, ...host.attachments] : []; - - const boardChoice = useMemo(() => { - const choice = getBoardChoiceView(waitingFor, objects); - return choice && choice.player === playerId ? choice : null; - }, [waitingFor, objects, playerId]); - - // Engine's live target legal-set for the current prompt, as a plain id set. - const targetIds = useMemo(() => { - const set = new Set(); - if ( - (waitingFor?.type === "TargetSelection" || waitingFor?.type === "TriggerTargetSelection") - && waitingFor.data.player === playerId - ) { - for (const target of waitingFor.data.selection?.current_legal_targets ?? []) { - if ("Object" in target) set.add(target.Object); - } - } - if (waitingFor?.type === "EquipTarget" && waitingFor.data.player === playerId) { - for (const id of waitingFor.data.valid_targets) set.add(id); - } - return set; - }, [waitingFor, playerId]); + // During an interaction, the engine projection is the sole authority for + // which direct attachments belong in the fan. The fallback preserves the + // existing read-only badge outside an interaction, where no choice is being + // exposed and therefore no interaction capability exists to consume. + const cardIds = host + ? [ + host.id, + ...(interactionFan + ? interactionFan.children.map((child) => child.objectId) + : host.attachments), + ] + : []; const close = useCallback(() => { setAttachmentFanHost(null); @@ -137,60 +108,20 @@ export function AttachmentFan() { return () => window.removeEventListener("keydown", onKey); }, [hostId, close]); - const choiceFor = useCallback( - (id: ObjectId): CardChoice => ({ - isTarget: targetIds.has(id), - boardEligible: boardChoice?.objectIds.includes(id) ?? false, - isSelected: selectedCardIds.includes(id), - activationActions: legalActionsByObject ? collectObjectActions(legalActionsByObject, id) : [], - }), - [targetIds, boardChoice, selectedCardIds, legalActionsByObject], - ); - const handlePick = useCallback( - (id: ObjectId, choice: CardChoice) => { - if (choice.isTarget) { - dispatchAction({ type: "ChooseTarget", data: { target: { Object: id } } }); - close(); - return; - } - if (choice.boardEligible && boardChoice) { - if (isBoardChoiceImmediate(boardChoice)) { - dispatchAction(buildBoardChoiceAction(boardChoice, [id])); - close(); - return; - } - // Multi-select: toggle within the engine's max, then Confirm finishes. - const max = boardChoiceMaxSelection(boardChoice); - const selectedForChoice = selectedCardIds.filter((s) => boardChoice.objectIds.includes(s)); - if (choice.isSelected || max == null || selectedForChoice.length < max) { - toggleSelectedCard(id); - } - return; - } - if (choice.activationActions.length > 0) { - if (choice.activationActions.length === 1) { - dispatchAction(choice.activationActions[0]); - } else { - setPendingAbilityChoice({ objectId: id, actions: choice.activationActions }); - } - close(); - } + (id: ObjectId) => { + const child = interactionFan?.children.find((candidate) => candidate.objectId === id); + if (!child || !viewerInteraction?.canSubmit) return; + void dispatchInteraction(child.submission).then(close).catch((error: unknown) => { + showNotification({ + title: t("actionError.title", { action: t("permanent.fanPick") }), + description: error instanceof Error ? error.message : t("actionError.unknownEngineError"), + }); + }); }, - [boardChoice, selectedCardIds, close, toggleSelectedCard, setPendingAbilityChoice], + [close, interactionFan, showNotification, t, viewerInteraction?.canSubmit], ); - const confirmSelection = useMemo(() => { - if (!boardChoice || isBoardChoiceImmediate(boardChoice)) return null; - const selectedForChoice = selectedCardIds.filter((s) => boardChoice.objectIds.includes(s)); - if (selectedForChoice.length === 0) return null; - return { - enabled: canConfirmBoardChoice(boardChoice, selectedForChoice, objects), - selected: selectedForChoice, - choice: boardChoice, - }; - }, [boardChoice, selectedCardIds, objects]); - if (hostId == null || !host || cardIds.length === 0) return null; // Shared compact whole-row fan — sized by the total card count so the host + @@ -220,30 +151,16 @@ export function AttachmentFan() { ))} - {confirmSelection && ( - - )} , document.body, ); @@ -251,20 +168,20 @@ export function AttachmentFan() { function FanCard({ objectId, - choice, marginLeft, rotation, arcOffset, zIndex, + selectable, onPick, }: { objectId: ObjectId; - choice: CardChoice; marginLeft: string | number; rotation: number; arcOffset: number; zIndex: number; - onPick: (id: ObjectId, choice: CardChoice) => void; + selectable: boolean; + onPick: (id: ObjectId) => void; }) { const { t } = useTranslation("game"); const obj = useGameStore((s) => s.gameState?.objects[objectId]); @@ -272,17 +189,9 @@ function FanCard({ const lookup = cardImageLookup(obj); const isToken = obj.display_source === "Token"; - const selectable = choice.isTarget || choice.boardEligible || choice.activationActions.length > 0; - // The whole fan speaks one "pick me" color — cyan — so a spread of a host and - // its attachments reads as a single chooser regardless of whether the engine - // is asking for a target, a board choice, or an activation. Selected (a - // toggled multi-select board choice) brightens and adds a check. - const ring = choice.isSelected - ? "ring-4 ring-cyan-300 shadow-[0_0_22px_7px_rgba(34,211,238,0.7),inset_0_0_18px_5px_rgba(34,211,238,0.35)]" - : selectable - ? "ring-2 ring-cyan-400 shadow-[0_0_16px_5px_rgba(34,211,238,0.55)]" - : ""; + // its attachments reads as one direct engine-authorized chooser. + const ring = selectable ? "ring-2 ring-cyan-400 shadow-[0_0_16px_5px_rgba(34,211,238,0.55)]" : ""; // Mirror the hand card's resting animation (arc + tilt) and hover lift so the // attachment fan feels identical to picking a card out of hand. @@ -295,7 +204,7 @@ function FanCard({ transition={{ duration: 0.2 }} onClick={(e) => { e.stopPropagation(); - if (selectable) onPick(objectId, choice); + if (selectable) onPick(objectId); }} aria-label={obj.name} className={`relative leading-[0] select-none ${selectable ? "cursor-pointer" : "cursor-default"}`} @@ -316,12 +225,7 @@ function FanCard({ className="!w-[var(--fan-card-w)] !h-[var(--fan-card-h)]" /> - {choice.isSelected && ( - - ✓ - - )} - {selectable && !choice.isSelected && ( + {selectable && ( {t("permanent.fanPick")} diff --git a/client/src/components/board/PermanentCard.tsx b/client/src/components/board/PermanentCard.tsx index f1009f32a4..ff48188d4d 100644 --- a/client/src/components/board/PermanentCard.tsx +++ b/client/src/components/board/PermanentCard.tsx @@ -342,7 +342,6 @@ export const PermanentCard = memo(function PermanentCard({ incomingAttackerCounts, manaTappableObjectIds, selectableManaCostCreatureIds, - selectableSacrificeObjectIds, undoableTapObjectIds, validAttackerIds, validTargetObjectIds, @@ -464,6 +463,12 @@ export const PermanentCard = memo(function PermanentCard({ const controllerIdentity = useGameStore( (s) => obj && s.gameState?.players?.find((p) => p.id === obj.controller)?.commander_color_identity, ); + const viewerInteraction = useGameStore((s) => s.viewerInteraction); + const interactionAttachmentFan = useMemo( + () => + viewerInteraction?.attachmentFans[objectId] ?? null, + [objectId, viewerInteraction], + ); const showAttachmentFan = useCallback(() => { dismissPreview(); @@ -490,31 +495,10 @@ export const PermanentCard = memo(function PermanentCard({ const ptDisplay = computePTDisplay(obj); const isSelected = selectedObjectId === objectId; - // CR 301.5 / CR 303.4: An attached Equipment/Aura is an independent permanent - // that can be a valid target, an activation source (re-equip), or a board - // choice in its own right. Collapsed behind its host it is unreachable — - // clicks land on the host instead, so a "put a counter on target nonland - // permanent you control" trigger lands on the creature rather than the chosen - // Equipment, and an attached Equipment can't be re-activated to move it. Open - // a host's attachments whenever any of them is actionable in the current - // waiting state so each is independently clickable without requiring a hover. - const attachmentsActionable = - obj.attachments.length > 0 - && obj.attachments.some( - (id) => - validTargetObjectIds.has(id) - || activatableObjectIds.has(id) - || manaTappableObjectIds.has(id) - || boardChoiceObjectIds.has(id) - || selectableSacrificeObjectIds.has(id) - || selectableManaCostCreatureIds.has(id) - // An attachment tapped for mana that can still be untapped (undo) is - // itself actionable — keep it expanded so the undo affordance stays - // clickable. `undoableTapObjectIds` is already gated upstream - // (GameBoard `undoLegal`) to the states whose engine match arms accept - // the untap, so no extra state check is needed here. - || undoableTapObjectIds.has(id), - ); + // The viewer-scoped engine projection owns both the direct-attachment + // relationship and whether one is actionable for this interaction. The + // board must not rediscover either fact from the raw snapshot. + const attachmentsActionable = interactionAttachmentFan !== null; const attachmentsLifted = obj.attachments.length > 0 && (attachmentsLiftedByAncestor || isInHoveredAttachmentTree); diff --git a/client/src/components/board/__tests__/PermanentCard.test.tsx b/client/src/components/board/__tests__/PermanentCard.test.tsx index b67c260f0e..7c8d3f6038 100644 --- a/client/src/components/board/__tests__/PermanentCard.test.tsx +++ b/client/src/components/board/__tests__/PermanentCard.test.tsx @@ -2,7 +2,12 @@ import { act, cleanup, fireEvent, render, screen } from "@testing-library/react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GameAction, GameObject, GameState } from "../../../adapter/types.ts"; -import { dispatchAction } from "../../../game/dispatch.ts"; +import type { + InteractionChoiceId, + InteractionId, + ViewerInteraction, +} from "../../../adapter/generated/interaction"; +import { dispatchAction, dispatchInteraction } from "../../../game/dispatch.ts"; import { useGameStore } from "../../../stores/gameStore.ts"; import { usePreferencesStore } from "../../../stores/preferencesStore.ts"; import { useUiStore } from "../../../stores/uiStore.ts"; @@ -22,6 +27,7 @@ import { PermanentCard } from "../PermanentCard.tsx"; vi.mock("../../../game/dispatch.ts", () => ({ dispatchAction: vi.fn(), + dispatchInteraction: vi.fn(), })); vi.mock("../../card/CardImage.tsx", () => ({ @@ -139,6 +145,49 @@ function renderPermanent( ); } +function interactionForAttachedObjects(objectIds: number[]): ViewerInteraction { + const interactionId = "attachment-interaction" as InteractionId; + const choiceId = (objectId: number) => `attachment-${objectId}` as InteractionChoiceId; + return { + waitingForKind: { simultaneous: null, terminal: false, code: "choose" }, + authorizedSubmitters: [0], + canSubmit: true, + autoPassRecommended: false, + opportunities: [{ + interactionId, + response: { + type: "exactChoices", + data: { + choices: objectIds.map((objectId) => ({ + id: choiceId(objectId), + status: { type: "available" }, + surfaces: [], + })), + }, + }, + surfaces: [], + progress: { selected: 0, minimum: 1, maximum: 1, aggregate: null, confirmable: false }, + }], + attachmentFans: { + 1: { + hostId: 1, + children: objectIds.map((objectId) => ({ + objectId, + submission: { + interactionId, + response: { type: "choose", data: { choiceId: choiceId(objectId) } }, + }, + })), + }, + }, + availability: { type: "inputRequired" }, + }; +} + +function interactionForAttachedObject(objectId: number): ViewerInteraction { + return interactionForAttachedObjects([objectId]); +} + describe("PermanentCard", () => { beforeEach(() => { window.matchMedia = ((query: string) => ({ @@ -176,6 +225,7 @@ describe("PermanentCard", () => { tapRotation: "classic", }); vi.mocked(dispatchAction).mockClear(); + vi.mocked(dispatchInteraction).mockResolvedValue(); }); afterEach(() => { @@ -521,11 +571,15 @@ describe("PermanentCard", () => { gameState.objects[1].attachments = [2, 4]; gameState.objects[4] = secondEquipment; gameState.battlefield = [1, 2, 3, 4]; - useGameStore.setState({ gameState, waitingFor: gameState.waiting_for }); + useGameStore.setState({ + gameState, + waitingFor: gameState.waiting_for, + viewerInteraction: interactionForAttachedObject(4), + }); // Attachment 4 is a valid target — both attachments must render even though // the host is neither hovered nor inspected. - const { container } = renderPermanent(new Set([4])); + const { container } = renderPermanent(); expect(container.querySelector('[data-object-id="2"]')).not.toBeNull(); expect(container.querySelector('[data-object-id="4"]')).not.toBeNull(); @@ -564,10 +618,14 @@ describe("PermanentCard", () => { }, }); gameState.waiting_for = waitingFor; - useGameStore.setState({ gameState, waitingFor }); + useGameStore.setState({ + gameState, + waitingFor, + viewerInteraction: interactionForAttachedObject(4), + }); useUiStore.setState({ attachmentFanHostId: null }); - const { container } = renderPermanent(new Set([4])); + const { container } = renderPermanent(); render(); fireEvent.click(container.querySelector('[data-object-id="1"]') as HTMLElement); @@ -579,9 +637,53 @@ describe("PermanentCard", () => { fireEvent.click(darksteelCard); - expect(dispatchAction).toHaveBeenCalledWith({ - type: "ChooseTarget", - data: { target: { Object: 4 } }, + expect(dispatchInteraction).toHaveBeenCalledWith({ + interactionId: "attachment-interaction", + response: { + type: "choose", + data: { choiceId: "attachment-4" }, + }, + }); + }); + + it("submits each attachment's engine-authored response independently", () => { + const secondEquipment = makeObject({ + id: 4, + card_id: 400, + attached_to: { type: "Object", data: 1 }, + attachments: [], + name: "Second Equipment", + power: null, + toughness: null, + base_power: null, + base_toughness: null, + card_types: { supertypes: [], core_types: ["Artifact"], subtypes: ["Equipment"] }, + color: [], + base_color: [], + }); + const gameState = makeState(); + gameState.objects[1].attachments = [2, 4]; + gameState.objects[4] = secondEquipment; + gameState.battlefield = [1, 2, 3, 4]; + useGameStore.setState({ + gameState, + waitingFor: gameState.waiting_for, + viewerInteraction: interactionForAttachedObjects([2, 4]), + }); + useUiStore.setState({ attachmentFanHostId: 1 }); + render(); + + const fan = document.querySelector("[data-attachment-fan]") as HTMLElement; + fireEvent.click(fan.querySelector('[aria-label="Test Equipment"]') as HTMLElement); + expect(dispatchInteraction).toHaveBeenCalledWith({ + interactionId: "attachment-interaction", + response: { type: "choose", data: { choiceId: "attachment-2" } }, + }); + + fireEvent.click(fan.querySelector('[aria-label="Second Equipment"]') as HTMLElement); + expect(dispatchInteraction).toHaveBeenCalledWith({ + interactionId: "attachment-interaction", + response: { type: "choose", data: { choiceId: "attachment-4" } }, }); }); @@ -606,14 +708,13 @@ describe("PermanentCard", () => { gameState.objects[1].attachments = [2, 4]; gameState.objects[4] = secondEquipment; gameState.battlefield = [1, 2, 3, 4]; - useGameStore.setState({ gameState, waitingFor: gameState.waiting_for }); + useGameStore.setState({ + gameState, + waitingFor: gameState.waiting_for, + viewerInteraction: interactionForAttachedObject(4), + }); - const { container } = renderPermanent( - new Set(), - new Set(), - new Set(), - new Set([4]), - ); + const { container } = renderPermanent(); expect(container.querySelector('[data-object-id="2"]')).not.toBeNull(); expect(container.querySelector('[data-object-id="4"]')).not.toBeNull(); @@ -640,15 +741,13 @@ describe("PermanentCard", () => { gameState.objects[1].attachments = [2, 4]; gameState.objects[4] = secondEquipment; gameState.battlefield = [1, 2, 3, 4]; - useGameStore.setState({ gameState, waitingFor: gameState.waiting_for }); + useGameStore.setState({ + gameState, + waitingFor: gameState.waiting_for, + viewerInteraction: interactionForAttachedObject(4), + }); - const { container } = renderPermanent( - new Set(), - new Set(), - new Set(), - new Set(), - new Set([4]), - ); + const { container } = renderPermanent(); expect(container.querySelector('[data-object-id="2"]')).not.toBeNull(); expect(container.querySelector('[data-object-id="4"]')).not.toBeNull(); diff --git a/client/src/game/dispatch.ts b/client/src/game/dispatch.ts index 4760e0215d..fc247a4106 100644 --- a/client/src/game/dispatch.ts +++ b/client/src/game/dispatch.ts @@ -1,4 +1,5 @@ import type { BatchResolveResult, EngineAdapter, EngineSnapshot, GameAction, GameEvent, GameLogEntry, GameState, WaitingFor } from "../adapter/types"; +import type { InteractionSubmission } from "../adapter/generated/interaction"; import { AdapterError, AdapterErrorCode } from "../adapter/types"; import { attemptStateRehydrate, isEnginePanic, notifyEngineLost, routePanic } from "./engineRecovery"; import { normalizeEvents } from "../animation/eventNormalizer"; @@ -707,6 +708,33 @@ export function dispatchAction( return dispatchActionInternal(action, actor, null); } +/** + * Submit an engine-authored interaction response through the same adapter and + * atomic snapshot boundary used by ordinary game actions. The response is + * opaque: UI callers cannot materialize or reinterpret a GameAction. + */ +export async function dispatchInteraction( + submission: InteractionSubmission, + actor: number = getPlayerId(), +): Promise { + const { adapter, gameState, gameMode } = useGameStore.getState(); + if (!adapter || !gameState || gameMode === "spectate" || actor === SPECTATOR_PLAYER_ID) return; + if (!adapter.submitInteraction) { + throw new AdapterError( + AdapterErrorCode.UNSUPPORTED, + "This game connection does not support interaction responses", + false, + ); + } + + const result = await adapter.submitInteraction(submission, actor); + const snapshot = await adapter.getSnapshot(); + useGameStore.getState().commitEngineSnapshot(snapshot, { + events: result.events, + logEntries: result.log_entries ?? [], + }); +} + /** Dispatch a standing preference only while its captured game lifecycle is * still current. A late response from a disposed or resumed session is dropped * before snapshot fetch/commit, so it cannot overwrite the replacement game. */ diff --git a/client/src/network/__tests__/protocol.test.ts b/client/src/network/__tests__/protocol.test.ts index be50a70c0f..8ba5036ed3 100644 --- a/client/src/network/__tests__/protocol.test.ts +++ b/client/src/network/__tests__/protocol.test.ts @@ -36,11 +36,11 @@ const viewerInteractionWithProducedMana = { } as never; describe("encodeWireMessage / decodeWireMessage", () => { - it("pins the P2P wire protocol to v15", () => { - expect(WIRE_PROTOCOL_VERSION).toBe(15); + it("pins the P2P wire protocol to v16", () => { + expect(WIRE_PROTOCOL_VERSION).toBe(16); }); - it("defaults shortcut actions for a v15 payload created before the additive field", () => { + it("defaults shortcut actions for a legacy payload created before the additive field", () => { expect(legalActionsFromWire({ legalActions: [] }).manaPaymentShortcutActions).toEqual([]); }); diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 6ac53d31dc..46808bdaae 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -9,7 +9,7 @@ import type { ObjectId, ObjectAction, } from "../adapter/types"; -import type { ViewerInteraction } from "../adapter/generated/interaction"; +import type { InteractionSubmission, ViewerInteraction } from "../adapter/generated/interaction"; import type { SeatMutation, SeatView } from "../multiplayer/seatTypes"; /** @@ -85,7 +85,7 @@ export function legalActionsFromWire(wire: LegalActionsWire): LegalActionsResult * sub-phase on WaitingFor::MulliganDecision; the MulliganBottomCards * variant was removed */ -export const WIRE_PROTOCOL_VERSION = 15 as const; +export const WIRE_PROTOCOL_VERSION = 16 as const; export type P2PMessage = | { type: "guest_deck"; deckData: unknown; displayName?: string; reservationToken?: string } @@ -99,6 +99,7 @@ export type P2PMessage = playerNames?: Record; } & LegalActionsWire) | { type: "action"; senderPlayerId: number; action: GameAction } + | { type: "interaction"; senderPlayerId: number; submission: InteractionSubmission } | { type: "preview_mana_payment"; requestId: number; action: GameAction } | ({ type: "state_update"; @@ -156,6 +157,7 @@ const VALID_TYPES = new Set([ "guest_deck", "game_setup", "action", + "interaction", "preview_mana_payment", "state_update", "action_rejected", diff --git a/client/src/pages/__tests__/GamePage.projectedManaChoices.test.ts b/client/src/pages/__tests__/GamePage.projectedManaChoices.test.ts index e455b7abdc..083fd60d78 100644 --- a/client/src/pages/__tests__/GamePage.projectedManaChoices.test.ts +++ b/client/src/pages/__tests__/GamePage.projectedManaChoices.test.ts @@ -65,6 +65,7 @@ function interactionWith(choices: InteractionChoice[]): ViewerInteraction { }, }, ], + attachmentFans: {}, availability: { type: "inputRequired" }, }; } diff --git a/client/src/wasm/engine_wasm.d.ts b/client/src/wasm/engine_wasm.d.ts index 4d49960352..4f85978853 100644 --- a/client/src/wasm/engine_wasm.d.ts +++ b/client/src/wasm/engine_wasm.d.ts @@ -456,6 +456,7 @@ export function signatureSpellSelectionPolicy(request: any): any; * applying the action as another player. */ export function submit_action(actor: number, action: any): any; +export function submit_interaction_js(actor: number, submission: any): any; /** * Drain the last captured panic message (consuming it). Returns `null` when @@ -515,6 +516,7 @@ export interface InitOutput { readonly sideboardPolicyForFormat: (a: any) => [number, number, number]; readonly signatureSpellSelectionPolicy: (a: any) => [number, number, number]; readonly submit_action: (a: number, b: any) => any; + readonly submit_interaction_js: (a: number, b: any) => any; readonly take_last_panic_message: () => [number, number]; readonly get_game_state: () => any; readonly get_legal_actions_js: () => any; diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index 4e16b153c2..653b540381 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -16,7 +16,7 @@ use engine::game::engine::{ apply, apply_for_simulation, resolve_all_fast_forward, ResolveAllCallbackDecision, ResolveAllFastForwardResult as BatchResolveResult, }; -use engine::game::interaction::bind_interaction_authority; +use engine::game::interaction::{bind_interaction_authority, submit_interaction}; use engine::game::preview::{compute_preview_diff, preview_auto_payment_sources}; use engine::game::{ can_pair_commanders, companion_candidates, deck_copy_limit_for, estimate_bracket, @@ -30,7 +30,7 @@ use engine::game::{ use engine::types::format::{DeckCopyLimit, FormatConfig, GameFormat}; use engine::types::game_state::{PersistedGameState, TrustedGameStateEnvelope, WaitingFor}; use engine::types::identifiers::ObjectId; -use engine::types::interaction::InteractionSessionId; +use engine::types::interaction::{InteractionSessionId, InteractionSubmission}; use engine::types::mana::ManaCost; use engine::types::match_config::MatchConfig; use engine::types::{GameAction, GameState, PlayerId, ReplayHeader, ReplayLog}; @@ -1027,6 +1027,30 @@ pub fn submit_action(actor: u8, action: JsValue) -> JsValue { } } +/// Submit one opaque, engine-authored interaction response. The browser never +/// materializes a `GameAction`; only a successful engine reducer result exposes +/// the exact action to the replay recorder. +#[wasm_bindgen] +pub fn submit_interaction_js(actor: u8, submission: JsValue) -> JsValue { + let submission: InteractionSubmission = match serde_wasm_bindgen::from_value(submission) { + Ok(submission) => submission, + Err(error) => { + return JsValue::from_str(&format!( + "Engine error: failed to deserialize interaction submission: {error}" + )); + } + }; + let actor = PlayerId(actor); + match with_state_mut(|state| submit_interaction(state, actor, submission)) { + Ok(Ok(applied)) => { + record_replay_action(false, actor, applied.action); + to_js(&applied.result) + } + Ok(Err(error)) => JsValue::from_str(&format!("Engine error: {:?}", error.code)), + Err(error) => error, + } +} + /// Record a successfully-applied action into REPLAY_LOG, or invalidate any /// in-progress recording if it was a (non-CreateCard) debug action. /// diff --git a/crates/engine/src/bin/interaction_bindings.rs b/crates/engine/src/bin/interaction_bindings.rs index ca2e100e6f..7cf60696bc 100644 --- a/crates/engine/src/bin/interaction_bindings.rs +++ b/crates/engine/src/bin/interaction_bindings.rs @@ -2,20 +2,20 @@ use std::path::{Path, PathBuf}; use engine::types::interaction::{ ActiveInteractionSlot, AggregateComparator, AmountAssignment, ConfirmSemantics, - InteractionActionCode, InteractionAggregateFunction, InteractionAvailability, - InteractionChoice, InteractionChoiceStatus, InteractionDamageAssignmentMode, - InteractionGroupConstraint, InteractionIntentCode, InteractionManaAbilityActivationScope, - InteractionManaColor, InteractionManaComparator, InteractionManaRestriction, - InteractionManaSpecialAction, InteractionManaSpellCostCriterion, - InteractionManaZoneSpendPolarity, InteractionObjectProperty, InteractionOpportunity, - InteractionOpportunityResponse, InteractionOutcomeCode, InteractionPresentationSurface, - InteractionPreview, InteractionPreviewRequest, InteractionPreviewStatus, InteractionProgress, - InteractionReasonCode, InteractionRelation, InteractionRelationConstraint, - InteractionRelationSourceConstraint, InteractionResponse, InteractionResponseSpec, - InteractionRoleCode, InteractionShortcutCountSpec, InteractionShortcutDecision, - InteractionShortcutPin, InteractionShortcutPoint, InteractionShortcutPointKind, - InteractionShortcutReply, InteractionShortcutResponseCode, InteractionSlotKind, - InteractionSubmission, InteractionSummaryCode, InteractionWaitingForCode, + InteractionActionCode, InteractionAggregateFunction, InteractionAttachmentFan, + InteractionAttachmentFanChild, InteractionAvailability, InteractionChoice, + InteractionChoiceStatus, InteractionDamageAssignmentMode, InteractionGroupConstraint, + InteractionIntentCode, InteractionManaAbilityActivationScope, InteractionManaColor, + InteractionManaComparator, InteractionManaRestriction, InteractionManaSpecialAction, + InteractionManaSpellCostCriterion, InteractionManaZoneSpendPolarity, InteractionObjectProperty, + InteractionOpportunity, InteractionOpportunityResponse, InteractionOutcomeCode, + InteractionPresentationSurface, InteractionPreview, InteractionPreviewRequest, + InteractionPreviewStatus, InteractionProgress, InteractionReasonCode, InteractionRelation, + InteractionRelationConstraint, InteractionRelationSourceConstraint, InteractionResponse, + InteractionResponseSpec, InteractionRoleCode, InteractionShortcutCountSpec, + InteractionShortcutDecision, InteractionShortcutPin, InteractionShortcutPoint, + InteractionShortcutPointKind, InteractionShortcutReply, InteractionShortcutResponseCode, + InteractionSlotKind, InteractionSubmission, InteractionSummaryCode, InteractionWaitingForCode, InteractionWaitingForKind, InteractionZoneCode, SelectionConstraint, SimultaneousDecisionKind, ViewerInteraction, }; @@ -41,6 +41,7 @@ fn expected_bindings() -> String { "InteractionChoiceId", "InteractionActionId", "PreviewRequestId", + "InteractionObjectReference", ] { output.push_str(&format!( "export type {name} = string & {{ readonly __brand: \"{name}\" }};\n\n" @@ -97,6 +98,8 @@ fn expected_bindings() -> String { InteractionOpportunityResponse, InteractionProgress, InteractionOpportunity, + InteractionAttachmentFanChild, + InteractionAttachmentFan, InteractionAvailability, ViewerInteraction, AmountAssignment, diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index d04c96feaa..68a1a2b932 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -1,8 +1,7 @@ //! Hidden engine-authority interaction projection and submission boundary. //! -//! No production transport calls this module yet. The existing human action UI -//! remains the only exposed authority until the separately reviewed adapter -//! cutover. Engine tests may use these entry points to prove the contract. +//! Production adapters consume this projection while the existing action UI +//! remains the exposed authority until its separately reviewed cutover. use std::collections::{BTreeMap, HashMap, HashSet}; @@ -38,7 +37,8 @@ use crate::types::identifiers::ObjectId; use crate::types::interaction::{ ActiveInteractionSlot, AggregateComparator, AmountAssignment, ConfirmSemantics, InteractionActionCode, InteractionActionId, InteractionAggregateFunction, - InteractionAvailability, InteractionChoice, InteractionChoiceId, InteractionChoiceStatus, + InteractionAttachmentFan, InteractionAttachmentFanChild, InteractionAvailability, + InteractionChoice, InteractionChoiceId, InteractionChoiceStatus, InteractionDamageAssignmentMode, InteractionGroupConstraint, InteractionId, InteractionIntentCode, InteractionManaAbilityActivationScope, InteractionManaColor, InteractionManaComparator, InteractionManaRestriction, InteractionManaSpecialAction, @@ -67,7 +67,7 @@ use super::dungeon::DungeonId; use super::engine::{ apply_interaction, apply_interaction_for_simulation, EngineError, MAX_SHORTCUT_CYCLES, }; -use super::game_object::RoomDoor; +use super::game_object::{AttachTarget, RoomDoor}; use super::merge::MergeSide; use super::{mana_sources, turn_control, visibility}; @@ -7182,6 +7182,7 @@ pub fn derive_viewer_interaction( can_submit: false, auto_pass_recommended: false, opportunities: Vec::new(), + attachment_fans: BTreeMap::new(), availability: InteractionAvailability::Terminal { outcome: InteractionOutcomeCode::Terminal, }, @@ -7197,6 +7198,7 @@ pub fn derive_viewer_interaction( can_submit: false, auto_pass_recommended: false, opportunities: Vec::new(), + attachment_fans: BTreeMap::new(), availability: InteractionAvailability::Waiting, }; } @@ -7214,6 +7216,7 @@ pub fn derive_viewer_interaction( can_submit: true, auto_pass_recommended: false, opportunities: Vec::new(), + attachment_fans: BTreeMap::new(), availability: InteractionAvailability::Unsupported { reason: InteractionReasonCode::AuthorityUnbound, }, @@ -7229,6 +7232,7 @@ pub fn derive_viewer_interaction( can_submit: true, auto_pass_recommended: false, opportunities: Vec::new(), + attachment_fans: BTreeMap::new(), availability: InteractionAvailability::Unsupported { reason: InteractionReasonCode::InvalidAuthorityState, }, @@ -7255,12 +7259,14 @@ pub fn derive_viewer_interaction( can_submit: true, auto_pass_recommended: false, opportunities: Vec::new(), + attachment_fans: BTreeMap::new(), availability: InteractionAvailability::Unsupported { reason: InteractionReasonCode::PayloadTooLarge, }, }; } let mut opportunities = Vec::with_capacity(slots.len()); + let mut attachment_fans = BTreeMap::new(); let mut first_progress = None; let mut first_fallback = None; let default_availability = InteractionAvailability::Stuck { @@ -7269,9 +7275,22 @@ pub fn derive_viewer_interaction( for slot in slots { let (mut opportunity, mut slot_availability) = opportunity_for_slot(authoritative_state, filtered_state, viewer, slot); - if bound_outbound_opportunity(&opportunity).is_err() { + let opportunity_is_bounded = bound_outbound_opportunity(&opportunity).is_ok(); + if !opportunity_is_bounded { (opportunity, slot_availability) = payload_too_large_opportunity(&slot.interaction_id); } + if opportunity_is_bounded + && !matches!( + slot_availability, + InteractionAvailability::Unsupported { .. } + ) + { + attachment_fans.extend(attachment_fans_for_slot( + authoritative_state, + filtered_state, + slot, + )); + } if matches!( slot_availability, InteractionAvailability::ProgressAvailable { .. } @@ -7299,10 +7318,12 @@ pub fn derive_viewer_interaction( WaitingFor::Priority { .. } ) && authoritative_state.auto_pass.contains_key(&viewer), opportunities, + attachment_fans, availability, }; if bound_outbound_view(&view).is_err() { view.opportunities.clear(); + view.attachment_fans.clear(); view.availability = InteractionAvailability::Unsupported { reason: InteractionReasonCode::PayloadTooLarge, }; @@ -7310,11 +7331,193 @@ pub fn derive_viewer_interaction( view } +/// Build attachment affordances from typed decision provenance. The host +/// back-link and child forward-link must agree; this avoids surfacing stale +/// relationship data or indirect descendants. +fn attachment_fans_for_slot( + authoritative_state: &GameState, + filtered_state: &GameState, + slot: &ActiveInteractionSlot, +) -> BTreeMap { + let semantic_owner = PlayerId(slot.semantic_owner); + let model = human_response_model(&filtered_state.waiting_for, semantic_owner); + let object_choices = match model { + HumanResponseModel::TargetSequence => { + target_sequence_projection(&filtered_state.waiting_for) + .ok() + .flatten() + .into_iter() + .flat_map(|projection| { + projection + .candidates + .into_iter() + .enumerate() + .filter_map(|(index, target)| match target { + TargetRef::Object(object_id) => Some(( + object_id, + interaction_choice_id(&slot.interaction_id, 't', index), + )), + TargetRef::Player(_) => None, + }) + .collect::>() + }) + .collect::>() + } + HumanResponseModel::Select => { + selection_projection(&filtered_state.waiting_for, filtered_state, semantic_owner) + .ok() + .flatten() + .into_iter() + .flat_map(|projection| { + projection + .object_ids + .into_iter() + .enumerate() + .map(|(index, object_id)| { + ( + object_id, + interaction_choice_id(&slot.interaction_id, 's', index), + ) + }) + .collect::>() + }) + .collect::>() + } + HumanResponseModel::ExactCandidates(AuditedExactCandidates) => { + actor_candidates(authoritative_state, semantic_owner) + .unwrap_or_default() + .into_iter() + .enumerate() + .filter_map(|(index, candidate)| { + candidate.action.source_object().map(|object_id| { + ( + object_id, + interaction_choice_id(&slot.interaction_id, 'c', index), + ) + }) + }) + .collect() + } + HumanResponseModel::Terminal + | HumanResponseModel::AssignAmounts + | HumanResponseModel::AmountAssignments + | HumanResponseModel::DamageAssignments + | HumanResponseModel::TriggerOrder + | HumanResponseModel::CoinFlipSequence + | HumanResponseModel::CategorySelection + | HumanResponseModel::CombatRelations(_) + | HumanResponseModel::ManaGroups(_) + | HumanResponseModel::ModeSequence + | HumanResponseModel::OutsideSelection + | HumanResponseModel::TextChoice + | HumanResponseModel::ShortcutReply + | HumanResponseModel::DirectChoices + | HumanResponseModel::SideboardPartition + | HumanResponseModel::NumberRange(_) + | HumanResponseModel::LoopShortcut => Vec::new(), + }; + attachment_fans_for_object_choices(filtered_state, &slot.interaction_id, model, object_choices) +} + +fn attachment_fans_for_object_choices( + filtered_state: &GameState, + interaction_id: &InteractionId, + model: HumanResponseModel, + object_choices: impl IntoIterator, +) -> BTreeMap { + let mut fans: BTreeMap< + (InteractionId, ObjectId), + BTreeMap>, + > = BTreeMap::new(); + + for (child_id, choice_id) in object_choices { + let Some(child) = filtered_state.objects.get(&child_id) else { + continue; + }; + let Some(AttachTarget::Object(host_id)) = child.attached_to else { + continue; + }; + let Some(host) = filtered_state.objects.get(&host_id) else { + continue; + }; + if !host.attachments.contains(&child_id) { + continue; + } + let choice_ids = fans + .entry((interaction_id.clone(), host_id)) + .or_default() + .entry(child_id) + .or_default(); + if !choice_ids.contains(&choice_id) { + choice_ids.push(choice_id); + } + } + + fans.into_iter() + .filter_map(|((interaction_id, host_id), children)| { + let children = children + .into_iter() + .filter_map(|(object_id, choice_ids)| { + let [choice_id] = choice_ids.as_slice() else { + return None; + }; + attachment_fan_submission(&interaction_id, model, choice_id.clone()).map( + |submission| InteractionAttachmentFanChild { + object_id: object_id.0, + submission, + }, + ) + }) + .collect::>(); + (!children.is_empty()).then_some(( + host_id.0, + InteractionAttachmentFan { + host_id: host_id.0, + children, + }, + )) + }) + .collect() +} + +/// Only publish one-step attachment picks. Multi-choice objects and response +/// families that require the UI to synthesize a payload stay in the normal +/// interaction surface until the engine exposes a dedicated picker model. +fn attachment_fan_submission( + interaction_id: &InteractionId, + model: HumanResponseModel, + choice_id: InteractionChoiceId, +) -> Option { + let response = match model { + HumanResponseModel::ExactCandidates(_) => InteractionResponse::Choose { choice_id }, + HumanResponseModel::Select => InteractionResponse::Select { + choice_ids: vec![choice_id], + }, + HumanResponseModel::TargetSequence => InteractionResponse::Sequence { + choice_ids: vec![choice_id], + }, + _ => return None, + }; + Some(InteractionSubmission { + interaction_id: interaction_id.clone(), + response, + }) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct InteractionSubmitError { pub code: InteractionReasonCode, } +/// Successful interaction submission. The action is the exact opaque response +/// materialized by the engine and is available to trusted adapters solely for +/// post-success replay recording. +#[derive(Debug, Clone)] +pub struct AppliedInteraction { + pub action: GameAction, + pub result: ActionResult, +} + impl From for InteractionSubmitError { fn from(code: InteractionReasonCode) -> Self { Self { code } @@ -7596,6 +7799,14 @@ fn bound_outbound_view(view: &ViewerInteraction) -> Result<(), InteractionReason for opportunity in &view.opportunities { bound_outbound_opportunity_with_budget(opportunity, &mut budget)?; } + budget.list(view.attachment_fans.len())?; + for fan in view.attachment_fans.values() { + budget.list(fan.children.len())?; + for child in &fan.children { + budget.string(child.submission.interaction_id.as_str())?; + bound_outbound_response(&child.submission.response, &mut budget)?; + } + } if let InteractionAvailability::ProgressAvailable { witness } = &view.availability { budget.string(witness.interaction_id.as_str())?; bound_outbound_response(&witness.response, &mut budget)?; @@ -9074,7 +9285,7 @@ pub fn submit_interaction( state: &mut GameState, actor: PlayerId, submission: InteractionSubmission, -) -> Result { +) -> Result { let action = resolve_interaction_response(state, actor, &submission)?; // Re-read the slot rather than threading it out of `resolve_*`: keeping that // function's return to the action alone is what makes it usable as a public @@ -9082,9 +9293,10 @@ pub fn submit_interaction( // slot per pending decision, and it has already succeeded once here. let semantic_owner = PlayerId(slot_for_submission(state, actor, &submission.interaction_id)?.semantic_owner); - apply_interaction(state, actor, semantic_owner, action).map_err(|_error: EngineError| { - InteractionSubmitError { + let result = apply_interaction(state, actor, semantic_owner, action.clone()).map_err( + |_error: EngineError| InteractionSubmitError { code: InteractionReasonCode::ReducerRejected, - } - }) + }, + )?; + Ok(AppliedInteraction { action, result }) } diff --git a/crates/engine/src/types/interaction.rs b/crates/engine/src/types/interaction.rs index e0d6f4ecbe..830f083bb9 100644 --- a/crates/engine/src/types/interaction.rs +++ b/crates/engine/src/types/interaction.rs @@ -6,6 +6,8 @@ //! wire graph. All display text is supplied by consumers from the semantic codes //! below; the engine never places localized UI prose in this contract. +use std::collections::BTreeMap; + use serde::{Deserialize, Serialize}; pub const MAX_INTERACTION_LIST_LEN: usize = 10_000; @@ -29,6 +31,9 @@ opaque_string_id!(InteractionId); opaque_string_id!(InteractionChoiceId); opaque_string_id!(InteractionActionId); opaque_string_id!(PreviewRequestId); +// Viewer-safe object reference. Only the engine maps this opaque interaction +// value back to an in-game object. +opaque_string_id!(InteractionObjectReference); /// Persistence slot semantics. Simultaneous pregame decisions deliberately /// retain one capability per semantic owner instead of sharing one global ID. @@ -1080,6 +1085,33 @@ pub struct InteractionOpportunity { pub progress: InteractionProgress, } +/// A direct, engine-authored interaction submission for one attachment. +/// +/// The UI must echo this opaque response rather than deriving an action or a +/// response envelope from the opportunity schema. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "interaction-bindings", derive(ts_rs::TS))] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "interaction-bindings", ts(rename_all = "camelCase"))] +pub struct InteractionAttachmentFanChild { + #[cfg_attr(feature = "interaction-bindings", ts(type = "number"))] + pub object_id: u64, + pub submission: InteractionSubmission, +} + +/// Viewer-scoped attachment affordance for a single interaction opportunity. +/// It is derived from the filtered projection, not by consumers scanning game +/// state that may carry authority-only relationship information. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "interaction-bindings", derive(ts_rs::TS))] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "interaction-bindings", ts(rename_all = "camelCase"))] +pub struct InteractionAttachmentFan { + #[cfg_attr(feature = "interaction-bindings", ts(type = "number"))] + pub host_id: u64, + pub children: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "interaction-bindings", derive(ts_rs::TS))] #[serde( @@ -1112,6 +1144,12 @@ pub struct ViewerInteraction { pub can_submit: bool, pub auto_pass_recommended: bool, pub opportunities: Vec, + #[serde(default)] + #[cfg_attr( + feature = "interaction-bindings", + ts(type = "Record") + )] + pub attachment_fans: BTreeMap, pub availability: InteractionAvailability, } diff --git a/crates/engine/tests/integration/interaction_contract.rs b/crates/engine/tests/integration/interaction_contract.rs index 2ef04b7dbb..3afc6332b4 100644 --- a/crates/engine/tests/integration/interaction_contract.rs +++ b/crates/engine/tests/integration/interaction_contract.rs @@ -317,8 +317,13 @@ fn resolving_a_response_materializes_the_advertised_action_under_the_same_author // live decision rather than one the engine would have refused anyway. // Equivalence between the two paths needs no assertion: `submit_interaction` // delegates here, so they cannot disagree. - submit_interaction(&mut state, P0, witness) + let applied = submit_interaction(&mut state, P0, witness) .expect("the witness the projection advertised is submittable"); + assert_eq!( + applied.action, + GameAction::PassPriority, + "the post-success transaction exposes the exact engine-materialized action for replay" + ); } #[test] @@ -389,6 +394,69 @@ fn priority_projection_previews_submits_and_rejects_stale_or_unauthorized_ids() assert_eq!(stale.code, InteractionReasonCode::StaleInteraction); } +#[test] +fn attachment_fans_are_per_interaction_filtered_and_direct() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let host = scenario.add_creature(P0, "Fan Host", 2, 2).id(); + let attachment = scenario.add_creature(P0, "Fan Attachment", 1, 1).id(); + let unrelated = scenario.add_creature(P0, "Fan Unrelated", 1, 1).id(); + let mut runner = scenario.build(); + { + let state = runner.state_mut(); + engine::game::effects::attach::attach_to(state, attachment, host); + state.objects.get_mut(&attachment).unwrap().tapped = true; + state.objects.get_mut(&unrelated).unwrap().tapped = true; + state.waiting_for = WaitingFor::ChooseUntapSubset { + player: P0, + group: vec![attachment, unrelated], + max: 1, + }; + bind(state, "attachment-fan"); + } + + let view = viewer_interaction(runner.state(), P0); + assert!( + !view.opportunities.is_empty(), + "reach guard: the selected attachment has a live opportunity" + ); + assert_eq!(view.attachment_fans.len(), 1); + let fan = view + .attachment_fans + .get(&host.0) + .expect("the engine keys the fan by its visible host object"); + assert_eq!(fan.host_id, host.0); + assert_eq!(fan.children.len(), 1); + assert_eq!(fan.children[0].object_id, attachment.0); + let submission = fan.children[0].submission.clone(); + submit_interaction(runner.state_mut(), P0, submission).expect( + "the engine-authored fan submission resolves through production interaction dispatch", + ); + assert!( + !runner.state().objects[&attachment].tapped, + "the published attachment submission applies its selected untap" + ); + + let mut mismatched_filtered = filter_state_for_viewer(runner.state(), P0); + mismatched_filtered + .objects + .get_mut(&host) + .expect("fixture host remains visible") + .attachments + .clear(); + let mismatched = derive_viewer_interaction(runner.state(), &mismatched_filtered, P0); + assert!( + mismatched.attachment_fans.is_empty(), + "a stale host back-link must not expose an attachment fan from authoritative state" + ); + + let unauthorized = viewer_interaction(runner.state(), P1); + assert!( + unauthorized.attachment_fans.is_empty(), + "non-authorized viewers receive no attachment sidecar before any opportunity derivation" + ); +} + #[test] fn authority_requires_explicit_binding_and_rebinding_invalidates_old_capabilities() { let mut state = GameState::new_two_player(42); From 219e04de5c5460da7ad8c38db1e7d0491aed7d38 Mon Sep 17 00:00:00 2001 From: jofortin Date: Wed, 29 Jul 2026 12:31:32 -0400 Subject: [PATCH 14/63] Fix Culling Scales (#6789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(parser): rank the bare postnominal superlative over its enclosing noun phrase WIP CHECKPOINT — production change complete and verified; the plan's test matrix (parser rows, snapshots, runtime rows per boundary) is NOT yet written, so this is not yet PR-ready. Backlog root cause 1: "with the {greatest|highest|least|lowest|smallest} {power|toughness|mana value}" was silently dropped unless followed by an "among " tail. Culling Scales' "destroy target nonland permanent with the lowest mana value" emitted `properties: []` — zero Unimplemented, zero warnings, and able to destroy ANY nonland permanent. CR 109.2: with no explicit set, the ranked population IS the enclosing noun phrase. Because the suffix is seen before that phrase closes, detection records the head and materialization builds the FilterProp after the phrase completes — snapshot before append, or the prop nests inside its own population. - `oracle_nom/primitives.rs`: `parse_superlative_adjective` / `parse_property_keyword` relocated here from `condition.rs` so the condition and target layers share one atom instead of parallel tables (+ the `smallest` arm, zero corpus attestation, kept so the consolidation loses no recognized grammar). - `oracle_nom/filter.rs`: `parse_superlative_property_head` — single authority for the head, with a `not(alphanumeric1)` word-boundary guard the bare form needs and the `among` form got implicitly from its following tag. - `oracle_target.rs`: `parse_superlative_property_suffix` delegates the head and now owns ONLY the explicit "among" clause; the 2x3 cross-product alt is deleted. - CR 109.2a carve-out is structural, checked twice — a fail-fast pre-check at detection so a "card" phrase is never consumed, and the authority at materialization over the final accumulators, because the zone passes run in between. A refused phrase records `IgnoredRemainder` rather than vanishing. No new engine surface: reuses `FilterProp::{Cmc,PtComparison}` + `QuantityRef::Aggregate`, all already evaluated at every boundary reached. Verified: 4 cards fixed — Culling Scales (`Cmc EQ Aggregate{Min,ManaValue}`), Szat's Will (`ScopedPlayer` on both the outer filter and the nested population), Triumph of Gerrard (Saga chapters, needs Saga subtypes to reach), Favor of the Mighty (static `affected`, 1 -> 0 Unimplemented). `cargo fmt --check` clean, `cargo clippy-strict` clean workspace-wide, `cargo test -p phase-engine` 17924 lib + 4167 integration passing, Gate A/G/P pass. The graveyard-card guard refuses `creature card with the greatest power in your graveyard` and emits no Aggregate. Co-Authored-By: Claude Opus 5 * test(parser): pin the bare-superlative fix at both consumption boundaries Six parser rows in `oracle_target.rs` plus one runtime test per boundary the emitted filter actually reaches. Every row was revert-verified: reverting the materialization block flips it red. Parser rows: - Culling Scales' verbatim clause ranks over its ENCLOSING noun phrase, and the population itself carries NO properties (a population nesting the superlative inside itself would make `resolve_filter_threshold` recurse without bound). - "creature you control with the greatest power" puts the controller on BOTH the candidate filter and the population — ranking a controller-scoped candidate against a global population picks the wrong creature. - the `among` form still takes its population from the EXPLICIT set: regression guard for the 37 corpus cards that already worked. - CR 109.2a: a card-in-graveyard phrase emits no superlative, reach-guarded on the zone prop still being parsed so it cannot pass on a total parse failure. - the head's `not(alphanumeric1)` boundary rejects "powerstone", with a positive twin. - all 5 superlative adjectives x 3 properties map to the right aggregate. Runtime, boundary 1 (target legality, CR 608.2b) — `culling_scales_lowest_mana_value_target.rs`: drives the real upkeep trigger and asserts on the engine's own `legal_targets`. The MV 1 pair is legal, MV 4 / MV 6 are not, the Land is excluded by `Non(Land)`. Two notes for the next reader: the fixture needs a deliberate TIE at the minimum, because a single legal target is auto-chosen and never surfaces `TriggerTargetSelection`; and `add_creature` does not set `mana_cost`, so without explicit costs everything ties at MV 0 and the test proves nothing. On revert: "MV 4 is not the lowest" fails. Runtime, boundary 2 (static `affected`, CR 613 layers) — `favor_of_the_mighty_greatest_mana_value_protection.rs`: this card flips to *supported*, so an AST assertion would not be enough. Protection lands on the greatest-mana-value creature, not the others, and MOVES when a larger creature enters (CR 611.3a — the affected set is re-derived, not locked in). On revert the dropped restriction over-grants protection to EVERY creature and the exclusion assertion fails. Full suite: 17930 lib + 4169 integration passing. fmt clean, clippy-strict clean workspace-wide, Gate A/G/P pass. Co-Authored-By: Claude Opus 5 * fix(parser): refuse the bare superlative before consuming an unmodellable population Addresses the independent review-impl findings on this branch. [MED] The CR 109.2a materialization guard was reachable and left a silently over-broad filter: "destroy target creature with the greatest power in your graveyard" consumed the superlative at the detection pass, then the guard refused to emit it once the zone prop had accumulated — so the restriction vanished while the card still reported as supported, the exact defect this change exists to remove. The zone passes run AFTER detection, so the fix is a look-ahead: `nonbattlefield_zone_clause_lies_ahead` tries the authoritative `parse_zone_suffix` at each word boundary of the remaining phrase (via the shared word-boundary scan primitive, so no bespoke zone vocabulary), and detection declines to consume at all. The phrase is then left unclaimed rather than consumed-and-dropped. [MED] The population snapshot omits `relative_core_type_filters`, which a trailing "that's an artifact" clause fills in after the detection pass — so "permanent with the greatest mana value that's an artifact" would have ranked `[Permanent, Artifact]` candidates against a `[Permanent]` population, i.e. a different set from the enclosing noun phrase (CR 109.2). Materialization now refuses when that accumulator is non-empty. Zero corpus attestation today; this keeps it that way rather than ranking over the wrong set. [LOW] `push_diagnostic` now dedupes `IgnoredRemainder` as it already did `TargetFallback` — both can be pushed from a combinator a speculative `alt` re-enters on a discarded alternative. The snapshot delta shows the effect: Fathom Fleet Swordjack was emitting one byte-identical "it's attacking" diagnostic twice and now emits it once. The diagnostic key is renamed to `..._unmodelled_population`, since it now covers both refusal reasons. [LOW] The Culling Scales `Non(Land)` assertion was dead — `land` was always `None` because nothing was on the battlefield. A basic land is now added at MV 0, BELOW the tie, so the exclusion can only come from the type conjunction and never from losing the mana-value comparison. Two new parser rows cover the refusal paths the review found unexercised: the zone look-ahead (with a positive twin so the guard cannot refuse everything) and the relative-clause refusal (reach-guarded on the clause reaching the candidates). Full suite: 17967 lib + 4188 integration passing. fmt clean, clippy-strict clean workspace-wide, Gate A/G/P pass. Co-Authored-By: Claude Opus 5 * fix(parser): fold a trailing relative type clause into the ranked population CodeRabbit finding on PR #6789 (Major, functional correctness): the previous commit refused to MATERIALIZE the superlative when `relative_core_type_filters` was non-empty, but the detection pass had already CONSUMED it — so "permanent with the greatest mana value that's an artifact" continued with an unranked target, the same silent drop this PR exists to remove. CodeRabbit offered two remedies; taking the second, because the first is worse. Refusing pre-consumption (which I implemented and then reverted) leaves the whole tail unparsed and drops the TYPE clause as well — a regression against `main`, which at least kept `[Permanent, Artifact]`. So the relative clause is now folded INTO the population: the population's type set is `base_type_filters + relative_core_type_filters`, making it equal to the candidate set, which is what CR 109.2 asks for. Ranking and candidacy then agree object-for-object. A MULTI-type relative clause is a disjunction that `type_filter_branches` spreads across several branches, which one conjunctive population cannot express, so that shape is still declined (`len() <= 1`). Zero corpus attestation for either form. The zone look-ahead is unchanged and still refuses pre-consumption — that population genuinely is not modelled here, so declining is correct there. `trailing_relative_type_clause_is_folded_into_the_population` now asserts the population carries the same type set as the candidates, keeping its reach-guard on the clause reaching the candidate filter. Full suite: 17967 lib + 4188 integration passing. fmt clean, clippy-strict clean, Gate A/G/P pass. Co-Authored-By: Claude Opus 5 * fix(parser): rank every disjunctive leg of a superlative target over the whole population Maintainer review on PR #6789 (blocker). The previous head folded a SINGLE-type trailing relative clause into the ranked population but declined the MULTI-type case, and declining happened at materialization — after the head was already consumed. So `target permanent with the greatest mana value that's an artifact or creature` parsed as `Or { [Permanent+Artifact], [Permanent+Creature] }` with NO aggregate property at all: every eligible artifact or creature was a legal target, not just the greatest-mana-value member(s). `IgnoredRemainder` is not a strict failure and cannot make that supported shape safe. Confirmed by probe before fixing. The population is now representable exactly, with no new engine surface: a multi-type relative clause becomes ONE conjunctive `TypeFilter::AnyOf` member, because `base ∧ (A ∨ B) == (base ∧ A) ∪ (base ∧ B)` — precisely the union of the `Or` legs `type_filter_branches` builds. The prop is pushed into `properties`, which the branch cross-product replicates onto every leg, so each leg ranks against the WHOLE population rather than its own type. `TypeFilter::AnyOf` already has full runtime authority (`game/filter.rs:3093` `type_filter_matches`), and `type_filter_includes_card` already recurses through it, so the CR 109.2a "card" carve-out cannot be smuggled past inside a disjunction. The `relative_core_type_filters.len() <= 1` guard is therefore gone, and with it the only path by which the aggregate could be dropped for a battlefield population. Also per the non-blocking finding: CR 109.2a is limited to a description containing BOTH "card" and a zone name (verified at docs/MagicCompRules.txt:584). The generic non-battlefield-zone guard is licensed by CR 109.2 (line 582 — the battlefield default applies only when no zone is named), so the six generic-zone sites are recited to CR 109.2, with CR 109.2a named only for the "card" leg. The structural carve-out doc block at `type_filter_includes_card` already cited both correctly and is unchanged. Coverage: - `multi_type_relative_clause_ranks_every_leg_against_the_whole_population` (parser) — reach-guarded on the 2-leg `Or`, asserts each leg's population is exactly `[Permanent, AnyOf([Artifact, Creature])]`. - `multi_type_relative_clause_ranks_over_the_disjunctive_population_at_runtime` (runtime) — the MV 0 Land is the discriminator that separates the two failure modes: dropping the aggregate makes the MV 5 creature legal, while ranking over `[Permanent]` alone lets the Land set the bar so nothing is legal and the trigger never pauses. Both modes were patched in and confirmed to fail. Full suite: 17968 lib + 4189 integration passing. fmt clean, clippy-strict clean, Gate A/G/P pass. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Jonathan Fortin Co-authored-by: Claude Opus 5 Co-authored-by: Matt Evans <1388610+matthewevans@users.noreply.github.com> --- crates/engine/src/parser/oracle_ir/context.rs | 9 +- ...apshots__diagnostic_ignored_remainder.snap | 6 - .../engine/src/parser/oracle_nom/condition.rs | 22 +- crates/engine/src/parser/oracle_nom/filter.rs | 38 +- .../src/parser/oracle_nom/primitives.rs | 36 +- crates/engine/src/parser/oracle_target.rs | 610 +++++++++++++++++- ...culling_scales_lowest_mana_value_target.rs | 266 ++++++++ ...e_mighty_greatest_mana_value_protection.rs | 121 ++++ crates/engine/tests/integration/main.rs | 2 + 9 files changed, 1045 insertions(+), 65 deletions(-) create mode 100644 crates/engine/tests/integration/culling_scales_lowest_mana_value_target.rs create mode 100644 crates/engine/tests/integration/favor_of_the_mighty_greatest_mana_value_protection.rs diff --git a/crates/engine/src/parser/oracle_ir/context.rs b/crates/engine/src/parser/oracle_ir/context.rs index 86c7b2b1ee..433c058e04 100644 --- a/crates/engine/src/parser/oracle_ir/context.rs +++ b/crates/engine/src/parser/oracle_ir/context.rs @@ -298,8 +298,13 @@ impl ParseContext { /// Push a diagnostic (replaces oracle_warnings::push_diagnostic). pub fn push_diagnostic(&mut self, d: OracleDiagnostic) { - if matches!(d, OracleDiagnostic::TargetFallback { .. }) - && self.diagnostics.iter().any(|existing| existing == &d) + // Both variants can be pushed from a combinator that a speculative `alt` + // re-enters on a discarded alternative, so an identical entry is noise + // rather than signal. + if matches!( + d, + OracleDiagnostic::TargetFallback { .. } | OracleDiagnostic::IgnoredRemainder { .. } + ) && self.diagnostics.iter().any(|existing| existing == &d) { return; } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__diagnostic_snapshots__diagnostic_ignored_remainder.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__diagnostic_snapshots__diagnostic_ignored_remainder.snap index f92d9a7b7f..b4502d1afe 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__diagnostic_snapshots__diagnostic_ignored_remainder.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__diagnostic_snapshots__diagnostic_ignored_remainder.snap @@ -3,12 +3,6 @@ source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs expression: "&diagnostics" --- [ - { - "type": "IgnoredRemainder", - "text": "it's attacking", - "parser": "deal-damage", - "line_index": 0 - }, { "type": "IgnoredRemainder", "text": "it's attacking", diff --git a/crates/engine/src/parser/oracle_nom/condition.rs b/crates/engine/src/parser/oracle_nom/condition.rs index a736b93b94..fe909bd261 100644 --- a/crates/engine/src/parser/oracle_nom/condition.rs +++ b/crates/engine/src/parser/oracle_nom/condition.rs @@ -15,6 +15,7 @@ use nom::Parser; use super::error::{oracle_err, OracleError, OracleResult}; use super::primitives::{ parse_article, parse_color, parse_keyword_name, parse_mana_cost, parse_number, + parse_property_keyword, parse_superlative_adjective, }; use super::quantity as nom_quantity; use crate::parser::oracle_target::{ @@ -2920,27 +2921,6 @@ fn parse_subject_has_superlative_form(input: &str) -> OracleResult<'_, StaticCon )) } -/// Parse a superlative adjective into its corresponding `AggregateFunction`. -pub(crate) fn parse_superlative_adjective(input: &str) -> OracleResult<'_, AggregateFunction> { - alt(( - value(AggregateFunction::Max, tag("greatest")), - value(AggregateFunction::Max, tag("highest")), - value(AggregateFunction::Min, tag("lowest")), - value(AggregateFunction::Min, tag("least")), - )) - .parse(input) -} - -/// Property keyword parser — used by both LHS and RHS of the comparison. -pub(crate) fn parse_property_keyword(input: &str) -> OracleResult<'_, ObjectProperty> { - alt(( - value(ObjectProperty::Power, tag("power")), - value(ObjectProperty::Toughness, tag("toughness")), - value(ObjectProperty::ManaValue, tag("mana value")), - )) - .parse(input) -} - /// Parse the comparator phrase between "is " and "each other ...". /// /// The aggregate function is coupled to the comparator direction by the diff --git a/crates/engine/src/parser/oracle_nom/filter.rs b/crates/engine/src/parser/oracle_nom/filter.rs index 086ad1e75a..de5898e7e9 100644 --- a/crates/engine/src/parser/oracle_nom/filter.rs +++ b/crates/engine/src/parser/oracle_nom/filter.rs @@ -6,16 +6,19 @@ use nom::branch::alt; use nom::bytes::complete::tag; -use nom::character::complete::space1; -use nom::combinator::{map, opt, value}; +use nom::character::complete::{alphanumeric1, space1}; +use nom::combinator::{map, not, opt, value}; use nom::sequence::preceded; use nom::Parser; use super::error::OracleResult; -use super::primitives::{parse_article, parse_pt_modifier}; +use super::primitives::{ + parse_article, parse_property_keyword, parse_pt_modifier, parse_superlative_adjective, +}; use super::quantity::{parse_quantity_expr_number, parse_quantity_ref}; use crate::types::ability::{ - Comparator, ControllerRef, FilterProp, PtStat, PtValueScope, QuantityExpr, + AggregateFunction, Comparator, ControllerRef, FilterProp, ObjectProperty, PtStat, PtValueScope, + QuantityExpr, }; #[cfg(test)] use crate::types::counter::CounterType; @@ -288,6 +291,33 @@ pub fn parse_pt_comparison(input: &str) -> OracleResult<'_, FilterProp> { Ok((rest, prop)) } +/// CR 208.1 (power and toughness) + CR 202.3 (mana value): the HEAD of a +/// postnominal superlative property qualifier — "with the `` +/// ``". +/// +/// SINGLE AUTHORITY for that head. The trailing eligible-set clause is the +/// caller's business: an explicit "among ``" (CR 109.2, owned by +/// `oracle_target::parse_superlative_property_suffix`), or the enclosing noun +/// phrase itself (CR 109.2, owned by the bare-form pass in +/// `parse_type_phrase_with_ctx`). +/// +/// The `not(alphanumeric1)` tail guard enforces a word boundary so "mana values" +/// or "powerstone" cannot half-match the property word. The `among`-form caller +/// gets that boundary implicitly from its following `tag(" among ")`; the +/// bare-form caller has none. The guard lives HERE rather than inside +/// `parse_property_keyword`, because narrowing that shared atom would silently +/// change the ten existing condition-layer call sites. +pub(crate) fn parse_superlative_property_head( + input: &str, +) -> OracleResult<'_, (AggregateFunction, ObjectProperty)> { + let (input, _) = tag("with the ").parse(input)?; + let (input, function) = parse_superlative_adjective(input)?; + let (input, _) = space1.parse(input)?; + let (input, property) = parse_property_keyword(input)?; + let (input, _) = not(alphanumeric1).parse(input)?; + Ok((input, (function, property))) +} + /// CR 208.1: Possessive pronoun introducing a creature's *own* stat in a /// self-referential P/T comparison — "its" (singular subject) or "their" (plural /// subject). Both refer to the candidate object itself, not the ability source. diff --git a/crates/engine/src/parser/oracle_nom/primitives.rs b/crates/engine/src/parser/oracle_nom/primitives.rs index 396d1d9473..f513fb9ee6 100644 --- a/crates/engine/src/parser/oracle_nom/primitives.rs +++ b/crates/engine/src/parser/oracle_nom/primitives.rs @@ -11,7 +11,7 @@ use nom::sequence::{delimited, preceded}; use nom::Parser; use super::error::{OracleError, OracleResult}; -use crate::types::ability::PtValue; +use crate::types::ability::{AggregateFunction, ObjectProperty, PtValue}; use crate::types::card_type::CoreType; use crate::types::counter::{CounterType, KEYWORD_COUNTERS}; use crate::types::keywords::KeywordKind; @@ -1322,6 +1322,40 @@ pub fn split_once_on<'a>( Ok(("", (before, after))) } +/// Parse a superlative adjective into its corresponding `AggregateFunction`. +/// +/// CR 208.1 (a creature's power and toughness) + CR 202.3 (an object's mana +/// value): greatest/highest select the maximum of the population, least/lowest/ +/// smallest the minimum. +/// +/// Relocated here from `oracle_nom/condition.rs` so the condition layer and the +/// target/filter layer share ONE atom rather than maintaining parallel tables. +pub(crate) fn parse_superlative_adjective(input: &str) -> OracleResult<'_, AggregateFunction> { + alt(( + value(AggregateFunction::Max, tag("greatest")), + value(AggregateFunction::Max, tag("highest")), + value(AggregateFunction::Min, tag("lowest")), + value(AggregateFunction::Min, tag("least")), + // Parity with the target-suffix table this consolidation absorbs. ZERO + // corpus attestation (0 hits over 35,679 MTGJSON faces), so it adds no + // card coverage — it exists so the consolidation loses no grammar that + // either predecessor table recognized. + value(AggregateFunction::Min, tag("smallest")), + )) + .parse(input) +} + +/// Property keyword → [`ObjectProperty`]. Shared by the condition layer's +/// comparison grammar and the target/filter layer's superlative head. +pub(crate) fn parse_property_keyword(input: &str) -> OracleResult<'_, ObjectProperty> { + alt(( + value(ObjectProperty::Power, tag("power")), + value(ObjectProperty::Toughness, tag("toughness")), + value(ObjectProperty::ManaValue, tag("mana value")), + )) + .parse(input) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/parser/oracle_target.rs b/crates/engine/src/parser/oracle_target.rs index 80e17261a1..709c827e77 100644 --- a/crates/engine/src/parser/oracle_target.rs +++ b/crates/engine/src/parser/oracle_target.rs @@ -2844,6 +2844,12 @@ pub fn parse_type_phrase_with_ctx<'a>( // CR 108.3 + CR 110.2: Ownership and control are distinct; "you own and control" satisfies both. let mut controller = None; + + // CR 109.2: a BARE postnominal superlative's ranked population is the + // enclosing noun phrase, which is not fully parsed yet at the point the + // suffix appears. Record the head here and materialize the `FilterProp` + // after the phrase closes (see the block below `base_type_filters`). + let mut pending_bare_superlative: Option<(AggregateFunction, ObjectProperty)> = None; pos += parse_ownership_or_controller_suffix(&lower[pos..], &mut properties, &mut controller, ctx); @@ -2886,6 +2892,50 @@ pub fn parse_type_phrase_with_ctx<'a>( pos += consumed; } + // CR 109.2 + CR 601.2c: BARE postnominal superlative ("with the lowest mana + // value" — Culling Scales; "with the greatest power" — Triumph of Gerrard, + // Szat's Will; "with the greatest mana value" — Favor of the Mighty). The + // `among`-bearing form was already consumed by the passes above, which are the + // single authority for an EXPLICIT eligible set; this pass handles the form + // whose population is the enclosing noun phrase itself. + // + // CR 109.2 PRE-CHECK (fail-fast, NOT the authority): CR 109.2 licenses the + // battlefield default only for a description that names no zone and contains no + // "card"/"spell"/"source"/"scheme"; CR 109.2a is what a "card" + zone + // description means instead. The accumulators bound so far already settle the + // "card" leg, so a `card` phrase is never consumed and its text stays honest in + // the remainder. The zone passes have + // not run yet — which is exactly why the AUTHORITY is the identical call in + // the materialization block below, over the FINAL accumulators. + if phrase_denotes_battlefield_permanents( + left_card_suffix, + &[ + &adjective_type_filters, + card_type.as_slice(), + &extra_core_type_filters, + &neg_type_filters, + ], + &properties, + ) { + if let Some((head, consumed)) = parse_bare_superlative_property_suffix(&lower[pos..]) { + // CR 109.2: refuse BEFORE consuming when a non-battlefield zone clause + // still lies ahead. Naming a zone withdraws the battlefield default, and + // that population is not modelled here; consuming first only to refuse + // at materialization would drop the restriction while leaving the card + // looking supported. + // + // A trailing relative type clause ("that's an artifact", "that's an + // artifact or creature") is deliberately NOT refused here — it is folded + // into the population below, so the population still equals the candidate + // set. Refusing it pre-consumption would leave the whole tail unparsed and + // drop the TYPE clause too, which is worse than the bug being fixed. + if !nonbattlefield_zone_clause_lies_ahead(&lower[pos + consumed..]) { + pending_bare_superlative = Some(head); + pos += consumed; + } + } + } + // Check "with [counter] counter(s) on it/them" suffix if let Some((prop, consumed)) = parse_counter_suffix(&lower[pos..]) { properties.push(prop); @@ -3517,6 +3567,71 @@ pub fn parse_type_phrase_with_ctx<'a>( ] .concat(); + // CR 109.2 + CR 601.2c + CR 608.2b: materialize the deferred bare superlative + // now that the enclosing noun phrase is complete — full type conjunction, + // controller (including a trailing "you control" / "they control"), and every + // other accumulated property. + // + // CR 109.2 AUTHORITY. This re-check is NOT redundant with the pre-check at the + // detection pass: `parse_zone_suffix` runs unconditionally after that pass and + // pushes into the same `properties` this block snapshots. "creature with the + // greatest power in your graveyard" therefore reaches here with + // `InZone { Graveyard }` accumulated and must NOT be emitted — a + // battlefield-defaulted population would rank the wrong set, and a + // graveyard-defaulted one would lean on off-battlefield P/T reads this change + // does not attempt. + // + // Ordering is load-bearing: build the population snapshot BEFORE appending the + // prop. Reversed, the prop nests inside its own population and + // `resolve_filter_threshold` recurses without bound. + if let Some((function, property)) = pending_bare_superlative.take() { + // CR 109.2: the ranked population must be the SAME set as the candidates. A + // trailing relative type clause closes the noun phrase AFTER the detection + // pass, so fold it into the population here rather than refusing — refusing + // would leave the tail unparsed and drop the type clause as well. + if phrase_denotes_battlefield_permanents( + left_card_suffix, + &[&base_type_filters, &relative_core_type_filters], + &properties, + ) { + // Population type set == candidate type set, so ranking and candidacy + // agree object-for-object. + // + // A MULTI-type relative clause ("that's an artifact or creature") is a + // DISJUNCTION that `type_filter_branches` spreads across one `Or` leg per + // type. `TypeFilter::AnyOf` expresses that same union as a single + // conjunctive member, because + // `base ∧ (A ∨ B) == (base ∧ A) ∪ (base ∧ B)`. The prop built below is + // pushed into `properties`, which the branch cross-product then + // replicates onto every leg — so each leg ranks against the WHOLE + // population, not just its own type. + let mut population_types = base_type_filters.clone(); + match relative_core_type_filters.as_slice() { + [] => {} + [only] => population_types.push(only.clone()), + many => population_types.push(TypeFilter::AnyOf(many.to_vec())), + } + let population = TargetFilter::Typed(TypedFilter { + type_filters: population_types, + controller: controller.clone(), + properties: properties.clone(), + }); + let prop = superlative_property_filter_prop(function, property, population); + properties.push(prop); + } else { + // CR 109.2 (+ CR 109.2a for the "card" leg): the phrase names a + // non-battlefield zone, so the battlefield default is withdrawn and the + // ranked population is one this change does not model. Leave the text + // unclaimed and record it, rather than emitting a population that + // would silently be the wrong set. + ctx.push_diagnostic(OracleDiagnostic::IgnoredRemainder { + text: lower.trim().into(), + parser: "bare_superlative_property_suffix_unmodelled_population".into(), + line_index: 0, + }); + } + } + let type_filter_branches = if relative_core_type_filters.is_empty() { vec![base_type_filters] } else if relative_core_type_filters.len() == 1 { @@ -4870,42 +4985,23 @@ fn superlative_property_filter_prop( /// `oracle_effect/search.rs::parse_highest_mana_value_library_suffix`. /// The eligible set after "among " is parsed by the authoritative /// `parse_type_phrase_with_ctx` combinator (type list + controller suffix). -/// Returns (FilterProp, bytes consumed from the original text). +/// CR 109.2: the postnominal superlative qualifier with an EXPLICIT eligible set — +/// "with the among ". This function owns ONLY the +/// explicit-set clause; an explicit set overrides the enclosing noun phrase as +/// the ranked population. +/// +/// The head grammar is delegated in full to the single authority +/// `oracle_nom::filter::parse_superlative_property_head`. The BARE form (no +/// "among" clause), whose population is the enclosing noun phrase, is handled by +/// `parse_bare_superlative_property_suffix` + the deferred materialization in +/// `parse_type_phrase_with_ctx`. fn parse_superlative_property_suffix( text: &str, ctx: &mut ParseContext, ) -> Option<(FilterProp, usize)> { let trimmed = text.trim_start(); - // "with the among " — the superlative head selects - // the aggregate direction and the property is the second axis. Factor the - // 2×3 cross product into two alts (PATTERNS.md §8b). - // CR 208.1 (power and toughness) + CR 202.3 (mana value): greatest/highest = - // Max, least/lowest/smallest = Min. Both directions feed the same generic - // superlative_property_filter_prop, so the runtime (values.min()/max() in - // game/quantity.rs) and the Sacrifice/Destroy/return resolvers select the - // constrained object unchanged. - let (rest, (function, property)) = ( - tag::<_, _, OracleError<'_>>("with the "), - alt(( - value( - AggregateFunction::Max, - alt((tag("greatest "), tag("highest "))), - ), - value( - AggregateFunction::Min, - alt((tag("least "), tag("lowest "), tag("smallest "))), - ), - )), - alt(( - value(ObjectProperty::Power, tag("power")), - value(ObjectProperty::Toughness, tag("toughness")), - value(ObjectProperty::ManaValue, tag("mana value")), - )), - tag(" among "), - ) - .parse(trimmed) - .map(|(rest, (_, function, property, _))| (rest, (function, property))) - .ok()?; + let (rest, (function, property)) = nom_filter::parse_superlative_property_head(trimmed).ok()?; + let (rest, _) = tag::<_, _, OracleError<'_>>(" among ").parse(rest).ok()?; // Delegate the " control(s)" clause to the // authoritative type-phrase combinator — it parses the multi-type // or/and list, any leading article, and the trailing controller suffix. @@ -4914,6 +5010,123 @@ fn parse_superlative_property_suffix( Some((prop, text.len() - after.len())) } +/// CR 109.2 + CR 601.2c: the BARE postnominal superlative — "with the +/// " with NO "among " clause. Detects and measures +/// the head only; the eligible population is the ENCLOSING noun phrase, so the +/// `FilterProp` is materialized by the caller once that phrase is fully parsed. +/// +/// The `not(peek((space1, tag("among"))))` guard keeps the `among` form on its own +/// authority even if that parse failed downstream for an unrelated reason — a +/// bare-form fallback there would silently RE-SCOPE the ranked population, which +/// is worse than leaving the phrase unparsed. +fn parse_bare_superlative_property_suffix( + text: &str, +) -> Option<((AggregateFunction, ObjectProperty), usize)> { + let trimmed = text.trim_start(); + let (rest, head) = nom_filter::parse_superlative_property_head(trimmed).ok()?; + let (rest, _) = not(peek((space1, tag::<_, _, OracleError<'_>>("among")))) + .parse(rest) + .ok()?; + Some((head, text.len() - rest.len())) +} + +/// CR 109.2 look-ahead: does a NON-BATTLEFIELD zone clause still lie ahead in +/// this noun phrase? (CR 109.2 grants the battlefield default only to a +/// description that names no zone; CR 109.2a is reserved for "card" + zone.) +/// +/// The zone passes run after the bare-superlative detection pass, so at detection +/// the accumulators cannot yet show a graveyard/exile scope. Without this +/// look-ahead the detection pass would CONSUME the superlative and the +/// materialization guard would then refuse to emit it — leaving a filter that +/// looks supported with its ranked restriction silently gone, which is the exact +/// defect this whole change exists to remove. +/// +/// Implemented by trying the authoritative `parse_zone_suffix` at each word +/// boundary of the remaining phrase (the shared word-boundary scan primitive), so +/// there is no bespoke zone vocabulary here. +fn nonbattlefield_zone_clause_lies_ahead(rest: &str) -> bool { + nom_primitives::scan_at_word_boundaries(rest, |candidate| match parse_zone_suffix(candidate) { + Some((props, _, _)) if props.iter().any(filter_prop_names_non_battlefield_zone) => { + Ok((candidate, ())) + } + _ => Err(nom::Err::Error(OracleError::new( + candidate, + nom::error::ErrorKind::Fail, + ))), + }) + .is_some() +} + +/// CR 109.2 vs CR 109.2a — STRUCTURAL carve-out, never positional. +/// +/// CR 109.2 makes a type description with no zone clause and no "card" mean +/// permanents on the battlefield; that is the ONLY reading under which a bare +/// superlative's ranked population may default to the battlefield +/// (`game/quantity.rs` zone default). CR 109.2a makes a description containing +/// "card" plus a zone name mean cards in that zone instead — a different +/// population this pass must NOT silently claim. +/// +/// Signals, all read from the parser's own typed accumulators: +/// * `left_card_suffix` — the noun phrase ended in the "card"/"cards" noun; +/// * a `TypeFilter::Card` anywhere in the accumulated type filters (the +/// bare-noun form, where `left_card_suffix` is not set); +/// * an accumulated non-battlefield zone prop. +/// +/// Called TWICE: as a fail-fast pre-check at detection (so a `card` phrase is +/// never consumed) and again at materialization over the FINAL accumulators, +/// where it is the AUTHORITY that governs emission — the zone passes run after +/// detection, so only the second call can see a zone prop. +fn phrase_denotes_battlefield_permanents( + left_card_suffix: bool, + type_filter_groups: &[&[TypeFilter]], + properties: &[FilterProp], +) -> bool { + !left_card_suffix + && !type_filter_groups + .iter() + .any(|group| group.iter().any(type_filter_includes_card)) + && !properties + .iter() + .any(filter_prop_names_non_battlefield_zone) +} + +/// CR 205: exhaustive over every `TypeFilter` variant so a negated or disjunctive +/// `Card` leg cannot slip past the CR 109.2a carve-out, and so a future variant +/// forces a CR 109.2a decision rather than defaulting to "battlefield". +fn type_filter_includes_card(filter: &TypeFilter) -> bool { + match filter { + TypeFilter::Card => true, + TypeFilter::Non(inner) => type_filter_includes_card(inner), + TypeFilter::AnyOf(inner) => inner.iter().any(type_filter_includes_card), + TypeFilter::Creature + | TypeFilter::Land + | TypeFilter::Artifact + | TypeFilter::Enchantment + | TypeFilter::Instant + | TypeFilter::Sorcery + | TypeFilter::Planeswalker + | TypeFilter::Battle + | TypeFilter::Kindred + | TypeFilter::Permanent + | TypeFilter::Any + | TypeFilter::Subtype(_) => false, + } +} + +/// CR 109.2 + CR 400.1: any accumulated zone prop naming a zone other than the +/// battlefield disqualifies the CR 109.2 battlefield default. The `_ => false` +/// wildcard is deliberate — `FilterProp` has ~180 variants and enumerating them +/// all in a zone predicate would be pure noise. +fn filter_prop_names_non_battlefield_zone(prop: &FilterProp) -> bool { + match prop { + FilterProp::InZone { zone } => *zone != Zone::Battlefield, + FilterProp::InAnyZone { zones } => zones.iter().any(|z| *z != Zone::Battlefield), + FilterProp::AnyOf { props } => props.iter().any(filter_prop_names_non_battlefield_zone), + FilterProp::Not { prop } => filter_prop_names_non_battlefield_zone(prop), + _ => false, + } +} + /// Parse "with/that have/that each have mana value N or less" / "… or greater" /// suffixes, dynamic "with mana value less than or equal to that [type]" /// patterns, and the superlative "with the greatest/highest mana value among @@ -16916,4 +17129,339 @@ mod tests { "expected Or filter, got {f:?}" ); } + + // ---- CR 109.2: BARE postnominal superlative (no "among" clause) ---- + + /// Extract the sole superlative `FilterProp` from a parsed target filter, + /// plus the population it ranks over. + fn bare_superlative_parts(filter: &TargetFilter) -> (&FilterProp, &TargetFilter) { + let tf = typed_leg(filter).expect("expected a Typed target filter"); + let prop = tf + .properties + .iter() + .find(|p| { + matches!( + p, + FilterProp::Cmc { + value: QuantityExpr::Ref { + qty: QuantityRef::Aggregate { .. } + }, + .. + } | FilterProp::PtComparison { + value: QuantityExpr::Ref { + qty: QuantityRef::Aggregate { .. } + }, + .. + } + ) + }) + .expect("expected a superlative FilterProp carrying an Aggregate"); + let population = match prop { + FilterProp::Cmc { + value: + QuantityExpr::Ref { + qty: QuantityRef::Aggregate { filter, .. }, + }, + .. + } + | FilterProp::PtComparison { + value: + QuantityExpr::Ref { + qty: QuantityRef::Aggregate { filter, .. }, + }, + .. + } => filter, + _ => unreachable!("matched above"), + }; + (prop, population) + } + + /// CR 109.2 + CR 202.3 — Culling Scales, verbatim clause. The bare superlative + /// ranks over the ENCLOSING noun phrase ("nonland permanent"), so the emitted + /// population must reproduce that type conjunction and must itself carry NO + /// properties (a population that nested the superlative inside itself would make + /// `resolve_filter_threshold` recurse without bound). + /// + /// Reverting the materialization block leaves `properties: []` here — the exact + /// misparse this fixes, where the ability could destroy ANY nonland permanent. + #[test] + fn bare_superlative_lowest_mana_value_ranks_over_enclosing_noun_phrase() { + let (filter, rest) = parse_target("target nonland permanent with the lowest mana value"); + assert!( + rest.trim().is_empty(), + "whole phrase consumed, got {rest:?}" + ); + let (prop, population) = bare_superlative_parts(&filter); + let FilterProp::Cmc { + comparator, value, .. + } = prop + else { + panic!("expected Cmc, got {prop:?}"); + }; + assert_eq!(*comparator, Comparator::EQ, "ties are all legal targets"); + let QuantityExpr::Ref { + qty: QuantityRef::Aggregate { + function, property, .. + }, + } = value + else { + unreachable!() + }; + assert_eq!(*function, AggregateFunction::Min, "lowest → Min"); + assert_eq!(*property, ObjectProperty::ManaValue); + + let pop = typed_leg(population).expect("population should be a Typed filter"); + assert!(pop.type_filters.contains(&TypeFilter::Permanent)); + assert!(pop + .type_filters + .contains(&TypeFilter::Non(Box::new(TypeFilter::Land)))); + assert!( + pop.properties.is_empty(), + "the population must not contain the superlative prop itself, got {:?}", + pop.properties + ); + } + + /// CR 109.2 — "greatest power" on a controller-scoped noun phrase (Triumph of + /// Gerrard's chapter text). The trailing "you control" belongs to the noun + /// phrase, so it must appear on BOTH the candidate filter and the ranked + /// population: ranking a controller-scoped candidate against a global + /// population would pick the wrong creature. + #[test] + fn bare_superlative_greatest_power_carries_controller_onto_population() { + let (filter, _) = parse_target("target creature you control with the greatest power"); + let tf = typed_leg(&filter).expect("typed"); + assert_eq!(tf.controller, Some(ControllerRef::You)); + let (prop, population) = bare_superlative_parts(&filter); + let FilterProp::PtComparison { stat, value, .. } = prop else { + panic!("expected PtComparison, got {prop:?}"); + }; + assert_eq!(*stat, PtStat::Power); + let QuantityExpr::Ref { + qty: QuantityRef::Aggregate { function, .. }, + } = value + else { + unreachable!() + }; + assert_eq!(*function, AggregateFunction::Max, "greatest → Max"); + let pop = typed_leg(population).expect("typed population"); + assert_eq!( + pop.controller, + Some(ControllerRef::You), + "the population must inherit the noun phrase's controller" + ); + } + + /// CR 109.2 — the explicit "among " form keeps its own authority and is + /// unchanged by the bare-form pass. Regression guard for the 37 corpus cards + /// that already worked: the population here is the EXPLICIT set, not the + /// CR 109.2 — a MULTI-type trailing relative clause is a disjunction, and every + /// `Or` leg must rank against the WHOLE population, not just its own type. + /// + /// `target permanent with the greatest mana value that's an artifact or creature` + /// spreads into `Or { [Permanent+Artifact], [Permanent+Creature] }`. Before this + /// fix the superlative was consumed and then dropped, so the phrase targeted ANY + /// artifact or creature (maintainer review on PR #6789). The population is now a + /// single conjunctive `TypeFilter::AnyOf`, which is exactly that union: + /// `base ∧ (A ∨ B) == (base ∧ A) ∪ (base ∧ B)`. + #[test] + fn multi_type_relative_clause_ranks_every_leg_against_the_whole_population() { + let (filter, _) = parse_target( + "target permanent with the greatest mana value that's an artifact or creature", + ); + let legs = match &filter { + TargetFilter::Or { filters } => filters, + other => panic!("expected an Or of one leg per relative type, got {other:?}"), + }; + // Reach-guard: the disjunctive branch really was taken, so the per-leg + // assertions below cannot pass vacuously on a single collapsed filter. + assert_eq!( + legs.len(), + 2, + "reach-guard: one leg per relative core type, got {legs:?}" + ); + + let expected_population_types = vec![ + TypeFilter::Permanent, + TypeFilter::AnyOf(vec![TypeFilter::Artifact, TypeFilter::Creature]), + ]; + for (leg, own_type) in legs + .iter() + .zip([TypeFilter::Artifact, TypeFilter::Creature]) + { + let tf = typed_leg(leg).expect("each leg is a Typed filter"); + assert!( + tf.type_filters.contains(&own_type), + "leg must keep its own candidate type {own_type:?}, got {:?}", + tf.type_filters + ); + // Reverting the fix removes this prop entirely and the leg targets any + // artifact or creature. + let (_, population) = bare_superlative_parts(leg); + let pop = typed_leg(population).expect("typed population"); + assert_eq!( + pop.type_filters, expected_population_types, + "every leg must rank against the FULL disjunctive population, not \ + just {own_type:?}" + ); + } + } + + /// enclosing noun phrase, so it must NOT inherit "nonland permanent". + #[test] + fn among_form_population_still_comes_from_the_explicit_set() { + let (filter, _) = + parse_target("target creature with the greatest power among creatures you control"); + let (_, population) = bare_superlative_parts(&filter); + let pop = typed_leg(population).expect("typed population"); + assert!(pop.type_filters.contains(&TypeFilter::Creature)); + assert_eq!(pop.controller, Some(ControllerRef::You)); + } + + /// CR 109.2a — a description containing "card" plus a zone names cards in that + /// zone, a population this change does not model, so the superlative must NOT + /// be materialized. + /// + /// Reach-guarded: the same filter must still carry the graveyard zone prop, + /// proving the phrase really was parsed and only the superlative was declined — + /// without that guard this assertion would pass on any total parse failure. + #[test] + fn card_in_nonbattlefield_zone_does_not_materialize_a_superlative() { + let (filter, _) = + parse_target("target creature card in your graveyard with the greatest power"); + let tf = typed_leg(&filter).expect("typed"); + assert!( + tf.properties.iter().any(|p| matches!( + p, + FilterProp::InZone { + zone: Zone::Graveyard + } + )), + "reach-guard: the graveyard zone must still be parsed, got {:?}", + tf.properties + ); + assert!( + !tf.properties.iter().any(|p| matches!( + p, + FilterProp::Cmc { + value: QuantityExpr::Ref { + qty: QuantityRef::Aggregate { .. } + }, + .. + } | FilterProp::PtComparison { + value: QuantityExpr::Ref { + qty: QuantityRef::Aggregate { .. } + }, + .. + } + )), + "CR 109.2a: no superlative may be emitted for a card-in-zone population, got {:?}", + tf.properties + ); + } + + /// The head's `not(alphanumeric1)` word boundary: a longer word starting with a + /// property keyword must not half-match. Reach-guarded by the positive twin so + /// the negative cannot pass because the phrase failed to parse at all. + #[test] + fn bare_superlative_head_requires_a_word_boundary() { + assert!( + nom_filter::parse_superlative_property_head("with the greatest powerstone").is_err(), + "\"powerstone\" must not half-match the \"power\" property keyword" + ); + assert!( + nom_filter::parse_superlative_property_head("with the greatest power").is_ok(), + "positive twin: the bare property keyword must still parse" + ); + } + + /// Every superlative adjective maps to the right aggregate direction through + /// the relocated shared atom, for every property axis. + #[test] + fn bare_superlative_head_covers_both_axes() { + for (word, want_fn) in [ + ("greatest", AggregateFunction::Max), + ("highest", AggregateFunction::Max), + ("least", AggregateFunction::Min), + ("lowest", AggregateFunction::Min), + ("smallest", AggregateFunction::Min), + ] { + for (noun, want_prop) in [ + ("power", ObjectProperty::Power), + ("toughness", ObjectProperty::Toughness), + ("mana value", ObjectProperty::ManaValue), + ] { + let text = format!("with the {word} {noun}"); + let (_, (f, p)) = nom_filter::parse_superlative_property_head(&text) + .unwrap_or_else(|e| panic!("{text:?} should parse: {e:?}")); + assert_eq!(f, want_fn, "failed for {text:?}"); + assert_eq!(p, want_prop, "failed for {text:?}"); + } + } + } + + /// CR 109.2 — the look-ahead that stops the superlative being CONSUMED when a + /// non-battlefield zone clause still lies ahead. Refusing only later, after + /// consumption, would leave a filter that looks supported with its ranked + /// restriction silently gone — the exact defect this change removes. + /// + /// Tested at the guard rather than through `parse_target`, because the zone + /// passes that would populate an `InZone` prop run later in the enclosing + /// phrase parser; asserting on the guard is what makes this non-vacuous. + #[test] + fn nonbattlefield_zone_lookahead_gates_superlative_consumption() { + for ahead in [ + " in your graveyard", + " in exile", + " in your hand", + " in a graveyard to your hand", + ] { + assert!( + nonbattlefield_zone_clause_lies_ahead(ahead), + "{ahead:?} must be recognized as a non-battlefield zone clause ahead" + ); + } + // Battlefield is the CR 109.2 default, so it must NOT block consumption — + // the positive twin that keeps the guard from refusing everything. + for ahead in [ + "", + " on the battlefield", + " you control", + " that's attacking", + ] { + assert!( + !nonbattlefield_zone_clause_lies_ahead(ahead), + "{ahead:?} must not block the CR 109.2 battlefield default" + ); + } + } + + /// CR 109.2 — a trailing relative type clause must end up in the ranked + /// POPULATION, not merely on the candidates: ranking `[Permanent, Artifact]` + /// candidates against a `[Permanent]` population would select the wrong object. + /// + /// This is the CodeRabbit finding on PR #6789. Refusing the superlative instead + /// was tried and rejected — it left the whole tail unparsed and dropped the + /// TYPE clause too, which is worse than the bug being fixed. + #[test] + fn trailing_relative_type_clause_is_folded_into_the_population() { + let (filter, _) = + parse_target("target permanent with the greatest mana value that's an artifact"); + let tf = typed_leg(&filter).expect("typed"); + // Reach-guard: the relative clause reached the candidate filter. + assert!( + tf.type_filters.contains(&TypeFilter::Artifact), + "reach-guard: the \"that's an artifact\" clause must reach the candidate \ + filter, got {:?}", + tf.type_filters + ); + let (_, population) = bare_superlative_parts(&filter); + let pop = typed_leg(population).expect("typed population"); + assert!( + pop.type_filters.contains(&TypeFilter::Artifact) + && pop.type_filters.contains(&TypeFilter::Permanent), + "the population must carry the SAME type set as the candidates, got {:?}", + pop.type_filters + ); + } } diff --git a/crates/engine/tests/integration/culling_scales_lowest_mana_value_target.rs b/crates/engine/tests/integration/culling_scales_lowest_mana_value_target.rs new file mode 100644 index 0000000000..7461fc1a5d --- /dev/null +++ b/crates/engine/tests/integration/culling_scales_lowest_mana_value_target.rs @@ -0,0 +1,266 @@ +//! Backlog root cause 1 — "relative-clause / filter restriction on target dropped". +//! +//! Culling Scales: "At the beginning of your upkeep, destroy target nonland +//! permanent with the lowest mana value." +//! +//! A postnominal superlative qualifier with no trailing `among ` clause was +//! silently dropped by the target grammar, so the emitted filter was +//! `Typed { type_filters: [Permanent, Non(Land)], properties: [] }` — zero +//! `Effect::Unimplemented`, zero parse warnings, and the trigger could destroy +//! ANY nonland permanent rather than only a lowest-mana-value one. +//! +//! CR 109.2: a description with no zone clause and no "card" means permanents on +//! the battlefield, so the ranked population is the ENCLOSING noun phrase — +//! every nonland permanent. CR 601.2c: the controller announces one legal target, +//! and because the comparison is `EQ` against the population's minimum, EVERY +//! permanent tied for lowest is legal. +//! +//! This test drives the real trigger pipeline and asserts on the engine's own +//! `legal_targets` at `WaitingFor::TriggerTargetSelection` — the target-legality +//! boundary (CR 608.2b). Reverting the parser change makes the higher-mana-value +//! permanents legal again and the assertions fail. + +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaColor, ManaCost}; +use engine::types::phase::Phase; + +/// Culling Scales, verbatim (reminder text included — it is stripped by the parser). +const CULLING_SCALES: &str = "At the beginning of your upkeep, destroy target nonland permanent with the lowest mana value. (If two or more permanents are tied for lowest, target any one of them.)"; + +/// Drive forward until the engine pauses on a trigger target selection, passing +/// priority and declining combat. Mirrors +/// `magus_of_the_abyss_scoped_chooser.rs::advance_to_trigger_target_selection`. +fn advance_to_trigger_target_selection(runner: &mut GameRunner) { + for _ in 0..240 { + match &runner.state().waiting_for { + WaitingFor::TriggerTargetSelection { .. } => return, + WaitingFor::Priority { .. } => { + if runner.act(GameAction::PassPriority).is_err() { + return; + } + } + WaitingFor::DeclareAttackers { .. } => { + if runner + .act(GameAction::DeclareAttackers { + attacks: vec![], + bands: vec![], + }) + .is_err() + { + return; + } + } + WaitingFor::DeclareBlockers { .. } => { + if runner + .act(GameAction::DeclareBlockers { + assignments: vec![], + }) + .is_err() + { + return; + } + } + WaitingFor::DiscardToHandSize { + count, ref cards, .. + } => { + let chosen: Vec<_> = cards.iter().take(*count).copied().collect(); + if runner + .act(GameAction::SelectCards { cards: chosen }) + .is_err() + { + return; + } + } + _ => return, + } + } +} + +/// The engine's announced legal targets for the paused trigger, as object ids. +fn legal_target_ids(runner: &GameRunner) -> Vec { + match &runner.state().waiting_for { + WaitingFor::TriggerTargetSelection { target_slots, .. } => target_slots + .iter() + .flat_map(|slot| slot.legal_targets.iter()) + .filter_map(|t| match t { + engine::types::ability::TargetRef::Object(id) => Some(*id), + _ => None, + }) + .collect(), + other => panic!("expected TriggerTargetSelection, got {other:?}"), + } +} + +/// CR 109.2 + CR 601.2c + CR 608.2b: only the lowest-mana-value nonland permanent +/// is a legal target. The two higher-cost permanents must be excluded, and the +/// Land must be excluded by the type conjunction. +#[test] +fn culling_scales_offers_only_the_lowest_mana_value_nonland_permanent() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + // Stock libraries so no one decks out before P0's next upkeep (CR 704.5b). + let deck = ["Forest"; 12]; + scenario.with_library_top(P0, &deck); + scenario.with_library_top(P1, &deck); + + // The Scales itself is MV 3 and is a nonland permanent, so it is inside its + // own ranked population — but not the minimum here. + scenario + .add_creature_from_oracle(P0, "Culling Scales", 1, 1, CULLING_SCALES) + .with_mana_cost(ManaCost::generic(3)); + + // FOOT-GUN: add_creature does not set mana_cost, so every fixture permanent + // needs an explicit one or they all tie at MV 0 and the test proves nothing. + // Deliberate TIE at the population minimum. With a single legal target the + // engine auto-targets and never surfaces `TriggerTargetSelection`, so a tie is + // what makes the legal-set assertion observable at all (CR 601.2c: every + // permanent tied for lowest is legal, per the card's own reminder text). + let cheap = scenario + .add_creature(P1, "Cheap Bear", 2, 2) + .with_mana_cost(ManaCost::generic(1)) + .id(); + let cheap_twin = scenario + .add_creature(P0, "Cheap Twin", 1, 1) + .with_mana_cost(ManaCost::generic(1)) + .id(); + let mid = scenario + .add_creature(P1, "Mid Bear", 3, 3) + .with_mana_cost(ManaCost::generic(4)) + .id(); + let dear = scenario + .add_creature(P0, "Dear Bear", 5, 5) + .with_mana_cost(ManaCost::generic(6)) + .id(); + + // A Land must be excluded by the `Non(Land)` leg regardless of its mana value. + // It is given MV 0 — BELOW the tie — so the exclusion can only come from the + // type conjunction, never from losing the mana-value comparison. + let land = scenario.add_basic_land(P0, ManaColor::Green); + + let mut runner = scenario.build(); + + advance_to_trigger_target_selection(&mut runner); + assert!( + matches!( + runner.state().waiting_for, + WaitingFor::TriggerTargetSelection { .. } + ), + "the upkeep trigger must pause for target selection, got {:?}", + runner.state().waiting_for + ); + + let legal = legal_target_ids(&runner); + // Reach-guard: the trigger really did offer targets, so the exclusions below + // cannot pass vacuously on an empty slot. + assert!( + !legal.is_empty(), + "reach-guard: the trigger must offer at least one legal target" + ); + assert!( + legal.contains(&cheap) && legal.contains(&cheap_twin), + "both MV 1 permanents tie for the population minimum and must be legal; legal={legal:?}" + ); + assert!( + !legal.contains(&mid), + "MV 4 is not the lowest — reverting the parser fix makes this legal again" + ); + assert!( + !legal.contains(&dear), + "MV 6 is not the lowest — reverting the parser fix makes this legal again" + ); + assert!( + !legal.contains(&land), + "a Land at MV 0 is below the tie yet must still be excluded by the \ + Non(Land) leg of the noun phrase; legal={legal:?}" + ); +} + +/// Culling Scales' own shape carries no relative clause, so this row covers the +/// building block one step out: a MULTI-type trailing relative clause, where the +/// noun phrase spreads into one `Or` leg per type (maintainer review on PR #6789). +/// +/// CR 109.2: the ranked population is the candidate set — here "permanents that are +/// artifacts or creatures" — expressed as a single conjunctive `TypeFilter::AnyOf` +/// (`base ∧ (A ∨ B) == (base ∧ A) ∪ (base ∧ B)`), and evaluated at runtime by +/// `type_filter_matches` (`game/filter.rs`). +/// +/// The Land is the discriminator that separates the two failure modes this test +/// must tell apart: +/// * aggregate dropped entirely (the pre-fix bug) → the MV 5 creature is legal; +/// * aggregate present but ranked over `[Permanent]` only (the population/candidate +/// mismatch) → the Land's MV 0 sets the bar, no artifact or creature matches it, +/// so there is no legal target at all and the trigger never pauses for selection. +/// +/// Both modes were confirmed to fail by patching them in and re-running. +#[test] +fn multi_type_relative_clause_ranks_over_the_disjunctive_population_at_runtime() { + const MULTI_TYPE: &str = "At the beginning of your upkeep, destroy target permanent \ + with the lowest mana value that's an artifact or creature."; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let deck = ["Forest"; 12]; + scenario.with_library_top(P0, &deck); + scenario.with_library_top(P1, &deck); + + // MV 3, and a creature — so inside its own population, but not the minimum. + scenario + .add_creature_from_oracle(P0, "Culling Scales", 1, 1, MULTI_TYPE) + .with_mana_cost(ManaCost::generic(3)); + + // Deliberate TIE at the population minimum, one member per `Or` leg: a + // non-creature artifact and a non-artifact creature. A tie is required or the + // engine auto-targets the sole legal object and never pauses. + let artifact = scenario + .add_creature(P1, "Cheap Relic", 1, 1) + .as_artifact() + .with_mana_cost(ManaCost::generic(2)) + .id(); + let creature = scenario + .add_creature(P0, "Cheap Bear", 2, 2) + .with_mana_cost(ManaCost::generic(2)) + .id(); + let expensive = scenario + .add_creature(P1, "Pricey Bear", 4, 4) + .with_mana_cost(ManaCost::generic(5)) + .id(); + // MV 0 — BELOW the tie, and neither an artifact nor a creature, so it must be + // outside both the candidate set and the ranked population. + let land = scenario.add_basic_land(P0, ManaColor::Green); + + let mut runner = scenario.build(); + + advance_to_trigger_target_selection(&mut runner); + assert!( + matches!( + runner.state().waiting_for, + WaitingFor::TriggerTargetSelection { .. } + ), + "the upkeep trigger must pause for target selection, got {:?}", + runner.state().waiting_for + ); + + let legal = legal_target_ids(&runner); + assert!( + !legal.is_empty(), + "reach-guard: ranking over the disjunctive population must leave the MV 2 tie \ + legal; an empty set means the Land's MV 0 set the bar, i.e. the population \ + was the wider [Permanent] set" + ); + assert!( + legal.contains(&artifact) && legal.contains(&creature), + "both MV 2 permanents tie for the population minimum, one per Or leg; legal={legal:?}" + ); + assert!( + !legal.contains(&expensive), + "MV 5 is not the lowest — dropping the aggregate makes this legal again" + ); + assert!( + !legal.contains(&land), + "a Land is neither an artifact nor a creature and must be excluded from the \ + candidates; legal={legal:?}" + ); +} diff --git a/crates/engine/tests/integration/favor_of_the_mighty_greatest_mana_value_protection.rs b/crates/engine/tests/integration/favor_of_the_mighty_greatest_mana_value_protection.rs new file mode 100644 index 0000000000..bb764fb835 --- /dev/null +++ b/crates/engine/tests/integration/favor_of_the_mighty_greatest_mana_value_protection.rs @@ -0,0 +1,121 @@ +//! Backlog root cause 1, at the STATIC-ABILITY boundary. +//! +//! Favor of the Mighty: "Each creature with the greatest mana value has +//! protection from each color." +//! +//! The bare postnominal superlative (no trailing `among ` clause) was +//! dropped by the target/filter grammar, so this whole line failed the static +//! parser and lowered to `Effect::Unimplemented { name: "static_structure" }`. +//! Recognizing the superlative takes the card to zero `Unimplemented` — which is +//! exactly why it needs a runtime test rather than an AST assertion: a card that +//! newly reports as *supported* must be demonstrably correct at the boundary that +//! actually consumes it. +//! +//! That boundary is NOT target legality (this ability targets nothing). It is +//! CR 613 layer evaluation of `StaticDefinition.affected`, which re-derives the +//! affected set on every layer pass. So the discriminating property is not just +//! "the highest-mana-value creature has protection" but that the protection +//! MOVES when a bigger creature arrives — the continuous re-check. +//! +//! CR 109.2: with no zone clause and no "card", the ranked population is +//! battlefield creatures. CR 611.3a: a static ability's continuous effect isn't +//! locked in — it is reapplied as the game state changes. + +use engine::game::layers::evaluate_layers; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::identifiers::ObjectId; +use engine::types::keywords::{Keyword, ProtectionTarget}; +use engine::types::mana::{ManaColor, ManaCost}; +use engine::types::phase::Phase; + +/// Favor of the Mighty, verbatim (the whole card is this one line). +const FAVOR_OF_THE_MIGHTY: &str = + "Each creature with the greatest mana value has protection from each color."; + +/// True iff `id` carries layer-baked protection from white after a fresh layer +/// pass. Protection from *each* color grants one `Protection(Color)` keyword per +/// color; white is a representative probe. +fn has_protection_from_white(runner: &mut GameRunner, id: ObjectId) -> bool { + runner.state_mut().layers_dirty.mark_full(); + evaluate_layers(runner.state_mut()); + runner.state().objects[&id] + .keywords + .contains(&Keyword::Protection(ProtectionTarget::Color( + ManaColor::White, + ))) +} + +/// CR 109.2 + CR 613.1f + CR 611.3a: protection lands on the greatest-mana-value +/// creature, not on the others — and it MOVES when a larger creature enters. +/// +/// Reverting the parser change makes the whole line `Unimplemented`, so no +/// creature gets protection at all and the first assertion fails. +#[test] +fn favor_of_the_mighty_protects_only_the_greatest_mana_value_creature() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.add_creature_from_oracle(P0, "Favor of the Mighty", 0, 0, FAVOR_OF_THE_MIGHTY); + + // FOOT-GUN: `add_creature` does not set `mana_cost`, so every fixture + // permanent needs an explicit one or they all tie at MV 0 and the ranking is + // vacuous. + let small = scenario + .add_creature(P0, "Small Bear", 2, 2) + .with_mana_cost(ManaCost::generic(2)) + .id(); + let big = scenario + .add_creature(P1, "Big Bear", 4, 4) + .with_mana_cost(ManaCost::generic(5)) + .id(); + + let mut runner = scenario.build(); + + // The population is global (no "you control" in the noun phrase), so the + // opponent's creature can be the one protected — that is the correct reading + // of "Each creature with the greatest mana value". + assert!( + has_protection_from_white(&mut runner, big), + "the MV 5 creature is the population maximum and must gain protection; \ + no protection at all means the line is still Unimplemented" + ); + assert!( + !has_protection_from_white(&mut runner, small), + "the MV 2 creature is not the maximum and must NOT gain protection" + ); + + // CR 611.3a: the affected set is re-derived, not locked in. A newly-entering + // larger creature takes the protection over, and the previous holder loses it. + let bigger = { + let card_id = engine::types::identifiers::CardId(runner.state().next_object_id); + let id = engine::game::zones::create_object( + runner.state_mut(), + card_id, + P0, + "Bigger Bear".to_string(), + engine::types::zones::Zone::Battlefield, + ); + let obj = runner.state_mut().objects.get_mut(&id).unwrap(); + obj.card_types + .core_types + .push(engine::types::card_type::CoreType::Creature); + obj.base_card_types = obj.card_types.clone(); + obj.power = Some(6); + obj.toughness = Some(6); + obj.base_power = Some(6); + obj.base_toughness = Some(6); + obj.mana_cost = ManaCost::generic(8); + obj.base_mana_cost = obj.mana_cost.clone(); + obj.summoning_sick = false; + id + }; + + assert!( + has_protection_from_white(&mut runner, bigger), + "the new MV 8 creature is now the population maximum and must gain protection" + ); + assert!( + !has_protection_from_white(&mut runner, big), + "CR 611.3a: the former maximum must LOSE protection once a larger creature \ + enters — a snapshotted affected set would wrongly keep it" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 1fe781fb5d..28e8036c39 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -117,6 +117,7 @@ mod cr_annotations; mod craft_tithing_blade_transform; mod cross_line_instead_override_branch; mod crossway_troublemakers_attacking_keywords; +mod culling_scales_lowest_mana_value_target; mod cultivate_split_destination; mod cumber_stone_opponent_debuff; mod cunning_rhetoric_attacks_you_scope_4736; @@ -180,6 +181,7 @@ mod exquisite_blood_routing; mod eyetwitch_learn_decline_lesson; mod fact_or_fiction_pile_separation; mod fateful_handoff_target_mana_value_draw; +mod favor_of_the_mighty_greatest_mana_value_protection; mod felisa_fang_of_silverquill; mod festival_of_embers_graveyard_additional_cost; mod fevered_visions; From 895de40cace4ad49ef688562e8e3714d9e8e7b68 Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:52:21 -0700 Subject: [PATCH 15/63] chore(card-data): refresh MTGJSON token & subtype catalogs (#6791) --- crates/engine/data/known-tokens.toml | 27 +++++++++++++++++++++++++++ crates/engine/data/mtgjson-vintage | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/engine/data/known-tokens.toml b/crates/engine/data/known-tokens.toml index 30f1b51be8..c6fd39e704 100644 --- a/crates/engine/data/known-tokens.toml +++ b/crates/engine/data/known-tokens.toml @@ -13086,6 +13086,7 @@ source_card_names = [ "Grave Titan", "Gravedig", "Graveyard Marshal", + "Grim Reaper's Scythe", "Grixis Slavedriver", "Havengul Runebinder", "Headless Rider", @@ -96460,13 +96461,16 @@ source_card_names = [ "Collector's Vault", "Common Crook", "Contested Game Ball", + "Contract Hero", "Contract Killing", "Corsair Captain", "Crack Open", "Creative Outburst", + "Crossover Collaboration", "Culmination of Studies", "Currency Converter", "Cutthroat Negotiator", + "Daily Bugle Newspaper", "Deadeye Plunderers", "Deadly Derision", "Deadly Dispute", @@ -96532,6 +96536,7 @@ source_card_names = [ "Hell to Pay", "Henry Wu, InGen Geneticist", "Herald of Hadar", + "Heroes for Hire", "Hit the Mother Lode", "Hoard Hauler", "Hoard Robber", @@ -96559,6 +96564,7 @@ source_card_names = [ "Kerblam! Warehouse", "Kheru Goldkeeper", "Killmonger, Ruthless Usurper", + "Kingpin, Wilson Fisk", "Korvold and the Noble Thief", "Life Insurance", "Lobelia Sackville-Baggins", @@ -96566,6 +96572,7 @@ source_card_names = [ "Loot Dispute", "Lotho, Corrupt Shirriff", "Luck Bobblehead", + "Luke Cage, Hero for Hire", "Luxurious Locomotive", "Magda, Brazen Outlaw", "Magda, the Hoardmaster", @@ -144052,6 +144059,7 @@ source_card_names = [ "Grave Titan", "Gravedig", "Graveyard Marshal", + "Grim Reaper's Scythe", "Grixis Slavedriver", "Havengul Runebinder", "Headless Rider", @@ -163907,6 +163915,7 @@ source_card_names = [ "Grave Titan", "Gravedig", "Graveyard Marshal", + "Grim Reaper's Scythe", "Grixis Slavedriver", "Havengul Runebinder", "Headless Rider", @@ -167944,13 +167953,16 @@ source_card_names = [ "Collector's Vault", "Common Crook", "Contested Game Ball", + "Contract Hero", "Contract Killing", "Corsair Captain", "Crack Open", "Creative Outburst", + "Crossover Collaboration", "Culmination of Studies", "Currency Converter", "Cutthroat Negotiator", + "Daily Bugle Newspaper", "Deadeye Plunderers", "Deadly Derision", "Deadly Dispute", @@ -168016,6 +168028,7 @@ source_card_names = [ "Hell to Pay", "Henry Wu, InGen Geneticist", "Herald of Hadar", + "Heroes for Hire", "Hit the Mother Lode", "Hoard Hauler", "Hoard Robber", @@ -168043,6 +168056,7 @@ source_card_names = [ "Kerblam! Warehouse", "Kheru Goldkeeper", "Killmonger, Ruthless Usurper", + "Kingpin, Wilson Fisk", "Korvold and the Noble Thief", "Life Insurance", "Lobelia Sackville-Baggins", @@ -168050,6 +168064,7 @@ source_card_names = [ "Loot Dispute", "Lotho, Corrupt Shirriff", "Luck Bobblehead", + "Luke Cage, Hero for Hire", "Luxurious Locomotive", "Magda, Brazen Outlaw", "Magda, the Hoardmaster", @@ -190291,13 +190306,16 @@ source_card_names = [ "Collector's Vault", "Common Crook", "Contested Game Ball", + "Contract Hero", "Contract Killing", "Corsair Captain", "Crack Open", "Creative Outburst", + "Crossover Collaboration", "Culmination of Studies", "Currency Converter", "Cutthroat Negotiator", + "Daily Bugle Newspaper", "Deadeye Plunderers", "Deadly Derision", "Deadly Dispute", @@ -190363,6 +190381,7 @@ source_card_names = [ "Hell to Pay", "Henry Wu, InGen Geneticist", "Herald of Hadar", + "Heroes for Hire", "Hit the Mother Lode", "Hoard Hauler", "Hoard Robber", @@ -190390,6 +190409,7 @@ source_card_names = [ "Kerblam! Warehouse", "Kheru Goldkeeper", "Killmonger, Ruthless Usurper", + "Kingpin, Wilson Fisk", "Korvold and the Noble Thief", "Life Insurance", "Lobelia Sackville-Baggins", @@ -190397,6 +190417,7 @@ source_card_names = [ "Loot Dispute", "Lotho, Corrupt Shirriff", "Luck Bobblehead", + "Luke Cage, Hero for Hire", "Luxurious Locomotive", "Magda, Brazen Outlaw", "Magda, the Hoardmaster", @@ -197357,13 +197378,16 @@ source_card_names = [ "Collector's Vault", "Common Crook", "Contested Game Ball", + "Contract Hero", "Contract Killing", "Corsair Captain", "Crack Open", "Creative Outburst", + "Crossover Collaboration", "Culmination of Studies", "Currency Converter", "Cutthroat Negotiator", + "Daily Bugle Newspaper", "Deadeye Plunderers", "Deadly Derision", "Deadly Dispute", @@ -197429,6 +197453,7 @@ source_card_names = [ "Hell to Pay", "Henry Wu, InGen Geneticist", "Herald of Hadar", + "Heroes for Hire", "Hit the Mother Lode", "Hoard Hauler", "Hoard Robber", @@ -197456,6 +197481,7 @@ source_card_names = [ "Kerblam! Warehouse", "Kheru Goldkeeper", "Killmonger, Ruthless Usurper", + "Kingpin, Wilson Fisk", "Korvold and the Noble Thief", "Life Insurance", "Lobelia Sackville-Baggins", @@ -197463,6 +197489,7 @@ source_card_names = [ "Loot Dispute", "Lotho, Corrupt Shirriff", "Luck Bobblehead", + "Luke Cage, Hero for Hire", "Luxurious Locomotive", "Magda, Brazen Outlaw", "Magda, the Hoardmaster", diff --git a/crates/engine/data/mtgjson-vintage b/crates/engine/data/mtgjson-vintage index c8dd300d77..763f196f14 100644 --- a/crates/engine/data/mtgjson-vintage +++ b/crates/engine/data/mtgjson-vintage @@ -1 +1 @@ -2026-07-28 +2026-07-29 From 3658a21d4c527b9d01a98223fb3bfe5c8f4fd09d Mon Sep 17 00:00:00 2001 From: invalidCards <842080+invalidCards@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:45:35 +0200 Subject: [PATCH 16/63] Add Deathbringer Regent (#6746) * feat(parser): add Deathbringer Regent * fix(parser): preserve condition clause remainders * fix(parser): preserve condition clause remainders --- .../engine/src/parser/oracle_nom/condition.rs | 66 +++- .../engine/src/parser/oracle_nom/quantity.rs | 113 +++++-- crates/engine/src/parser/oracle_trigger.rs | 78 ++--- .../engine/src/parser/oracle_trigger_tests.rs | 122 ++++++++ .../tests/integration/l02_bb2_cast_origin.rs | 285 +++++++++++++++++- 5 files changed, 607 insertions(+), 57 deletions(-) diff --git a/crates/engine/src/parser/oracle_nom/condition.rs b/crates/engine/src/parser/oracle_nom/condition.rs index fe909bd261..084371bb9a 100644 --- a/crates/engine/src/parser/oracle_nom/condition.rs +++ b/crates/engine/src/parser/oracle_nom/condition.rs @@ -7903,6 +7903,26 @@ fn parse_entered_this_turn_under_opponent_control( /// continuous "as long as" gates), both of which re-read the condition against /// live game state at check time. fn parse_there_are_conditions(input: &str) -> OracleResult<'_, StaticCondition> { + parse_there_are_conditions_with_quantity(input, nom_quantity::parse_quantity_ref) +} + +/// Narrow fallback for a caller that owns the comma separating an +/// intervening-if from its trigger effect. The caller first tries the complete +/// generic condition grammar; this only handles the strict battlefield-count +/// noun phrase followed by that clause boundary. +pub(crate) fn parse_there_are_battlefield_count_clause( + input: &str, +) -> OracleResult<'_, StaticCondition> { + parse_there_are_conditions_with_quantity( + input, + nom_quantity::parse_type_count_on_battlefield_clause, + ) +} + +fn parse_there_are_conditions_with_quantity( + input: &str, + parse_quantity: for<'a> fn(&'a str) -> OracleResult<'a, QuantityRef>, +) -> OracleResult<'_, StaticCondition> { let (rest, _) = tag("there are ").parse(input)?; let (rest, prefix) = opt(parse_strict_comparator_prefix).parse(rest)?; let (rest, n) = parse_number(rest)?; @@ -7934,7 +7954,7 @@ fn parse_there_are_conditions(input: &str) -> OracleResult<'_, StaticCondition> ), )); } - let (rest, qty) = nom_quantity::parse_quantity_ref.parse(rest)?; + let (rest, qty) = parse_quantity(rest)?; Ok(( rest, make_quantity_comparison( @@ -15680,6 +15700,50 @@ mod tests { } } + /// CR 603.4 + CR 109.2: Deathbringer Regent's threshold counts any + /// player's other creatures on the battlefield when given its complete + /// condition clause. + #[test] + fn deathbringer_regent_noun_clause_is_battlefield_scoped() { + let (rest, c) = + parse_inner_condition("there are five or more other creatures on the battlefield") + .unwrap(); + assert_eq!(rest, ""); + match c { + StaticCondition::QuantityComparison { + lhs: + QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { filter }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 5 }, + } => match filter { + TargetFilter::Typed(tf) => { + assert!( + tf.type_filters.contains(&TypeFilter::Creature), + "expected creature filter, got {:?}", + tf.type_filters + ); + assert_eq!(tf.controller, None); + assert!( + tf.properties.contains(&FilterProp::Another), + "expected Another prop, got {:?}", + tf.properties + ); + assert!( + tf.properties.contains(&FilterProp::InZone { + zone: Zone::Battlefield + }), + "expected battlefield-only count, got {:?}", + tf.properties + ); + } + other => panic!("expected typed creature filter, got {other:?}"), + }, + other => panic!("expected ObjectCount GE 5, got {other:?}"), + } + } + /// SEMANTIC CORRECTNESS: the there-form scopes to exactly the stated /// objects. "there are no Zombies on the battlefield" (Sarcomancy) counts /// only the Zombie subtype, NOT all creatures. diff --git a/crates/engine/src/parser/oracle_nom/quantity.rs b/crates/engine/src/parser/oracle_nom/quantity.rs index a1f8026d37..d1bcb09d8e 100644 --- a/crates/engine/src/parser/oracle_nom/quantity.rs +++ b/crates/engine/src/parser/oracle_nom/quantity.rs @@ -7,7 +7,7 @@ use crate::parser::oracle_nom::error::OracleError; use nom::branch::alt; use nom::bytes::complete::{tag, take_until, take_while1}; -use nom::combinator::{all_consuming, map, map_res, opt, value}; +use nom::combinator::{all_consuming, eof, map, map_res, opt, peek, value}; use nom::multi::separated_list1; use nom::sequence::{pair, preceded, terminated}; use nom::Parser; @@ -940,7 +940,7 @@ pub fn parse_quantity_ref(input: &str) -> OracleResult<'_, QuantityRef> { // extremum. Distinctive "the player with the " prefix; no ordering // hazard with sibling arms. parse_player_with_extremum_cards_in_hand, - // CR 118.9 / CR 601.3: bare " on the battlefield" object count. + // Bare " on the battlefield" object count. // Placed LAST (lowest priority) so every specific arm — notably // `parse_greatest_commander_mana_value_ref` for "the greatest mana value // of a commander you own on the battlefield" — wins first; this fallback @@ -951,34 +951,65 @@ pub fn parse_quantity_ref(input: &str) -> OracleResult<'_, QuantityRef> { .parse(input) } -/// CR 118.9 + CR 601.3: " on the battlefield" → count of matching objects -/// on the battlefield. The GE / "N or more" sibling of `parse_no_on_battlefield` +/// " on the battlefield" → count of matching objects on the battlefield. +/// The GE / "N or more" sibling of `parse_no_on_battlefield` /// (`oracle_nom/condition.rs`, which emits the `== 0` form); reached after a /// parent combinator (`parse_there_are_conditions`) has consumed the "there are /// N or more " quantifier, leaving the bare noun phrase (Blasphemous Edict's /// "creatures on the battlefield"). /// -/// The full phrase is decoded by `parse_type_phrase`, so the " on the -/// battlefield" locative is re-attached as `FilterProp::InZone { Battlefield }` -/// exactly as the for-each battlefield-count fallback does -/// (`oracle_quantity::parse_type_phrase_with_ctx`) — this arm therefore produces -/// byte-identical `ObjectCount` filters for any for-each clause it now intercepts -/// at the shared `parse_quantity_ref` seam. Guarded two ways so it stays strictly -/// additive on that high-traffic surface: the phrase must END with the battlefield -/// locative (rejecting "…on the battlefield or in the command zone", which its -/// dedicated commander-mana-value arm claims), and the type phrase must fully -/// consume and be non-`Any`. +/// The phrase must be fully consumed here. Context-specific callers that own a +/// clause boundary (such as cast-and-condition trigger parsing) split it before +/// using this shared grammar. The competing zone-disjunction form remains +/// rejected so its dedicated commander-mana-value arm can claim it. fn parse_type_count_on_battlefield(input: &str) -> OracleResult<'_, QuantityRef> { + parse_type_count_on_battlefield_with_boundary(input, BattlefieldCountBoundary::CompletePhrase) +} + +/// The same count grammar when the enclosing caller owns a comma-delimited +/// clause boundary. Keep this separate from `parse_quantity_ref`: generic +/// condition extraction must not consume an effect tail as a quantity suffix. +pub(crate) fn parse_type_count_on_battlefield_clause(input: &str) -> OracleResult<'_, QuantityRef> { + parse_type_count_on_battlefield_with_boundary(input, BattlefieldCountBoundary::Clause) +} + +enum BattlefieldCountBoundary { + CompletePhrase, + Clause, +} + +fn parse_type_count_on_battlefield_with_boundary( + input: &str, + boundary: BattlefieldCountBoundary, +) -> OracleResult<'_, QuantityRef> { let (after_anchor, _) = take_until(" on the battlefield").parse(input)?; - let (after_anchor, _) = tag(" on the battlefield").parse(after_anchor)?; - if !after_anchor.trim().is_empty() { - return Err(oracle_err(input)); - } - let (filter, type_rest) = parse_type_phrase(input); + let (rest, _) = tag(" on the battlefield").parse(after_anchor)?; + let (type_text, needs_battlefield_presence) = match boundary { + BattlefieldCountBoundary::CompletePhrase => { + if !rest.trim().is_empty() { + return Err(oracle_err(input)); + } + (input, false) + } + BattlefieldCountBoundary::Clause => { + let (rest, _) = peek(alt((eof, tag(",")))).parse(rest)?; + if rest.is_empty() { + (input, false) + } else { + (&input[..input.len() - after_anchor.len()], true) + } + } + }; + let (filter, type_rest) = parse_type_phrase(type_text); if matches!(filter, TargetFilter::Any) || !type_rest.trim().is_empty() { return Err(oracle_err(input)); } - Ok(("", QuantityRef::ObjectCount { filter })) + let filter = if needs_battlefield_presence { + super::condition::inject_battlefield_presence(filter) + } else { + filter + }; + Ok((rest, QuantityRef::ObjectCount { filter })) } /// CR 109.3 + CR 205.3m: Parse "the greatest/fewest/total number of @@ -5499,6 +5530,48 @@ mod tests { ); } + #[test] + fn type_count_on_battlefield_accepts_eof_tail() { + let (rest, parsed) = parse_type_count_on_battlefield("other creatures on the battlefield") + .expect("EOF terminates the noun clause"); + assert_eq!(rest, ""); + assert!(matches!(parsed, QuantityRef::ObjectCount { .. })); + } + + #[test] + fn type_count_on_battlefield_rejects_comma_tail() { + assert!(parse_type_count_on_battlefield( + "other creatures on the battlefield, then draw a card" + ) + .is_err()); + } + + #[test] + fn type_count_on_battlefield_clause_preserves_comma_tail() { + let (rest, parsed) = parse_type_count_on_battlefield_clause( + "other creatures on the battlefield, then draw a card", + ) + .expect("the caller owns the clause boundary"); + assert_eq!(rest, ", then draw a card"); + assert!(matches!(parsed, QuantityRef::ObjectCount { .. })); + } + + #[test] + fn type_count_on_battlefield_rejects_command_zone_disjunction() { + assert!(parse_type_count_on_battlefield( + "creatures on the battlefield or in the command zone" + ) + .is_err()); + } + + #[test] + fn type_count_on_battlefield_rejects_non_comma_textual_tail() { + assert!( + parse_type_count_on_battlefield("creatures on the battlefield then draw a card") + .is_err() + ); + } + #[test] fn for_each_opponent_dealt_damage_is_event_context_player_count() { for phrase in [ diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index b3f0c37884..bb818f7732 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -22,7 +22,9 @@ use super::oracle_ir::trigger::{ }; use super::oracle_modal::try_parse_inline_modal_ir; use super::oracle_nom::condition::parse_elided_subject_state_condition; -use super::oracle_nom::condition::parse_inner_condition; +use super::oracle_nom::condition::{ + parse_inner_condition, parse_there_are_battlefield_count_clause, +}; use super::oracle_nom::condition::{parse_source_counters_exist, parse_source_has_counters}; use super::oracle_nom::error::{oracle_err, OracleResult}; use super::oracle_nom::filter::{ @@ -4804,33 +4806,44 @@ fn parse_cast_from_zone_intervening_if(input: &str) -> OracleResult<'_, TriggerC Ok((rest, scoped_you_cast_from_zone(zone))) } -/// CR 603.4 + CR 404.1 + CR 205.3: "if you cast it/them and " -/// — cast-provenance AND a second conjunct (Ran and Shaw: "if you cast them and -/// there are three or more Dragon and/or Lesson cards in your graveyard"). The -/// "you cast it/them" conjunct binds the unscoped `WasCast` (matching the sibling -/// "if you cast it" convention at the bare handler below — the meaningful ETB -/// discriminator is cast vs put-into-play, and the caster controls their own -/// creature). The second conjunct is delegated to `parse_inner_condition` (the -/// shared game-state authority) and bridged via `static_condition_to_trigger_condition`, -/// so the graveyard multi-subtype "and/or" threshold (CR 404.1 owner-specific -/// graveyard, CR 205.3 subtypes) rides the existing `ZoneCardCount` path with no -/// new combinator. Both conjuncts fold into `TriggerCondition::And`, mirroring the -/// BB4 Fearless conjunction `parse_typed_attacked_this_combat_conjunction`. MUST be -/// registered before the bare "if you cast it" handler, which strips only the cast -/// conjunct and would drop the second clause. -fn parse_cast_them_and_graveyard_count_intervening_if( - input: &str, -) -> OracleResult<'_, TriggerCondition> { +/// CR 603.4 + CR 601.2: Parse "if you cast it/them [from ] and +/// " as a cast-provenance AND a second conjunct. The +/// game-state side is delegated to `parse_inner_condition` and bridged through +/// `static_condition_to_trigger_condition`, keeping all quantity/filter grammar +/// in its shared authority. A zoned cast uses the same caster/owner scoping as +/// the simple "if you cast it from " handler; the unzoned form preserves +/// the established bare `WasCast` representation used by Ran and Shaw. +/// +/// This must be registered before the simple positive zoned and bare cast +/// scanners: either scanner would otherwise consume only the cast prefix and +/// silently discard the trailing conjunct. +fn parse_cast_and_condition_intervening_if(input: &str) -> OracleResult<'_, TriggerCondition> { let (rest, _) = tag("if ").parse(input)?; let (rest, _) = alt((tag("you cast them"), tag("you cast it"))).parse(rest)?; + let (rest, zone) = opt(preceded( + tag(" from "), + alt(( + value(Zone::Hand, tag("your hand")), + value(Zone::Graveyard, tag("your graveyard")), + value(Zone::Exile, tag("exile")), + )), + )) + .parse(rest)?; let (rest, _) = tag(" and ").parse(rest)?; - let (rest, sc) = parse_inner_condition(rest)?; + // First use the complete shared condition grammar: some conditions own + // commas themselves (for example, multi-zone lists). Only the strict + // battlefield-count noun phrase needs the clause-aware fallback below. + let (rest, sc) = + parse_inner_condition(rest).or_else(|_| parse_there_are_battlefield_count_clause(rest))?; let cond_b = static_condition_to_trigger_condition(&sc).ok_or_else(|| oracle_err(input))?; - let cond_a = TriggerCondition::WasCast { - zone: None, - controller: None, - owner: None, - }; + let cond_a = zone.map_or( + TriggerCondition::WasCast { + zone: None, + controller: None, + owner: None, + }, + scoped_you_cast_from_zone, + ); Ok(( rest, TriggerCondition::And { @@ -4953,11 +4966,11 @@ fn extract_if_condition_with_card_name( ); } - // CR 603.4 + CR 601.2: "if you cast it from your hand/graveyard/exile" — - // positive zone-specific cast check. The entering object must have been cast - // from the named zone. MUST precede the zoneless "if you cast it" arm. + // CR 603.4 + CR 601.2: "if you cast it/them [from ] and ". This must precede both simple positive and bare cast scanners, + // which otherwise truncate the condition after its cast-provenance prefix. if let Some((before, condition, rest)) = - scan_preceded(&lower, parse_cast_from_zone_intervening_if) + scan_preceded(&lower, parse_cast_and_condition_intervening_if) { let pos = before.len(); let clause_len = lower.len() - before.len() - rest.len(); @@ -4967,13 +4980,10 @@ fn extract_if_condition_with_card_name( ); } - // CR 603.4 + CR 404.1 + CR 205.3: "if you cast it/them and " — cast-provenance AND a second conjunct (Ran and Shaw's - // graveyard-count gate). MUST precede the bare "if you cast it" handler - // below: that handler strips only the cast conjunct and would silently drop - // the trailing And clause, truncating the condition. + // CR 603.4 + CR 601.2: simple positive zone-specific cast check. Keep this + // after the conjunction scanner so an `and ` tail cannot be lost. if let Some((before, condition, rest)) = - scan_preceded(&lower, parse_cast_them_and_graveyard_count_intervening_if) + scan_preceded(&lower, parse_cast_from_zone_intervening_if) { let pos = before.len(); let clause_len = lower.len() - before.len() - rest.len(); diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index c39139b474..16c5194245 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -24,6 +24,128 @@ use crate::types::mana::{ManaColor, ManaCost, ManaType, ManaUnit}; use crate::types::replacements::ReplacementEvent; use crate::types::statics::{CastFrequency, StaticMode}; +#[test] +fn extract_hand_cast_battlefield_threshold_leaves_effect_text() { + let (cleaned, condition) = extract_if_condition( + "if you cast it from your hand and there are five or more other creatures on the battlefield, destroy all other creatures", + ); + assert_eq!(cleaned, "destroy all other creatures"); + + let TriggerCondition::And { conditions } = condition.expect("expected conjunction") else { + panic!("expected cast-and-threshold trigger condition"); + }; + assert_eq!(conditions.len(), 2); + assert!(matches!( + &conditions[0], + TriggerCondition::WasCast { + zone: Some(crate::types::zones::Zone::Hand), + controller: Some(ControllerRef::You), + owner: Some(ControllerRef::You), + } + )); + let TriggerCondition::QuantityComparison { + lhs: + QuantityExpr::Ref { + qty: + QuantityRef::ObjectCount { + filter: TargetFilter::Typed(filter), + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 5 }, + } = &conditions[1] + else { + panic!("expected other-creature threshold, got {:?}", conditions[1]); + }; + assert_eq!(filter.type_filters, vec![TypeFilter::Creature]); + assert!(filter + .properties + .contains(&FilterProp::OtherThanTriggerObject)); + assert!(filter.properties.contains(&FilterProp::InZone { + zone: crate::types::zones::Zone::Battlefield, + })); +} + +/// The zoned cast-and-condition parser shares its condition branch across all +/// supported origin zones. These focused extractor cases prove the graveyard +/// owner scope and shared-exile owner scope without inventing a runtime card. +#[test] +fn extract_zoned_cast_and_threshold_preserves_graveyard_and_exile_scopes() { + for (origin, expected_zone, expected_owner) in [ + ("your graveyard", Zone::Graveyard, Some(ControllerRef::You)), + ("exile", Zone::Exile, None), + ] { + let input = format!( + "if you cast it from {origin} and there are five or more other creatures on the battlefield, destroy all other creatures" + ); + let (cleaned, condition) = extract_if_condition(&input); + assert_eq!(cleaned, "destroy all other creatures"); + + let Some(TriggerCondition::And { conditions }) = condition else { + panic!("expected {origin} cast-and-threshold conjunction, got {condition:?}"); + }; + assert_eq!(conditions.len(), 2); + match &conditions[0] { + TriggerCondition::WasCast { + zone: Some(zone), + controller: Some(ControllerRef::You), + owner, + } => { + assert_eq!(zone, &expected_zone, "unexpected origin zone for {origin}"); + assert_eq!( + owner, &expected_owner, + "unexpected owner scope for {origin}" + ); + } + other => panic!("expected scoped WasCast for {origin}, got {other:?}"), + } + assert!(matches!( + &conditions[1], + TriggerCondition::QuantityComparison { + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 5 }, + .. + } + )); + } +} + +#[test] +fn extract_cast_and_condition_keeps_condition_internal_commas() { + let (cleaned, condition) = extract_if_condition( + "if you cast it and ~ is in your graveyard, in your hand, or in exile, draw a card", + ); + assert_eq!(cleaned, "draw a card"); + let Some(TriggerCondition::And { conditions }) = condition else { + panic!("expected cast-and-zone-list conjunction, got {condition:?}"); + }; + assert!(matches!(conditions[0], TriggerCondition::WasCast { .. })); + assert!(matches!( + &conditions[1], + TriggerCondition::Or { conditions: zones } if zones.len() == 3 + )); +} + +#[test] +fn extract_cast_and_condition_keeps_grouped_number() { + let (cleaned, condition) = extract_if_condition( + "if you cast it and there are 1,000 or more other creatures on the battlefield, destroy all other creatures", + ); + assert_eq!(cleaned, "destroy all other creatures"); + let Some(TriggerCondition::And { conditions }) = condition else { + panic!("expected cast-and-threshold conjunction, got {condition:?}"); + }; + assert!(matches!(conditions[0], TriggerCondition::WasCast { .. })); + assert!(matches!( + &conditions[1], + TriggerCondition::QuantityComparison { + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 1000 }, + .. + } + )); +} + // --- Fix B: damage-recipient qualifier (player axis preserved + object axis added) --- #[test] diff --git a/crates/engine/tests/integration/l02_bb2_cast_origin.rs b/crates/engine/tests/integration/l02_bb2_cast_origin.rs index a17c131d97..f960be5cbd 100644 --- a/crates/engine/tests/integration/l02_bb2_cast_origin.rs +++ b/crates/engine/tests/integration/l02_bb2_cast_origin.rs @@ -23,9 +23,10 @@ use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; use engine::parser::oracle::parse_oracle_text; use engine::parser::oracle_ir::diagnostic::OracleDiagnostic; use engine::types::ability::{ - AbilityCondition, Comparator, CountScope, QuantityExpr, QuantityRef, TriggerCondition, - TypeFilter, ZoneRef, + AbilityCondition, Comparator, CountScope, FilterProp, QuantityExpr, QuantityRef, TargetFilter, + TriggerCondition, TypeFilter, ZoneRef, }; +use engine::types::actions::GameAction; use engine::types::card_type::Supertype; use engine::types::counter::CounterType; use engine::types::game_state::CastingVariant; @@ -59,6 +60,10 @@ When Ran and Shaw enter, if you cast them and there are three or more Dragon and cards in your graveyard, create a token that's a copy of Ran and Shaw, except it's not legendary.\n\ {3}{R}: Dragons you control get +2/+0 until end of turn."; +const DEATHBRINGER_REGENT: &str = "Flying\nWhen this creature enters, if you cast it from your hand and there are five or more other creatures on the battlefield, destroy all other creatures."; + +const MURDER: &str = "Destroy target creature."; + /// A vanilla {0} reanimation sorcery: the entering permanent is *put onto the /// battlefield* (never cast), so its `cast_from_zone` stays `None` (CR 400.7). /// This is the Channel-B "put-into-play-not-cast" discriminator. @@ -249,6 +254,87 @@ fn ran_and_shaw_trigger_condition_is_cast_and_graveyard_count() { ); } +/// Deathbringer Regent: the generic cast-and-condition scanner must retain both +/// the owner/caster-scoped hand provenance and the shared quantity condition. +/// Revert-probe: remove its optional `from ` grammar or register it after +/// the simple zoned scanner → the condition is truncated or swallowed. +#[test] +fn deathbringer_regent_trigger_condition_is_hand_cast_and_other_creature_count() { + let parsed = parse(DEATHBRINGER_REGENT, "Deathbringer Regent", &["Creature"]); + let cond = first_trigger_condition(&parsed).expect("Regent ETB must carry a condition"); + let conditions = match cond { + TriggerCondition::And { conditions } => conditions, + other => panic!("expected And[..], got {other:?}"), + }; + assert_eq!(conditions.len(), 2, "hand-cast AND other-creature-count"); + assert_eq!( + conditions[0], + TriggerCondition::WasCast { + zone: Some(Zone::Hand), + controller: Some(engine::types::ability::ControllerRef::You), + owner: Some(engine::types::ability::ControllerRef::You), + }, + "first conjunct scopes both caster and owner for 'you cast it from your hand'" + ); + match &conditions[1] { + TriggerCondition::QuantityComparison { + lhs: + QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { filter }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 5 }, + } => match filter { + TargetFilter::Typed(tf) => { + assert_eq!(tf.type_filters, vec![TypeFilter::Creature]); + assert_eq!( + tf.controller, None, + "all creatures count, regardless of controller" + ); + assert!( + tf.properties.contains(&FilterProp::OtherThanTriggerObject), + "'other creatures' must exclude the entering Regent, got {:?}", + tf.properties + ); + assert!( + tf.properties.contains(&FilterProp::InZone { + zone: Zone::Battlefield + }), + "the threshold must only count battlefield creatures, got {:?}", + tf.properties + ); + } + other => panic!("expected Typed creature filter, got {other:?}"), + }, + other => panic!("expected ObjectCount >= 5, got {other:?}"), + } + assert!( + !has_condition_if_swallow(&parsed), + "Condition_If must clear once the full conjunction attaches" + ); +} + +/// The generalized scanner requires an `and ` tail, so a simple +/// zoned cast gate still reaches the pre-existing positive-zone parser rather +/// than being misclassified as a conjunction. +#[test] +fn simple_zoned_cast_gate_remains_a_single_condition() { + let parsed = parse( + "When Testcard enters, if you cast it from your hand, draw a card.", + "Testcard", + &["Creature"], + ); + assert_eq!( + first_trigger_condition(&parsed), + Some(TriggerCondition::WasCast { + zone: Some(Zone::Hand), + controller: Some(engine::types::ability::ControllerRef::You), + owner: Some(engine::types::ability::ControllerRef::You), + }) + ); + assert!(!has_condition_if_swallow(&parsed)); +} + // =========================================================================== // P (building-block class tests, via the public parser) // =========================================================================== @@ -672,6 +758,201 @@ fn ran_and_shaw_opponent_graveyard_dragon_uncounted() { ); } +// =========================================================================== +// R — Deathbringer Regent (Channel B, zoned cast-and-condition conjunction) +// =========================================================================== + +/// Hand-cast Regent with five other creatures split across both players: +/// both intervening-if conjuncts hold, so the ETB destroys every other +/// creature while retaining Regent. +#[test] +fn deathbringer_regent_hand_cast_with_five_other_creatures_wipes_them() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let others: Vec<_> = (0..5) + .map(|i| { + let controller = if i == 4 { P1 } else { P0 }; + scenario + .add_creature(controller, &format!("Other Creature {i}"), 1, 1) + .id() + }) + .collect(); + let mut b = scenario.add_creature_to_hand_from_oracle( + P0, + "Deathbringer Regent", + 5, + 6, + DEATHBRINGER_REGENT, + ); + b.with_mana_cost(engine::types::mana::ManaCost::generic(0)); + let regent = b.id(); + let mut runner = scenario.build(); + + let out = runner.cast(regent).resolve(); + assert_eq!( + out.zone_of(regent), + Zone::Battlefield, + "the entering Regent is excluded by 'other creatures'" + ); + for other in others { + assert_eq!( + out.zone_of(other), + Zone::Graveyard, + "five other creatures satisfy the count, so Regent destroys {other:?}" + ); + } +} + +/// Four other creatures miss the quantity conjunct, so the ETB never reaches +/// its destroy effect even though the Regent was cast from the correct zone. +#[test] +fn deathbringer_regent_hand_cast_with_four_other_creatures_does_not_wipe() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let others: Vec<_> = (0..4) + .map(|i| { + scenario + .add_creature(P0, &format!("Other Creature {i}"), 1, 1) + .id() + }) + .collect(); + let mut b = scenario.add_creature_to_hand_from_oracle( + P0, + "Deathbringer Regent", + 5, + 6, + DEATHBRINGER_REGENT, + ); + b.with_mana_cost(engine::types::mana::ManaCost::generic(0)); + let regent = b.id(); + let mut runner = scenario.build(); + + let out = runner.cast(regent).resolve(); + assert_eq!(out.zone_of(regent), Zone::Battlefield); + for other in others { + assert_eq!( + out.zone_of(other), + Zone::Battlefield, + "four other creatures fail the count conjunct, so {other:?} survives" + ); + } +} + +/// Reanimation supplies the count but not the cast-from-hand provenance, so +/// the conjunction is false and all five other creatures remain. +#[test] +fn deathbringer_regent_reanimated_with_five_other_creatures_does_not_wipe() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let others: Vec<_> = (0..5) + .map(|i| { + scenario + .add_creature(P0, &format!("Other Creature {i}"), 1, 1) + .id() + }) + .collect(); + let mut b = scenario.add_creature_to_graveyard(P0, "Deathbringer Regent", 5, 6); + b.from_oracle_text(DEATHBRINGER_REGENT); + let regent = b.id(); + let mut reanimate = scenario.add_spell_to_hand_from_oracle(P0, "Reanimate", false, REANIMATE); + reanimate.with_mana_cost(engine::types::mana::ManaCost::generic(0)); + let reanimate = reanimate.id(); + let mut runner = scenario.build(); + + let out = runner.cast(reanimate).target_object(regent).resolve(); + assert_eq!( + out.zone_of(regent), + Zone::Battlefield, + "reach-guard: reanimation put Regent onto the battlefield" + ); + for other in others { + assert_eq!( + out.zone_of(other), + Zone::Battlefield, + "put-into-play is not a hand cast, so {other:?} survives" + ); + } +} + +/// The quantity side of an intervening-if is rechecked at resolution. Regent +/// triggers with five others, then Murder removes one while its trigger waits +/// on the stack; with only four left, the trigger resolves without wiping. +#[test] +fn deathbringer_regent_rechecks_other_creature_count_at_resolution() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let others: Vec<_> = (0..5) + .map(|i| { + scenario + .add_creature(P0, &format!("Other Creature {i}"), 1, 1) + .id() + }) + .collect(); + let victim = others[0]; + let mut regent_builder = scenario.add_creature_to_hand_from_oracle( + P0, + "Deathbringer Regent", + 5, + 6, + DEATHBRINGER_REGENT, + ); + regent_builder.with_mana_cost(engine::types::mana::ManaCost::generic(0)); + let regent = regent_builder.id(); + let mut murder_builder = scenario.add_spell_to_hand_from_oracle(P0, "Murder", true, MURDER); + murder_builder.with_mana_cost(engine::types::mana::ManaCost::generic(0)); + let murder = murder_builder.id(); + let mut runner = scenario.build(); + + // Resolve Regent through the live priority protocol. Its ETB has passed the + // creation check and now waits on the stack above the empty spell stack. + runner.cast(regent).commit(); + runner + .act(GameAction::PassPriority) + .expect("P0 passes to resolve Regent"); + runner + .act(GameAction::PassPriority) + .expect("P1 passes to resolve Regent"); + assert_eq!(runner.state().objects[®ent].zone, Zone::Battlefield); + assert!( + !runner.state().stack.is_empty(), + "five others at entry must create Regent's intervening-if trigger" + ); + + // Cast and resolve the response through the scenario cast/target pipeline, + // leaving Regent's already-created trigger underneath it on the live stack. + runner.cast(murder).target_object(victim).commit(); + runner + .act(GameAction::PassPriority) + .expect("P0 passes to resolve Murder"); + runner + .act(GameAction::PassPriority) + .expect("P1 passes to resolve Murder"); + assert_eq!( + runner.state().objects[&victim].zone, + Zone::Graveyard, + "reach-guard: Murder removed one of the five counted creatures" + ); + assert!( + !runner.state().stack.is_empty(), + "Regent's trigger remains on the stack for its resolution-time recheck" + ); + + runner + .act(GameAction::PassPriority) + .expect("P0 passes to resolve Regent's trigger"); + runner + .act(GameAction::PassPriority) + .expect("P1 passes to resolve Regent's trigger"); + assert_eq!(runner.state().objects[®ent].zone, Zone::Battlefield); + for other in others.into_iter().skip(1) { + assert_eq!( + runner.state().objects[&other].zone, + Zone::Battlefield, + "only four others remain at resolution, so Regent does not destroy {other:?}" + ); + } +} + // =========================================================================== // R — Phage the Untouchable (STANDING regression fence, NOT a BB-FU4 delta) // =========================================================================== From f449f1e32c49038068db233a7741f663bd89cdc1 Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:30:02 -0700 Subject: [PATCH 17/63] fix(ai): preserve viable payment continuations (#6785) * fix(ai): preserve viable payment continuations * fix(ai): remove obsolete mana color shortcut * test(engine): align payment witness successor assertion --------- Co-authored-by: matthewevans --- crates/engine/src/ai_support/mod.rs | 6 + .../src/ai_support/payment_continuation.rs | 777 +++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + .../tests/integration/payment_continuation.rs | 223 +++++ crates/phase-ai/src/mana_colors.rs | 98 +-- crates/phase-ai/src/planner/mod.rs | 225 ++++- crates/phase-ai/src/policies/registry.rs | 27 +- crates/phase-ai/src/projection.rs | 53 +- crates/phase-ai/src/search.rs | 802 +++++++++++++----- crates/phase-ai/src/session.rs | 23 +- 10 files changed, 1860 insertions(+), 375 deletions(-) create mode 100644 crates/engine/src/ai_support/payment_continuation.rs create mode 100644 crates/engine/tests/integration/payment_continuation.rs diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index b44edfde70..f27f8d2e27 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -3,6 +3,7 @@ mod combat_withdrawal; mod context; mod copy; pub mod filter; +mod payment_continuation; use std::collections::{HashMap, HashSet}; @@ -45,6 +46,11 @@ pub use copy::{ pub use filter::{ BasicLegalityFilter, CandidateFilter, FilterCost, FilterPipeline, SimulationFilter, }; +pub use payment_continuation::{ + classify_payment_continuation, witness_payment_continuation, AcceptedPaymentSuccessor, + PaymentContinuationRoot, PaymentContinuationState, PaymentContinuationUnsupported, + PAYMENT_CONTINUATION_MAX_REDUCER_ATTEMPTS, +}; /// Filter `candidate_actions` down to the actions that are actually legal now. /// diff --git a/crates/engine/src/ai_support/payment_continuation.rs b/crates/engine/src/ai_support/payment_continuation.rs new file mode 100644 index 0000000000..be1f147f80 --- /dev/null +++ b/crates/engine/src/ai_support/payment_continuation.rs @@ -0,0 +1,777 @@ +//! Reducer-backed safety witness for AI payment continuations. +//! +//! The engine is the sole authority for mana spending restrictions, payment +//! ordering, and the nested mana-ability state machine. This module therefore +//! never estimates capacity or infers payable colors: it accepts an AI edge +//! only after bounded reducer simulation reaches the matching root's real stack +//! finalization. + +use std::collections::BTreeSet; + +use crate::ai_support::legal_actions; +use crate::game::engine::apply_as_current_for_simulation; +use crate::types::actions::GameAction; +use crate::types::events::GameEvent; +use crate::types::game_state::{ + CollectEvidenceResume, CostResume, DeferredLifeCostResume, GameState, ManaAbilityCostCursor, + ManaAbilityResume, ManaChoiceContext, PendingCast, PendingCostMoveResume, PendingManaAbility, + StackEntryKind, WaitingFor, +}; +use crate::types::identifiers::{CardId, ObjectId}; +use crate::types::player::PlayerId; + +/// Maximum reducer applications made while witnessing one proposed successor. +/// +/// The supplied action counts as one application. The bound is per witness, +/// not per candidate list: callers that inspect `A` raw payment candidates may +/// therefore cause at most `PAYMENT_CONTINUATION_MAX_REDUCER_ATTEMPTS * A` +/// applications. +pub const PAYMENT_CONTINUATION_MAX_REDUCER_ATTEMPTS: usize = 64; + +/// Mode-free identity of the announced spell or activated ability being paid. +/// +/// CR 601.2f–i / CR 602.2b: the total cost is locked, paid, and then finalized +/// as the specific announced spell or activated ability. `ConvokeMode` is an +/// immediate-carrier grammar guard, not root identity: several later carriers +/// do not preserve it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PaymentContinuationRoot { + Spell { + object_id: ObjectId, + card_id: CardId, + payer: PlayerId, + }, + Activation { + source_id: ObjectId, + ability_index: usize, + payer: PlayerId, + }, +} + +/// A payment state classification for AI consumers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PaymentContinuationState { + /// This is not one of the spell/activation mana-payment carriers owned by + /// this oracle. Existing one-step AI behavior remains authoritative. + NotAffiliated, + /// The state is a supported carrier for this exact payment root. + Affiliated(PaymentContinuationRoot), + /// The state advertises an in-flight payment root, but its typed authority + /// cannot prove the root safely. Consumers must fail closed, never fall + /// through to an unrelated first-legal policy. + UnsupportedAffiliated(PaymentContinuationUnsupported), +} + +/// Why an affiliated-looking carrier cannot be witnessed safely. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaymentContinuationUnsupported { + MissingPendingCast, + MissingOuterPayer, + PayerMismatch, + RootMismatch, + UnsupportedDeferredManaRoot, + MissingSpellPlaceholder, +} + +/// An accepted edge together with its already-applied immediate successor. +/// +/// Reusing this state prevents AI callers from applying the selected reducer +/// edge a second time after the oracle already proved its completion witness. +#[derive(Debug, Clone)] +pub struct AcceptedPaymentSuccessor { + pub action: GameAction, + pub state: GameState, +} + +/// Classify the current payment carrier without guessing cross-carrier state. +pub fn classify_payment_continuation(state: &GameState) -> PaymentContinuationState { + if let Some(deferred) = state.pending_deferred_life_cost_resume.as_ref() { + return classify_deferred_life_root(state, deferred); + } + + match &state.waiting_for { + // CR 601.2g–h: during the ordinary mana-payment window, the visible + // payer and live pending cast jointly identify the payment root. + WaitingFor::ManaPayment { player, .. } => classify_global_root(state, *player), + // CR 601.2f–h: submitting Phyrexian choices remains part of the same + // cost payment. The prompt's object must agree with the announced root. + WaitingFor::PhyrexianPayment { + player, + spell_object, + .. + } => match root_from_global(state, *player) { + Ok(root) if root.object_id() == *spell_object => { + PaymentContinuationState::Affiliated(root) + } + Ok(_) => PaymentContinuationState::UnsupportedAffiliated( + PaymentContinuationUnsupported::RootMismatch, + ), + Err(reason) => PaymentContinuationState::UnsupportedAffiliated(reason), + }, + WaitingFor::PayCost { + resume: CostResume::ManaAbility { mana_ability }, + .. + } + | WaitingFor::PayManaAbilityMana { + pending_mana_ability: mana_ability, + .. + } + | WaitingFor::PayAmountChoice { + pending_mana_ability: Some(mana_ability), + .. + } => classify_pending_mana_ability(state, mana_ability), + WaitingFor::ChooseManaColor { + context: ManaChoiceContext::ManaAbility(mana_ability), + .. + } => classify_pending_mana_ability(state, mana_ability), + WaitingFor::CollectEvidenceChoice { resume, .. } => match resume.as_ref() { + CollectEvidenceResume::ManaAbility { + pending_mana_ability, + } => classify_pending_mana_ability(state, pending_mana_ability), + CollectEvidenceResume::Casting { .. } | CollectEvidenceResume::Effect { .. } => { + PaymentContinuationState::NotAffiliated + } + }, + _ => classify_parked_cost_move_root(state), + } +} + +/// Return an already-applied successor only when a bounded reducer search can +/// finish the same announced root. +/// +/// CR 601.2h / CR 602.2b: partial payment cannot certify success. The witness +/// rejects cancellation and requires the actual spell/ability finalization +/// delta after the original root has disappeared from every typed authority. +pub fn witness_payment_continuation( + state: &GameState, + action: &GameAction, +) -> Option { + let PaymentContinuationState::Affiliated(root) = classify_payment_continuation(state) else { + return None; + }; + if matches!(action, GameAction::CancelCast) { + return None; + } + + let baseline = WitnessBaseline::capture(state, &root)?; + let mut successor = state.clone(); + let first_result = apply_as_current_for_simulation(&mut successor, action.clone()).ok()?; + let mut attempts = 1; + record_witness_attempts(attempts); + let mut events = first_result.events; + + if witness_completion(&successor, &root, &baseline, &mut events, &mut attempts) { + record_witness_attempts(attempts); + Some(AcceptedPaymentSuccessor { + action: action.clone(), + state: successor, + }) + } else { + record_witness_attempts(attempts); + None + } +} + +#[derive(Debug, Clone)] +struct WitnessBaseline { + pre_stack_ids: BTreeSet, + completion: CompletionBaseline, +} + +#[derive(Debug, Clone)] +enum CompletionBaseline { + Spell { + entry_id: ObjectId, + object_id: ObjectId, + card_id: CardId, + controller: PlayerId, + }, + Activation, +} + +impl WitnessBaseline { + fn capture(state: &GameState, root: &PaymentContinuationRoot) -> Option { + let pre_stack_ids = state.stack.iter().map(|entry| entry.id).collect(); + let completion = match root { + PaymentContinuationRoot::Spell { + object_id, + card_id, + payer, + } => { + let entry = state.stack.iter().find(|entry| entry.id == *object_id)?; + let StackEntryKind::Spell { + card_id: entry_card_id, + ability: None, + actual_mana_spent: 0, + .. + } = &entry.kind + else { + return None; + }; + if *entry_card_id != *card_id + || entry.controller != *payer + || state.stack_paid_facts.contains_key(object_id) + { + return None; + } + CompletionBaseline::Spell { + entry_id: entry.id, + object_id: *object_id, + card_id: *card_id, + controller: *payer, + } + } + PaymentContinuationRoot::Activation { .. } => CompletionBaseline::Activation, + }; + Some(Self { + pre_stack_ids, + completion, + }) + } +} + +fn witness_completion( + state: &GameState, + root: &PaymentContinuationRoot, + baseline: &WitnessBaseline, + events: &mut Vec, + attempts: &mut usize, +) -> bool { + if !root_present(state, root) { + return finalized_root_matches(state, root, baseline, events); + } + if *attempts >= PAYMENT_CONTINUATION_MAX_REDUCER_ATTEMPTS { + return false; + } + + match classify_payment_continuation(state) { + PaymentContinuationState::Affiliated(current_root) if current_root == *root => {} + PaymentContinuationState::Affiliated(_) + | PaymentContinuationState::NotAffiliated + | PaymentContinuationState::UnsupportedAffiliated(_) => return false, + } + + let mut actions = legal_actions(state); + actions.sort_by(|left, right| left.cmp_stable(right)); + for next_action in actions { + if matches!(next_action, GameAction::CancelCast) + || *attempts >= PAYMENT_CONTINUATION_MAX_REDUCER_ATTEMPTS + { + continue; + } + + *attempts += 1; + let mut next_state = state.clone(); + let Ok(result) = apply_as_current_for_simulation(&mut next_state, next_action) else { + continue; + }; + let event_len = events.len(); + events.extend(result.events); + if witness_completion(&next_state, root, baseline, events, attempts) { + return true; + } + events.truncate(event_len); + } + false +} + +fn finalized_root_matches( + state: &GameState, + root: &PaymentContinuationRoot, + baseline: &WitnessBaseline, + events: &[GameEvent], +) -> bool { + match (&baseline.completion, root) { + ( + CompletionBaseline::Spell { + entry_id, + object_id, + card_id, + controller, + }, + PaymentContinuationRoot::Spell { .. }, + ) => { + let Some(entry) = state.stack.iter().find(|entry| entry.id == *entry_id) else { + return false; + }; + let StackEntryKind::Spell { + card_id: entry_card_id, + actual_mana_spent: entry_spent, + .. + } = &entry.kind + else { + return false; + }; + let Some(paid) = state.stack_paid_facts.get(object_id) else { + return false; + }; + *entry_card_id == *card_id + && entry.controller == *controller + && *entry_spent == paid.actual_mana_spent + && events.iter().any(|event| { + matches!( + event, + GameEvent::SpellCast { + card_id: event_card_id, + controller: event_controller, + object_id: event_object_id, + } if *event_card_id == *card_id + && *event_controller == *controller + && *event_object_id == *object_id + ) + }) + } + ( + CompletionBaseline::Activation, + PaymentContinuationRoot::Activation { + source_id, + ability_index, + payer, + }, + ) => { + state.stack.iter().any(|entry| { + !baseline.pre_stack_ids.contains(&entry.id) + && entry.source_id == *source_id + && entry.controller == *payer + && matches!( + &entry.kind, + StackEntryKind::ActivatedAbility { + source_id: entry_source_id, + ability, + } if *entry_source_id == *source_id + && ability.ability_index == Some(*ability_index) + ) + }) && events.iter().any(|event| { + matches!( + event, + GameEvent::AbilityActivated { + player_id, + source_id: event_source_id, + .. + } if *player_id == *payer && *event_source_id == *source_id + ) + }) + } + _ => false, + } +} + +fn classify_global_root(state: &GameState, payer: PlayerId) -> PaymentContinuationState { + match root_from_global(state, payer) { + Ok(root) => PaymentContinuationState::Affiliated(root), + Err(reason) => PaymentContinuationState::UnsupportedAffiliated(reason), + } +} + +fn classify_pending_mana_ability( + state: &GameState, + pending: &PendingManaAbility, +) -> PaymentContinuationState { + match root_from_pending_mana_ability(state, pending) { + Ok(Some(root)) => PaymentContinuationState::Affiliated(root), + Ok(None) => PaymentContinuationState::NotAffiliated, + Err(reason) => PaymentContinuationState::UnsupportedAffiliated(reason), + } +} + +fn classify_parked_cost_move_root(state: &GameState) -> PaymentContinuationState { + let Some(resume) = state.pending_cost_move_resume.as_ref() else { + return PaymentContinuationState::NotAffiliated; + }; + match resume { + PendingCostMoveResume::ManaAbilityPayment { pending, cursor } => { + match root_from_pending_mana_and_cursor(state, pending, cursor) { + Ok(Some(root)) => PaymentContinuationState::Affiliated(root), + Ok(None) => PaymentContinuationState::NotAffiliated, + Err(reason) => PaymentContinuationState::UnsupportedAffiliated(reason), + } + } + PendingCostMoveResume::CollectEvidencePayment { resume, .. } => match resume.as_ref() { + CollectEvidenceResume::ManaAbility { + pending_mana_ability, + } => classify_pending_mana_ability(state, pending_mana_ability), + CollectEvidenceResume::Casting { .. } | CollectEvidenceResume::Effect { .. } => { + PaymentContinuationState::NotAffiliated + } + }, + PendingCostMoveResume::DelveManaPayment { player, .. } => { + classify_global_root(state, *player) + } + // These are distinct cost/resolution continuations. They intentionally + // retain their existing policies rather than being misidentified from a + // coincidental PendingCast elsewhere in state. + PendingCostMoveResume::Cast { .. } + | PendingCostMoveResume::SacrificeForCost { .. } + | PendingCostMoveResume::WardSacrificePayment { .. } + | PendingCostMoveResume::ReplacementMayCost { .. } + | PendingCostMoveResume::Foretell { .. } + | PendingCostMoveResume::UnlessBouncePayment { .. } + | PendingCostMoveResume::LoyaltyActivation { .. } => { + PaymentContinuationState::NotAffiliated + } + } +} + +fn classify_deferred_life_root( + state: &GameState, + deferred: &DeferredLifeCostResume, +) -> PaymentContinuationState { + match deferred { + DeferredLifeCostResume::Cast { + player, + pending: Some(pending), + .. + } => PaymentContinuationState::Affiliated(root_from_pending_cast(pending, *player)), + DeferredLifeCostResume::Cast { pending: None, .. } => { + PaymentContinuationState::UnsupportedAffiliated( + PaymentContinuationUnsupported::MissingPendingCast, + ) + } + DeferredLifeCostResume::ManaRoot { player, resume, .. } => match resume.as_ref() { + ManaAbilityResume::ManaPayment { + outer_player: Some(outer_player), + .. + } if outer_player == player => classify_global_root(state, *player), + ManaAbilityResume::ManaPayment { .. } => { + PaymentContinuationState::UnsupportedAffiliated( + PaymentContinuationUnsupported::PayerMismatch, + ) + } + ManaAbilityResume::PhyrexianCastPayment { .. } + | ManaAbilityResume::FinalizePendingManaPayment { .. } => { + PaymentContinuationState::UnsupportedAffiliated( + PaymentContinuationUnsupported::UnsupportedDeferredManaRoot, + ) + } + ManaAbilityResume::Priority + | ManaAbilityResume::CompanionToHand { .. } + | ManaAbilityResume::EndContinuousEffect { .. } + | ManaAbilityResume::UnlessPayment { .. } + | ManaAbilityResume::EffectPayCost { .. } => PaymentContinuationState::NotAffiliated, + }, + DeferredLifeCostResume::PayAmount { .. } => PaymentContinuationState::NotAffiliated, + } +} + +fn root_from_global( + state: &GameState, + payer: PlayerId, +) -> Result { + state + .pending_cast + .as_deref() + .map(|pending| root_from_pending_cast(pending, payer)) + .ok_or(PaymentContinuationUnsupported::MissingPendingCast) +} + +fn root_from_pending_cast(pending: &PendingCast, payer: PlayerId) -> PaymentContinuationRoot { + match pending.activation_ability_index { + Some(ability_index) => PaymentContinuationRoot::Activation { + source_id: pending.object_id, + ability_index, + payer, + }, + None => PaymentContinuationRoot::Spell { + object_id: pending.object_id, + card_id: pending.card_id, + payer, + }, + } +} + +fn root_from_pending_mana_ability( + state: &GameState, + pending: &PendingManaAbility, +) -> Result, PaymentContinuationUnsupported> { + let mut root = None; + record_root_from_resume(state, &pending.resume, &mut root)?; + if let Some(resume) = pending.cost_move_resume.as_ref() { + record_root_from_resume(state, resume, &mut root)?; + } + Ok(root) +} + +fn root_from_pending_mana_and_cursor( + state: &GameState, + pending: &PendingManaAbility, + cursor: &ManaAbilityCostCursor, +) -> Result, PaymentContinuationUnsupported> { + let mut root = root_from_pending_mana_ability(state, pending)?; + record_root_from_cursor(state, cursor, &mut root)?; + Ok(root) +} + +fn record_root_from_cursor( + state: &GameState, + cursor: &ManaAbilityCostCursor, + root: &mut Option, +) -> Result<(), PaymentContinuationUnsupported> { + let Some(parent) = cursor.parent.as_deref() else { + return Ok(()); + }; + let parent_root = root_from_pending_mana_ability(state, &parent.pending)?; + merge_root(root, parent_root)?; + record_root_from_cursor(state, &parent.cursor, root) +} + +fn record_root_from_resume( + state: &GameState, + resume: &ManaAbilityResume, + root: &mut Option, +) -> Result<(), PaymentContinuationUnsupported> { + let next = match resume { + ManaAbilityResume::ManaPayment { + outer_player: Some(payer), + .. + } => Some(root_from_global(state, *payer)?), + ManaAbilityResume::ManaPayment { + outer_player: None, .. + } => return Err(PaymentContinuationUnsupported::MissingOuterPayer), + ManaAbilityResume::PhyrexianCastPayment { caster, .. } => { + Some(root_from_global(state, *caster)?) + } + ManaAbilityResume::FinalizePendingManaPayment { player } => { + Some(root_from_global(state, *player)?) + } + ManaAbilityResume::Priority + | ManaAbilityResume::CompanionToHand { .. } + | ManaAbilityResume::EndContinuousEffect { .. } + | ManaAbilityResume::UnlessPayment { .. } + | ManaAbilityResume::EffectPayCost { .. } => None, + }; + merge_root(root, next) +} + +fn merge_root( + existing: &mut Option, + next: Option, +) -> Result<(), PaymentContinuationUnsupported> { + let Some(next) = next else { + return Ok(()); + }; + if let Some(existing) = existing { + if *existing != next { + return Err(PaymentContinuationUnsupported::RootMismatch); + } + } else { + *existing = Some(next); + } + Ok(()) +} + +fn root_present(state: &GameState, root: &PaymentContinuationRoot) -> bool { + state + .pending_cast + .as_deref() + .is_some_and(|pending| pending_matches_root(pending, root)) + || state + .waiting_for + .pending_cast_ref() + .is_some_and(|pending| pending_matches_root(pending, root)) + || waiting_for_contains_root(&state.waiting_for, root) + || pending_cost_move_contains_root(state.pending_cost_move_resume.as_ref(), root) + || deferred_life_contains_root(state.pending_deferred_life_cost_resume.as_ref(), root) +} + +fn waiting_for_contains_root(waiting_for: &WaitingFor, root: &PaymentContinuationRoot) -> bool { + match waiting_for { + WaitingFor::PayCost { + resume: CostResume::ManaAbility { mana_ability }, + .. + } + | WaitingFor::PayManaAbilityMana { + pending_mana_ability: mana_ability, + .. + } + | WaitingFor::PayAmountChoice { + pending_mana_ability: Some(mana_ability), + .. + } => pending_mana_contains_root(mana_ability, root), + WaitingFor::ChooseManaColor { + context: ManaChoiceContext::ManaAbility(mana_ability), + .. + } => pending_mana_contains_root(mana_ability, root), + WaitingFor::CollectEvidenceChoice { resume, .. } => match resume.as_ref() { + CollectEvidenceResume::Casting { pending_cast, .. } => { + pending_matches_root(pending_cast, root) + } + CollectEvidenceResume::ManaAbility { + pending_mana_ability, + } => pending_mana_contains_root(pending_mana_ability, root), + CollectEvidenceResume::Effect { .. } => false, + }, + _ => false, + } +} + +fn pending_cost_move_contains_root( + resume: Option<&PendingCostMoveResume>, + root: &PaymentContinuationRoot, +) -> bool { + match resume { + Some(PendingCostMoveResume::Cast { + pending: Some(pending), + .. + }) => pending_matches_root(pending, root), + Some(PendingCostMoveResume::SacrificeForCost { pending, .. }) => { + pending_matches_root(pending, root) + } + Some(PendingCostMoveResume::ManaAbilityPayment { pending, cursor }) => { + pending_mana_contains_root(pending, root) || cursor_contains_root(cursor, root) + } + Some(PendingCostMoveResume::CollectEvidencePayment { resume, .. }) => match resume.as_ref() + { + CollectEvidenceResume::Casting { pending_cast, .. } => { + pending_matches_root(pending_cast, root) + } + CollectEvidenceResume::ManaAbility { + pending_mana_ability, + } => pending_mana_contains_root(pending_mana_ability, root), + CollectEvidenceResume::Effect { .. } => false, + }, + Some(PendingCostMoveResume::Cast { pending: None, .. }) + | Some(PendingCostMoveResume::WardSacrificePayment { .. }) + | Some(PendingCostMoveResume::ReplacementMayCost { .. }) + | Some(PendingCostMoveResume::Foretell { .. }) + | Some(PendingCostMoveResume::DelveManaPayment { .. }) + | Some(PendingCostMoveResume::UnlessBouncePayment { .. }) + | Some(PendingCostMoveResume::LoyaltyActivation { .. }) + | None => false, + } +} + +fn deferred_life_contains_root( + deferred: Option<&DeferredLifeCostResume>, + root: &PaymentContinuationRoot, +) -> bool { + matches!( + deferred, + Some(DeferredLifeCostResume::Cast { + pending: Some(pending), + .. + }) if pending_matches_root(pending, root) + ) +} + +fn pending_mana_contains_root( + pending: &PendingManaAbility, + root: &PaymentContinuationRoot, +) -> bool { + mana_resume_matches_root(&pending.resume, root) + || pending + .cost_move_resume + .as_ref() + .is_some_and(|resume| mana_resume_matches_root(resume, root)) +} + +fn cursor_contains_root(cursor: &ManaAbilityCostCursor, root: &PaymentContinuationRoot) -> bool { + cursor.parent.as_deref().is_some_and(|parent| { + pending_mana_contains_root(&parent.pending, root) + || cursor_contains_root(&parent.cursor, root) + }) +} + +fn mana_resume_matches_root(resume: &ManaAbilityResume, root: &PaymentContinuationRoot) -> bool { + match (resume, root) { + ( + ManaAbilityResume::ManaPayment { + outer_player: Some(player), + .. + }, + PaymentContinuationRoot::Spell { payer, .. } + | PaymentContinuationRoot::Activation { payer, .. }, + ) => player == payer, + ( + ManaAbilityResume::PhyrexianCastPayment { caster, .. }, + PaymentContinuationRoot::Spell { payer, .. } + | PaymentContinuationRoot::Activation { payer, .. }, + ) => caster == payer, + ( + ManaAbilityResume::FinalizePendingManaPayment { player }, + PaymentContinuationRoot::Spell { payer, .. } + | PaymentContinuationRoot::Activation { payer, .. }, + ) => player == payer, + _ => false, + } +} + +fn pending_matches_root(pending: &PendingCast, root: &PaymentContinuationRoot) -> bool { + match root { + PaymentContinuationRoot::Spell { + object_id, card_id, .. + } => { + pending.activation_ability_index.is_none() + && pending.object_id == *object_id + && pending.card_id == *card_id + } + PaymentContinuationRoot::Activation { + source_id, + ability_index, + .. + } => { + pending.object_id == *source_id + && pending.activation_ability_index == Some(*ability_index) + } + } +} + +impl PaymentContinuationRoot { + fn object_id(&self) -> ObjectId { + match self { + PaymentContinuationRoot::Spell { object_id, .. } => *object_id, + PaymentContinuationRoot::Activation { source_id, .. } => *source_id, + } + } +} + +// This counter deliberately records one witness invocation's reducer work; +// callers may invoke the oracle once per raw action, so it is not a global +// candidate-list cap. Kept test-only so production hot paths stay allocation- +// and synchronization-free. +#[cfg(test)] +static LAST_WITNESS_REDUCER_ATTEMPTS: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +#[cfg(test)] +fn record_witness_attempts(attempts: usize) { + LAST_WITNESS_REDUCER_ATTEMPTS.store(attempts, std::sync::atomic::Ordering::Relaxed); +} + +#[cfg(not(test))] +fn record_witness_attempts(_: usize) {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn direct_mana_payment_without_a_live_root_fails_closed() { + let mut state = GameState::new_two_player(1); + state.waiting_for = WaitingFor::ManaPayment { + player: PlayerId(0), + convoke_mode: None, + }; + + assert_eq!( + classify_payment_continuation(&state), + PaymentContinuationState::UnsupportedAffiliated( + PaymentContinuationUnsupported::MissingPendingCast + ) + ); + } + + #[test] + fn witness_attempts_remain_bounded_per_proposed_action() { + let mut state = GameState::new_two_player(1); + state.waiting_for = WaitingFor::ManaPayment { + player: PlayerId(0), + convoke_mode: None, + }; + + assert!(witness_payment_continuation(&state, &GameAction::CancelCast).is_none()); + assert!( + LAST_WITNESS_REDUCER_ATTEMPTS.load(std::sync::atomic::Ordering::Relaxed) + <= PAYMENT_CONTINUATION_MAX_REDUCER_ATTEMPTS + ); + } +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 28e8036c39..1b7e4144e3 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1099,6 +1099,7 @@ mod owner_life_change_routing_3351; mod pact_of_negation_upkeep_payment; mod park_heights_pegasus_draw_condition_runtime; mod parker_luck; +mod payment_continuation; mod plagon_toughness_combat_damage; mod plague_drone_gain_life_lose_life; mod planar_birth_owners_control_1126; diff --git a/crates/engine/tests/integration/payment_continuation.rs b/crates/engine/tests/integration/payment_continuation.rs new file mode 100644 index 0000000000..d0d6210a02 --- /dev/null +++ b/crates/engine/tests/integration/payment_continuation.rs @@ -0,0 +1,223 @@ +//! Production-path regressions for the reducer-backed AI payment witness. + +use engine::ai_support::{ + classify_payment_continuation, legal_actions, witness_payment_continuation, + PaymentContinuationRoot, PaymentContinuationState, +}; +use engine::game::engine::apply_as_current_for_simulation; +use engine::game::scenario::{GameScenario, P0}; +use engine::types::ability::{ + AbilityCost, AbilityDefinition, AbilityKind, Effect, ManaProduction, QuantityExpr, +}; +use engine::types::actions::GameAction; +use engine::types::game_state::{CastPaymentMode, ManaChoice, StackEntryKind, WaitingFor}; +use engine::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use std::sync::Arc; + +const DRAW_ORACLE: &str = "Draw a card."; + +/// Reach the live CR 601.2g–i manual-payment carrier through the normal cast +/// reducer, rather than manufacturing a pending-cast terminal state. +fn manual_spell_payment_state() -> engine::types::game_state::GameState { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Witnessed Draw", true, DRAW_ORACLE) + .with_mana_cost(ManaCost::generic(2)) + .id(); + scenario.with_mana_pool( + P0, + (0..2) + .map(|_| ManaUnit::new(ManaType::Colorless, spell, false, Vec::new())) + .collect(), + ); + let mut runner = scenario.build(); + let card_id = runner.state().objects[&spell].card_id; + runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: Vec::new(), + payment_mode: CastPaymentMode::Manual, + }) + .expect("manual cast must reach a live payment carrier"); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::ManaPayment { .. } + )); + runner.state().clone() +} + +/// Reach a real mana-ability colour prompt nested in an announced `{1}{U}` +/// payment. The one-use tap-cost producer emits exactly two mana in any ordered +/// combination of blue and red, so every colour product is reducer-legal but +/// only products containing blue complete the announced spell. +fn flexible_mana_payment_state() -> engine::types::game_state::GameState { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Flexible Witness", true, DRAW_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Blue], + generic: 1, + }) + .id(); + let source = scenario.add_creature(P0, "Flexible Source", 1, 1).id(); + let mut runner = scenario.build(); + let ability = AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::AnyCombination { + count: QuantityExpr::Fixed { value: 2 }, + color_options: vec![ManaColor::Blue, ManaColor::Red], + }, + restrictions: Vec::new(), + grants: Vec::new(), + expiry: None, + target: None, + }, + ) + .cost(AbilityCost::Tap); + let source_object = runner.state_mut().objects.get_mut(&source).unwrap(); + Arc::make_mut(&mut source_object.abilities).push(ability); + let card_id = runner.state().objects[&spell].card_id; + runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: Vec::new(), + payment_mode: CastPaymentMode::Manual, + }) + .expect("manual cast reaches the live mana-payment carrier"); + runner + .act(GameAction::ActivateAbility { + source_id: source, + ability_index: 0, + }) + .expect("the real zero-cost mana ability is activatable while paying"); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::ChooseManaColor { .. } + )); + runner.state().clone() +} + +#[test] +fn witnessed_manual_payment_requires_real_spell_finalization() { + let state = manual_spell_payment_state(); + let PaymentContinuationState::Affiliated(PaymentContinuationRoot::Spell { + object_id, + card_id, + payer, + }) = classify_payment_continuation(&state) + else { + panic!("production manual payment must classify as its announced spell root"); + }; + let placeholder = state + .stack + .iter() + .find(|entry| entry.id == object_id) + .expect("CR 601.2a announcement placeholder is present"); + assert!(matches!( + placeholder.kind, + StackEntryKind::Spell { + ability: None, + actual_mana_spent: 0, + .. + } + )); + assert!( + !state.stack_paid_facts.contains_key(&object_id), + "the announced placeholder has no paid facts before CR 601.2i" + ); + + let completing = legal_actions(&state) + .into_iter() + .find_map(|action| witness_payment_continuation(&state, &action)) + .expect("at least one reducer-legal row must complete the announced root"); + let entry = completing + .state + .stack + .iter() + .find(|entry| entry.id == object_id) + .expect("the same announced stack entry remains after finalization"); + let StackEntryKind::Spell { + card_id: finalized_card_id, + actual_mana_spent, + .. + } = &entry.kind + else { + panic!("payment witness must retag the announcement as a finalized spell"); + }; + let paid = completing + .state + .stack_paid_facts + .get(&object_id) + .expect("finalization installs paid facts for the announced entry"); + assert_eq!(*finalized_card_id, card_id); + assert_eq!(entry.controller, payer); + assert!( + *actual_mana_spent > 0, + "the nonzero production cost was paid" + ); + assert_eq!(*actual_mana_spent, paid.actual_mana_spent); + assert!(completing.state.pending_cast.is_none()); +} + +#[test] +fn cancellation_is_never_a_payment_witness() { + let state = manual_spell_payment_state(); + assert!( + witness_payment_continuation(&state, &GameAction::CancelCast).is_none(), + "CancelCast remains reducer-legal but cannot certify root finalization" + ); +} + +#[test] +fn flexible_mana_witness_keeps_every_completing_product() { + let state = flexible_mana_payment_state(); + let mut actions = legal_actions(&state); + actions.sort_by(|left, right| left.cmp_stable(right)); + let expected = [ + GameAction::ChooseManaColor { + choice: ManaChoice::Combination(vec![ManaType::Blue, ManaType::Blue]), + count: 1, + }, + GameAction::ChooseManaColor { + choice: ManaChoice::Combination(vec![ManaType::Blue, ManaType::Red]), + count: 1, + }, + GameAction::ChooseManaColor { + choice: ManaChoice::Combination(vec![ManaType::Red, ManaType::Blue]), + count: 1, + }, + GameAction::ChooseManaColor { + choice: ManaChoice::Combination(vec![ManaType::Red, ManaType::Red]), + count: 1, + }, + ]; + assert_eq!( + actions, expected, + "the live carrier exposes all four products" + ); + + for action in &actions { + let mut simulated = state.clone(); + apply_as_current_for_simulation(&mut simulated, action.clone()) + .expect("every generated colour product remains reducer-legal"); + } + + let accepted: Vec<_> = actions + .iter() + .filter_map(|action| witness_payment_continuation(&state, action)) + .collect(); + assert_eq!( + accepted + .iter() + .map(|accepted| &accepted.action) + .collect::>(), + expected[..3].iter().collect::>(), + "only the products that leave blue available complete the exact root" + ); +} diff --git a/crates/phase-ai/src/mana_colors.rs b/crates/phase-ai/src/mana_colors.rs index dcec9f067d..32221dd7c4 100644 --- a/crates/phase-ai/src/mana_colors.rs +++ b/crates/phase-ai/src/mana_colors.rs @@ -100,40 +100,6 @@ fn push_color(colors: &mut Vec, mana_type: ManaType) { } } -/// Pick which color a flexible mana source (dual land, Mox Opal, City of Brass) -/// should produce when answering a `ManaChoicePrompt::SingleColor` *during a -/// pending cast*. The AI must produce the color the in-flight spell actually -/// demands, not the first option in the list: tapping a U/R source for {R} when -/// the spell needs {U} strands the colored pip and dead-ends the ManaPayment. -/// -/// Returns the first option whose WUBRG colored demand of the pending cast is -/// nonzero; if none of the options match a demanded color (generic-only cost, no -/// pending cast), falls back to the first option — identical to the old `first()` -/// behavior, so this is a strict improvement everywhere. -/// -/// Limitation: `pending_cast.cost` is the FULL locked outer cost, not decremented -/// per-pip as colors are produced. Demand is therefore exact for single-colored-pip -/// costs (the repro: {2}{U}, {5}{U}) and a strict improvement over `first()` for -/// all costs. Incremental per-pip demand tracking for multi-colored-pip costs -/// (e.g. {U}{U}{R}, where two blues must be produced before red) is a documented -/// follow-up, out of scope here. -pub(crate) fn demand_aware_single_color( - options: &[ManaType], - state: &GameState, -) -> Option { - let demand: ColorDemand = state - .pending_cast - .as_deref() - .map(|pc| outer_cost_color_demand(&pc.cost)) - .unwrap_or([0u32; 5]); - - options - .iter() - .copied() - .find(|&opt| color_is_demanded(demand, opt)) - .or_else(|| options.first().copied()) -} - /// Whether `mana_type` satisfies a colored pip the in-flight cost still demands. /// WUBRG demand slot per color; Colorless has no slot, so it never satisfies a /// colored pip. @@ -155,9 +121,7 @@ fn color_is_demanded(demand: ColorDemand, mana_type: ManaType) -> bool { /// would*, i.e. taking this row strands the demanded pip in a `ManaPayment` /// dead-end (a U/R dual tapped for {R} against a {2}{U} spell). /// -/// This is the [`demand_aware_single_color`] rule for the OTHER shape the same -/// choice takes. That helper answers a `ManaChoicePrompt::SingleColor` prompt; here -/// the color is instead carried in each `TapLandForMana` candidate's +/// The color is carried in each `TapLandForMana` candidate's /// `ManaSourceSelection::mana_type`, so the choice is expressed as a *set of /// candidates* and must be resolved by eliminating the stranding ones rather than /// by returning a color. @@ -168,10 +132,6 @@ fn color_is_demanded(demand: ColorDemand, mana_type: ManaType) -> bool { /// false — tapping it for an undemanded color may still be a fine way to pay /// generic. /// -/// Shares [`demand_aware_single_color`]'s limitation: `pending_cast.cost` is the -/// full locked outer cost, not decremented per-pip as colors are produced, so -/// demand is exact for single-colored-pip costs and a strict improvement for the -/// rest. pub(crate) fn tap_strands_demanded_color( state: &GameState, player: PlayerId, @@ -207,62 +167,6 @@ mod tests { use engine::types::mana::{ManaCost, ManaCostShard}; use engine::types::player::PlayerId; - fn state_with_cost(shards: Vec, generic: u32) -> GameState { - let mut state = GameState::new_two_player(42); - state.pending_cast = Some(Box::new(PendingCast::new( - ObjectId(100), - CardId(100), - ResolvedAbility::new( - Effect::Draw { - count: QuantityExpr::Fixed { value: 0 }, - target: TargetFilter::Controller, - }, - Vec::new(), - ObjectId(100), - PlayerId(0), - ), - ManaCost::Cost { shards, generic }, - ))); - state - } - - #[test] - fn picks_demanded_blue_over_first_red() { - // {2}{U}: a U/R source offered [Red, Blue] must produce Blue (the - // demanded color), not Red (the first option). - let state = state_with_cost(vec![ManaCostShard::Blue], 2); - assert_eq!( - demand_aware_single_color(&[ManaType::Red, ManaType::Blue], &state), - Some(ManaType::Blue) - ); - } - - #[test] - fn generic_only_cost_falls_back_to_first() { - // {2}: no colored demand, so the first option (Red) is fine. - let state = state_with_cost(Vec::new(), 2); - assert_eq!( - demand_aware_single_color(&[ManaType::Red, ManaType::Blue], &state), - Some(ManaType::Red) - ); - } - - #[test] - fn no_pending_cast_falls_back_to_first() { - let state = GameState::new_two_player(42); - assert!(state.pending_cast.is_none()); - assert_eq!( - demand_aware_single_color(&[ManaType::Red, ManaType::Blue], &state), - Some(ManaType::Red) - ); - } - - #[test] - fn empty_options_returns_none() { - let state = state_with_cost(vec![ManaCostShard::Blue], 2); - assert_eq!(demand_aware_single_color(&[], &state), None); - } - /// Battlefield fixture: one land for `P0` with `oracle_text`, plus a pending /// cast of `{2}{U}` — the shape of the measured `Metallic Rebuke` repro. fn state_with_land(oracle_text: &str) -> (GameState, ObjectId) { diff --git a/crates/phase-ai/src/planner/mod.rs b/crates/phase-ai/src/planner/mod.rs index c2483447ca..f269e9ec35 100644 --- a/crates/phase-ai/src/planner/mod.rs +++ b/crates/phase-ai/src/planner/mod.rs @@ -3,7 +3,8 @@ use std::collections::HashMap; use std::hash::{Hash, Hasher}; use engine::ai_support::{ - build_decision_context, AiDecisionContext, CandidateAction, TacticalClass, + build_decision_context, classify_payment_continuation, witness_payment_continuation, + AiDecisionContext, CandidateAction, PaymentContinuationState, TacticalClass, }; use engine::game::engine::apply_as_current_for_simulation; use engine::game::players; @@ -26,6 +27,99 @@ use crate::policies::PolicyRegistry; pub struct RankedCandidate { pub candidate: CandidateAction, pub score: f64, + pub(crate) payment_successor: Option, +} + +impl RankedCandidate { + pub fn new(candidate: CandidateAction, score: f64) -> Self { + Self { + candidate, + score, + payment_successor: None, + } + } + + pub(crate) fn with_payment_successor( + candidate: CandidateAction, + score: f64, + state: GameState, + ) -> Self { + Self { + candidate, + score, + payment_successor: Some(state), + } + } +} + +/// A raw engine candidate plus the first reducer successor already witnessed +/// for an affiliated payment root. This stays private to planning: the engine +/// owns both the carrier classification and the finalization proof. +#[derive(Debug, Clone)] +pub(crate) struct PreparedCandidate { + pub candidate: CandidateAction, + pub payment_successor: Option, +} + +/// Fail closed for affiliated payment states before ranking or width limits. +/// +/// The accepted successor is retained so a planning edge does not apply its +/// first reducer action once for the witness and again for search. +pub(crate) fn prepare_payment_candidates( + state: &GameState, + candidates: impl IntoIterator, +) -> Vec { + match classify_payment_continuation(state) { + PaymentContinuationState::NotAffiliated => candidates + .into_iter() + .map(|candidate| PreparedCandidate { + candidate, + payment_successor: None, + }) + .collect(), + PaymentContinuationState::UnsupportedAffiliated(_) => Vec::new(), + PaymentContinuationState::Affiliated(_) => candidates + .into_iter() + .filter_map(|candidate| { + witness_payment_continuation(state, &candidate.action).map(|accepted| { + PreparedCandidate { + candidate, + payment_successor: Some(accepted.state), + } + }) + }) + .collect(), + } +} + +pub(crate) fn rank_prepared_candidates( + candidates: impl IntoIterator, + mut scorer: F, + limit: usize, +) -> Vec +where + F: FnMut(&CandidateAction) -> f64, +{ + let mut ranked: Vec = candidates + .into_iter() + .map(|prepared| { + let score = scorer(&prepared.candidate); + match prepared.payment_successor { + Some(state) => { + RankedCandidate::with_payment_successor(prepared.candidate, score, state) + } + None => RankedCandidate::new(prepared.candidate, score), + } + }) + .collect(); + ranked.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.candidate.action.cmp_stable(&b.candidate.action)) + }); + ranked.truncate(limit); + ranked } #[derive(Debug, Clone)] @@ -76,6 +170,7 @@ impl SearchBudget { pub struct PolicyPrior { pub candidate: CandidateAction, pub prior: f64, + pub(crate) payment_successor: Option, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -721,7 +816,10 @@ impl<'a> PlannerServices<'a> { if out.len() == sample_count || self.deadline.expired() { break; } - if let Some(sim) = self.apply_candidate(state, &prior.candidate) { + let sim = prior + .payment_successor + .or_else(|| self.apply_candidate(state, &prior.candidate)); + if let Some(sim) = sim { out.push((prior.prior, sim)); } } @@ -1104,17 +1202,28 @@ impl<'a> PlannerServices<'a> { // (`apply_candidate` → None), mirroring the beam path. This removes one // clone-and-apply-per-candidate probe (`state_clone_for_legality`). let ctx = self.build_decision_context(state); + let candidates = prepare_payment_candidates(state, ctx.candidates.clone()); let scoring_player = state.waiting_for.acting_player().unwrap_or(self.ai_player); + let mut priors = self.policy_priors( + state, + &ctx, + &candidates + .iter() + .map(|prepared| prepared.candidate.clone()) + .collect::>(), + scoring_player, + SearchDepth::Lookahead, + ); + for prior in &mut priors { + prior.payment_successor = candidates + .iter() + .find(|prepared| prepared.candidate.action == prior.candidate.action) + .and_then(|prepared| prepared.payment_successor.clone()); + } PlannerEvaluation { // Rollout leaf: every node reached here is deep lookahead, never the // committed decision, so board-wide/affordability policies self-gate. - priors: self.policy_priors( - state, - &ctx, - &ctx.candidates, - scoring_player, - SearchDepth::Lookahead, - ), + priors, value: self.evaluate_for_planner(state), } } @@ -1243,15 +1352,16 @@ impl BeamContinuationPlanner { // path (planner_evaluation → sample_backfilled_continuations) applies the // same skip: illegal candidates are dropped when apply_candidate returns // None during backfill sampling, not by an upfront clone-per-candidate probe. - if ctx.candidates.is_empty() { + let candidates = prepare_payment_candidates(state, ctx.candidates.clone()); + if candidates.is_empty() { return services.evaluate_state_quiesced(state); } let node_player = state.waiting_for.acting_player(); let is_maximizing = node_player.is_none_or(|player| player == services.ai_player); let scoring_player = node_player.unwrap_or(services.ai_player); - let mut ranked = rank_candidates( - ctx.candidates.clone(), + let mut ranked = rank_prepared_candidates( + candidates, // Interior beam node: `search_value` is always entered ≥1 ply below // the decision root, so move-ordering scoring runs in lookahead. |candidate| { @@ -1285,7 +1395,11 @@ impl BeamContinuationPlanner { if services.deadline.expired() { break; } - let Some(sim) = services.apply_candidate(state, &ranked.candidate) else { + let Some(sim) = ranked + .payment_successor + .clone() + .or_else(|| services.apply_candidate(state, &ranked.candidate)) + else { continue; }; let value = self.search_value(&sim, depth - 1, ply + 1, alpha, beta, services, budget) @@ -1357,27 +1471,25 @@ pub fn rank_candidates( where F: FnMut(&CandidateAction) -> f64, { - let mut ranked: Vec = candidates - .into_iter() - .map(|candidate| RankedCandidate { - score: scorer(&candidate), + rank_prepared_candidates( + candidates.into_iter().map(|candidate| PreparedCandidate { candidate, - }) - .collect(); - ranked.sort_by(|a, b| { - b.score - .partial_cmp(&a.score) - .unwrap_or(std::cmp::Ordering::Equal) - // Issue #4878: without this tie-break, equal-score candidates fall - // back to `ranked`'s pre-sort (enumeration) order, which is not - // guaranteed stable across processes — mirrors search.rs:1956/2205. - .then_with(|| a.candidate.action.cmp_stable(&b.candidate.action)) - }); - ranked.truncate(limit); - ranked + payment_successor: None, + }), + |candidate| scorer(candidate), + limit, + ) } pub fn apply_candidate(state: &GameState, candidate: &CandidateAction) -> Option { + match classify_payment_continuation(state) { + PaymentContinuationState::Affiliated(_) => { + return witness_payment_continuation(state, &candidate.action) + .map(|accepted| accepted.state); + } + PaymentContinuationState::UnsupportedAffiliated(_) => return None, + PaymentContinuationState::NotAffiliated => {} + } let mut sim = state.clone(); apply_as_current_for_simulation(&mut sim, candidate.action.clone()).ok()?; Some(sim) @@ -2265,10 +2377,12 @@ mod tests { let illegal_prior = PolicyPrior { candidate: illegal.clone(), prior: 0.9, + payment_successor: None, }; let legal_prior = PolicyPrior { candidate: legal.clone(), prior: 0.1, + payment_successor: None, }; // sample_count=1: the high-prior illegal candidate is backfilled past, and @@ -2293,10 +2407,12 @@ mod tests { let legal_a = PolicyPrior { candidate: legal.clone(), prior: 0.2, + payment_successor: None, }; let legal_b = PolicyPrior { candidate: legal.clone(), prior: 0.15, + payment_successor: None, }; let two = services.sample_backfilled_continuations( &state, @@ -2327,6 +2443,49 @@ mod tests { ); } + #[test] + fn retained_payment_successor_bypasses_inapplicable_rollout_fallback() { + let state = make_state_with_land(); + let config = create_config(AiDifficulty::VeryHard, Platform::Native); + let policies = PolicyRegistry::default(); + let services = PlannerServices::new_default(PlayerId(0), &config, &policies); + let fallback = CandidateAction { + action: GameAction::ActivateAbility { + source_id: ObjectId(99999), + ability_index: 0, + }, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Mana), + }; + assert!( + services.apply_candidate(&state, &fallback).is_none(), + "reach-guard: the hostile fallback cannot be applied at this root" + ); + let retained = services + .apply_candidate( + &state, + &CandidateAction { + action: GameAction::PassPriority, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Pass), + }, + ) + .expect("reach-guard: a concrete retained successor exists"); + let sampled = services.sample_backfilled_continuations( + &state, + vec![PolicyPrior { + candidate: fallback, + prior: 1.0, + payment_successor: Some(retained.clone()), + }], + 1, + ); + assert_eq!(sampled.len(), 1); + assert_eq!( + quick_state_hash(&sampled[0].1), + quick_state_hash(&retained), + "rollout consumes the witnessed successor instead of retrying its hostile fallback" + ); + } + // T3′: rollout_estimate at its production entry point must not probe legality. // PINNED to a land-only empty-stack fixture: every sampled continuation leaves // an empty stack, so the depth-0 leaf never enters quiesce → build_decision_context @@ -2898,13 +3057,13 @@ mod tests { // ---- U2: move ordering (killers) + witness counters ---- fn ranked_candidate(action: GameAction, score: f64) -> RankedCandidate { - RankedCandidate { - candidate: CandidateAction { + RankedCandidate::new( + CandidateAction { action, metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Pass), }, score, - } + ) } // V1: a beta cutoff records the cutting move as the ply's primary killer. diff --git a/crates/phase-ai/src/policies/registry.rs b/crates/phase-ai/src/policies/registry.rs index 375205ccd0..e438983426 100644 --- a/crates/phase-ai/src/policies/registry.rs +++ b/crates/phase-ai/src/policies/registry.rs @@ -419,6 +419,12 @@ impl Default for PolicyRegistry { Box::new(super::draw_payoff::DrawPayoffPolicy), Box::new(super::discard_payoff::DiscardPayoffPolicy), ]; + Self::from_policies(policies) + } +} + +impl PolicyRegistry { + fn from_policies(policies: Vec>) -> Self { let mut by_kind: HashMap> = HashMap::new(); for (idx, policy) in policies.iter().enumerate() { for kind in policy.decision_kinds() { @@ -427,9 +433,13 @@ impl Default for PolicyRegistry { } Self { policies, by_kind } } -} -impl PolicyRegistry { + #[cfg(test)] + #[allow(dead_code)] + pub(crate) fn for_tests(policies: Vec>) -> Self { + Self::from_policies(policies) + } + /// Return a process-wide shared `PolicyRegistry`, constructed once on first /// access. Policies are stateless (`TacticalPolicy: Send + Sync`, no /// interior mutability by construction), so a single instance safely @@ -580,7 +590,11 @@ impl PolicyRegistry { return candidates .iter() .cloned() - .map(|candidate| PolicyPrior { candidate, prior }) + .map(|candidate| PolicyPrior { + candidate, + prior, + payment_successor: None, + }) .collect(); } let shifted: Vec = raw_scores @@ -604,7 +618,11 @@ impl PolicyRegistry { return candidates .iter() .cloned() - .map(|candidate| PolicyPrior { candidate, prior }) + .map(|candidate| PolicyPrior { + candidate, + prior, + payment_successor: None, + }) .collect(); } @@ -615,6 +633,7 @@ impl PolicyRegistry { .map(|(candidate, prior)| PolicyPrior { candidate, prior: prior / total, + payment_successor: None, }) .collect() } diff --git a/crates/phase-ai/src/projection.rs b/crates/phase-ai/src/projection.rs index 9ced93443c..743de5f16e 100644 --- a/crates/phase-ai/src/projection.rs +++ b/crates/phase-ai/src/projection.rs @@ -11,7 +11,10 @@ use std::collections::HashMap; -use engine::ai_support::legal_actions; +use engine::ai_support::{ + classify_payment_continuation, legal_actions, witness_payment_continuation, + PaymentContinuationState, +}; use engine::game::combat::AttackTarget; use engine::game::engine::{apply_for_simulation, EngineError}; use engine::types::game_state::{ManaChoice, ManaChoicePrompt}; @@ -19,7 +22,6 @@ use engine::types::{ CoreType, GameAction, GameState, ObjectId, PayCostKind, Phase, PlayerId, WaitingFor, }; -use crate::mana_colors::demand_aware_single_color; use web_time::{Duration, Instant}; /// How far into the opponent's upcoming turn to project. @@ -157,12 +159,17 @@ pub fn project_to( }); } - let (actor, action, is_policy_choice) = resolve_choice(&state, ai_player, target_opponent)?; + let (actor, action, is_policy_choice, witnessed_successor) = + resolve_choice(&state, ai_player, target_opponent)?; if is_policy_choice { choice_count += 1; } - apply_for_simulation(&mut state, actor, action).map_err(BailReason::EngineRejected)?; + if let Some(successor) = witnessed_successor { + state = successor; + } else { + apply_for_simulation(&mut state, actor, action).map_err(BailReason::EngineRejected)?; + } if matches!(state.waiting_for, WaitingFor::GameOver { .. }) { return Err(BailReason::GameOverDuringProjection); @@ -283,7 +290,7 @@ fn resolve_choice( state: &GameState, ai_player: PlayerId, target_opponent: PlayerId, -) -> Result<(PlayerId, GameAction, bool), BailReason> { +) -> Result<(PlayerId, GameAction, bool, Option), BailReason> { // Impossible-mid-game gates. match &state.waiting_for { WaitingFor::MulliganDecision { .. } @@ -312,6 +319,22 @@ fn resolve_choice( }); } + match classify_payment_continuation(state) { + PaymentContinuationState::NotAffiliated => {} + PaymentContinuationState::UnsupportedAffiliated(_) => { + return Err(BailReason::NoLegalManaPayment); + } + PaymentContinuationState::Affiliated(_) => { + let mut actions = actions; + actions.sort_by(|left, right| left.cmp_stable(right)); + let accepted = actions + .into_iter() + .find_map(|action| witness_payment_continuation(state, &action)) + .ok_or(BailReason::NoLegalManaPayment)?; + return Ok((acting, accepted.action, true, Some(accepted.state))); + } + } + // Policy dispatch on WaitingFor kind + actor identity. let action = match &state.waiting_for { WaitingFor::Priority { .. } => pick_pass_or_first(&actions), @@ -354,9 +377,7 @@ fn resolve_choice( | PayCostKind::TapCreatures { .. }, .. } - | WaitingFor::ManaPayment { .. } | WaitingFor::DefilerPayment { .. } - | WaitingFor::PhyrexianPayment { .. } | WaitingFor::CombatTaxPayment { .. } | WaitingFor::HarmonizeTapChoice { .. } | WaitingFor::AlternativeCastChoice { .. } @@ -368,14 +389,12 @@ fn resolve_choice( .ok_or(BailReason::NoLegalManaPayment)? } - // CR 106.3 + CR 608.2d: Mana-color choice during payment. The - // SingleColor prompt must produce the color the pending cost demands — - // projecting an arbitrary color (the old `actions.first()`) can strand a - // colored pip and dead-end the projected ManaPayment, mirroring the live - // AI bug fixed in `search.rs`. Combination / AnyCombination keep - // first-legal, matching the `fallback_action` shapes. + // Non-payment color choices retain their established first-legal policy. + // Affiliated mana-ability choices return through the witness above. WaitingFor::ChooseManaColor { choice, .. } => match choice { - ManaChoicePrompt::SingleColor { options } => demand_aware_single_color(options, state) + ManaChoicePrompt::SingleColor { options } => options + .first() + .copied() .map(|color| GameAction::ChooseManaColor { choice: ManaChoice::SingleColor(color), count: 1, @@ -491,7 +510,7 @@ fn resolve_choice( }; let is_policy_choice = !matches!(action, GameAction::PassPriority); - Ok((acting, action, is_policy_choice)) + Ok((acting, action, is_policy_choice, None)) } fn pick_pass_or_first(actions: &[GameAction]) -> GameAction { @@ -630,7 +649,7 @@ mod tests { schema: engine::analysis::decision_template::ShortcutDecisionSchema::default(), }; - let (_actor, action, is_policy_choice) = + let (_actor, action, is_policy_choice, _successor) = resolve_choice(&state, PlayerId(0), PlayerId(1)).expect("the offer has legal actions"); assert_eq!(action, GameAction::DeclineShortcut); assert!( @@ -687,7 +706,7 @@ mod tests { #[test] fn projection_declines_precast_shortcut_offer() { let state = precast_offer_state(); - let (_, action, is_policy_choice) = + let (_, action, is_policy_choice, _successor) = resolve_choice(&state, PlayerId(0), PlayerId(1)).expect("offer has a legal decline"); assert!(matches!( diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index f336c6a8f9..fc84ed5f34 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -26,11 +26,10 @@ use crate::combat_ai::{choose_attackers_with_targets_with_profile, choose_blocke use crate::config::{AiConfig, PlannerMode, ThreatAwareness}; use crate::context::AiContext; use crate::features::DeckFeatures; -use crate::mana_colors::demand_aware_single_color; use crate::plan::{PlanSnapshot, PlanState}; use crate::planner::{ - apply_candidate, BeamContinuationPlanner, ContinuationPlanner, PlannerServices, - RankedCandidate, RungStat, SearchBudget, + apply_candidate, prepare_payment_candidates, BeamContinuationPlanner, ContinuationPlanner, + PlannerServices, PreparedCandidate, RankedCandidate, RungStat, SearchBudget, }; use crate::policies::context::{PolicyContext, SearchDepth}; use crate::policies::copy_value::score_legend_rule_keep; @@ -201,28 +200,6 @@ pub fn choose_action_with_session( } } - // CR 106.3 + CR 608.2d: Selecting which color a flexible mana source - // produces while paying for a spell is mechanical, not a policy judgment — - // the AI must produce the color the in-flight cost demands. `candidates.rs` - // enumerates *every* color option, so `score_candidates` is always non-empty - // and the old `fallback_action` SingleColor arm never fired on the normal - // path; the scorer then picked an arbitrary (first-enumerated) color, - // tapping a U/R dual for {R} when the spell needed {U} and stranding the pip - // (ManaPayment dead-end). This deterministic pre-emption — parallel to the - // TributeChoice and SearchChoice pre-emptions above — is the real fix. - if let WaitingFor::ChooseManaColor { - choice: ManaChoicePrompt::SingleColor { ref options }, - .. - } = state.waiting_for - { - if let Some(color) = demand_aware_single_color(options, state) { - return Some(GameAction::ChooseManaColor { - choice: ManaChoice::SingleColor(color), - count: 1, - }); - } - } - // CR 608.2d (hidden information): the guesser has no legal access to the // committed value / chosen-card identity — it is genuinely a guess. The AI // MUST NOT score guess branches via `score_candidates` (eval/search runs on @@ -1649,40 +1626,36 @@ pub fn fallback_action(state: &GameState, config: &AiConfig) -> Option { - match choice { - ManaChoicePrompt::SingleColor { options } => { - // Defense-in-depth: the primary fix is the pre-emption in - // `choose_action_with_session`; this honors the demanded - // color too should this path ever be reached. - demand_aware_single_color(options, state).map(|color| { - GameAction::ChooseManaColor { - choice: ManaChoice::SingleColor(color), - count: 1, - } - }) - } - ManaChoicePrompt::Combination { options } => { - options.first().map(|combo| GameAction::ChooseManaColor { - choice: ManaChoice::Combination(combo.clone()), - count: 1, - }) - } - ManaChoicePrompt::AnyCombination { count, options } => { - let combo = vec![ - options - .first() - .copied() - .unwrap_or(engine::types::mana::ManaType::Colorless); - *count - ]; - Some(GameAction::ChooseManaColor { - choice: ManaChoice::Combination(combo), + WaitingFor::ChooseManaColor { choice, .. } => match choice { + ManaChoicePrompt::SingleColor { options } => { + options + .first() + .copied() + .map(|color| GameAction::ChooseManaColor { + choice: ManaChoice::SingleColor(color), count: 1, }) - } } - } + ManaChoicePrompt::Combination { options } => { + options.first().map(|combo| GameAction::ChooseManaColor { + choice: ManaChoice::Combination(combo.clone()), + count: 1, + }) + } + ManaChoicePrompt::AnyCombination { count, options } => { + let combo = vec![ + options + .first() + .copied() + .unwrap_or(engine::types::mana::ManaType::Colorless); + *count + ]; + Some(GameAction::ChooseManaColor { + choice: ManaChoice::Combination(combo), + count: 1, + }) + } + }, WaitingFor::PayManaAbilityMana { options, .. } => { options.first().map(|plan| GameAction::PayManaAbilityMana { payment: plan.clone(), @@ -1980,6 +1953,57 @@ fn priority_action_is_allowed_by_loop_guards( } } +/// Rank the root beam after validation and gating, retaining an affiliated +/// payment candidate's already-witnessed reducer successor through width +/// truncation. This is the single production seam for root payment ranking; +/// tests exercise it directly to prove the enabled-search beam boundary. +fn rank_root_payment_candidates( + state: &GameState, + decision: &engine::ai_support::AiDecisionContext, + prepared: &[PreparedCandidate], + gated: &[crate::tactical_gate::GatedCandidate], + services: &PlannerServices<'_>, + max_branching: usize, +) -> Vec { + let mut ranked: Vec = gated + .iter() + .map(|gated_candidate| { + let score = services.tactical_score( + state, + decision, + &gated_candidate.candidate, + services.ai_player, + SearchDepth::Root, + ) + gated_candidate.penalty; + prepared + .iter() + .find(|prepared_candidate| { + prepared_candidate.candidate.action == gated_candidate.candidate.action + }) + .and_then(|prepared_candidate| prepared_candidate.payment_successor.clone()) + .map_or_else( + || RankedCandidate::new(gated_candidate.candidate.clone(), score), + |successor| { + RankedCandidate::with_payment_successor( + gated_candidate.candidate.clone(), + score, + successor, + ) + }, + ) + }) + .collect(); + ranked.sort_by(|left, right| { + right + .score + .partial_cmp(&left.score) + .unwrap_or(Ordering::Equal) + .then_with(|| left.candidate.action.cmp_stable(&right.candidate.action)) + }); + ranked.truncate(max_branching); + ranked +} + /// Core scoring for a single (possibly determinized) state. Byte-identical to /// the pre-feature `score_candidates_with_session` except it threads a shared /// `deadline_override` into `PlannerServices` — `None` reproduces the old @@ -1996,6 +2020,12 @@ fn score_candidates_core( } let ctx = build_decision_context(state); + #[cfg(test)] + let policies = session + .policy_registry_override + .as_deref() + .unwrap_or_else(|| PolicyRegistry::shared()); + #[cfg(not(test))] let policies = PolicyRegistry::shared(); let context = build_ai_context_with_session(state, ai_player, config, Arc::clone(session)); @@ -2021,7 +2051,14 @@ fn score_candidates_core( let mut services = PlannerServices::with_deadline(ai_player, config, policies, context, deadline_override); - let candidates = services.validate_candidates(state, ctx.candidates.clone()); + let prepared = prepare_payment_candidates(state, ctx.candidates.clone()); + let candidates = services.validate_candidates( + state, + prepared + .iter() + .map(|candidate| candidate.candidate.clone()) + .collect(), + ); let gated = gate_candidates( state, &ctx, @@ -2052,10 +2089,15 @@ fn score_candidates_core( // Deterministic early returns — these don't benefit from search/parallelism. // Pass the already-built context so the mulligan branch avoids a second // full deck analysis (DeckProfile + SynergyGraph for both players). - if let Some(action) = - deterministic_choice(state, ai_player, config, &actions, Some(&services.context)) - { - return vec![(action, 1.0)]; + if matches!( + engine::ai_support::classify_payment_continuation(state), + engine::ai_support::PaymentContinuationState::NotAffiliated + ) { + if let Some(action) = + deterministic_choice(state, ai_player, config, &actions, Some(&services.context)) + { + return vec![(action, 1.0)]; + } } // Score actions via search or heuristics @@ -2096,29 +2138,8 @@ fn score_candidates_core( // O(n) linear scan of `gated` per scored candidate — O(n²) overall. // GameAction is not Hash, so we can't key a HashMap; carrying the // penalty with its candidate is both cheaper and more idiomatic. - let mut ranked: Vec = gated - .iter() - .map(|g| { - let tactical = services.tactical_score( - state, - &ctx, - &g.candidate, - ai_player, - SearchDepth::Root, - ); - RankedCandidate { - candidate: g.candidate.clone(), - score: tactical + g.penalty, - } - }) - .collect(); - ranked.sort_by(|a, b| { - b.score - .partial_cmp(&a.score) - .unwrap_or(Ordering::Equal) - .then_with(|| a.candidate.action.cmp_stable(&b.candidate.action)) - }); - ranked.truncate(branching); + let ranked = + rank_root_payment_candidates(state, &ctx, &prepared, &gated, &services, branching); run_iterative_deepening(state, ranked, tactical_weight, config, &mut services) } else { @@ -2204,7 +2225,11 @@ fn run_iterative_deepening( completed = false; break; } - let score = if let Some(sim) = apply_candidate(state, &r.candidate) { + let score = if let Some(sim) = r + .payment_successor + .clone() + .or_else(|| apply_candidate(state, &r.candidate)) + { let cont = planner.evaluate_after_action(&sim, services, &mut budget); cont + (r.score * tactical_weight) } else { @@ -3218,11 +3243,12 @@ pub fn softmax_select_pairs( mod tests { use super::*; use engine::ai_support::{ActionMetadata, AiDecisionContext, CandidateAction, TacticalClass}; + use engine::game::scenario::{GameScenario, P0}; use engine::game::zones::create_object; use engine::types::ability::{ - AbilityDefinition, AbilityKind, CategoryChooserScope, ContinuousModification, Duration, - Effect, EffectKind, QuantityExpr, ResolvedAbility, StaticDefinition, TargetFilter, - TargetRef, TypedFilter, + AbilityCost, AbilityDefinition, AbilityKind, CategoryChooserScope, ContinuousModification, + Duration, Effect, EffectKind, ManaProduction, QuantityExpr, ResolvedAbility, + StaticDefinition, TargetFilter, TargetRef, TypedFilter, }; use engine::types::ability::{ChoiceType, ChosenAttribute}; use engine::types::card_type::CoreType; @@ -3232,7 +3258,8 @@ mod tests { PromptSourceBinding, StackEntry, StackEntryKind, }; use engine::types::identifiers::{CardId, ObjectId}; - use engine::types::mana::{ManaCostShard, ManaType, ManaUnit}; + use engine::types::keywords::Keyword; + use engine::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType, ManaUnit}; use engine::types::phase::Phase; use engine::types::zones::Zone; use rand::rngs::SmallRng; @@ -3240,6 +3267,7 @@ mod tests { use crate::config::{create_config, AiDifficulty, Platform}; use crate::policies::context::PolicyContext; + use crate::policies::{DecisionKind, PolicyReason, TacticalPolicy}; use crate::session::SessionCache; use crate::test_support::{context_with_plans, default_deck_plan, ramp_deck_plan}; @@ -4485,28 +4513,6 @@ mod tests { ); } - fn pending_cast_with_cost( - shards: Vec, - generic: u32, - ) -> Box { - use engine::types::ability::{QuantityExpr, ResolvedAbility, TargetFilter}; - use engine::types::game_state::PendingCast; - Box::new(PendingCast::new( - ObjectId(100), - CardId(100), - ResolvedAbility::new( - engine::types::ability::Effect::Draw { - count: QuantityExpr::Fixed { value: 0 }, - target: TargetFilter::Controller, - }, - Vec::new(), - ObjectId(100), - PlayerId(0), - ), - engine::types::mana::ManaCost::Cost { shards, generic }, - )) - } - fn spell_target_selection_state( current_legal_targets: Vec, stale_slot_targets: Vec, @@ -4564,21 +4570,13 @@ mod tests { state } - /// Minimal ChooseManaColor SingleColor state with the given option list and - /// pending cast. The `context` is a degenerate `ResolvingEffect` resume — the - /// pre-emption never inspects it, only `choice` and `state.pending_cast`. - /// Production Improvise/dual repro paths use `ManaChoiceContext::ManaAbility`, - /// but the context variant is irrelevant to the pre-emption (which reads only - /// `pending_cast` + `options`), so `ResolvingEffect` is used for fixture - /// simplicity. - fn choose_mana_color_state( - options: Vec, - pending: Option>, - ) -> GameState { + /// Minimal non-payment mana-color prompt. The production payment path uses + /// a live mana-ability carrier below; this fixture is deliberately outside + /// that authority so it pins the established first-option fallback. + fn non_affiliated_choose_mana_color_state(options: Vec) -> GameState { use engine::types::ability::{QuantityExpr, ResolvedAbility, TargetFilter}; use engine::types::game_state::{ManaChoiceContext, ManaChoicePrompt}; let mut state = make_state(); - state.pending_cast = pending; let resume = ResolvedAbility::new( engine::types::ability::Effect::Draw { count: QuantityExpr::Fixed { value: 0 }, @@ -4597,85 +4595,396 @@ mod tests { } #[test] - fn choose_mana_color_preemption_selects_demanded_color() { - // Repro: {2}{U} spell, U/R source offered [Red, Blue]. The AI must - // produce Blue (demanded) — the old scorer picked the first-enumerated - // color (Red) and stranded the {U} pip into a ManaPayment dead-end. - // Drives the PUBLIC choose_action path, not fallback_action. - let state = choose_mana_color_state( - vec![ManaType::Red, ManaType::Blue], - Some(pending_cast_with_cost( - vec![engine::types::mana::ManaCostShard::Blue], - 2, - )), - ); + fn non_affiliated_choose_mana_color_uses_first_option() { + let state = non_affiliated_choose_mana_color_state(vec![ManaType::Red, ManaType::Blue]); let config = create_config(AiDifficulty::Medium, Platform::Native); let mut rng = SmallRng::seed_from_u64(1); assert_eq!( choose_action(&state, PlayerId(0), &config, &mut rng), Some(GameAction::ChooseManaColor { - choice: engine::types::game_state::ManaChoice::SingleColor(ManaType::Blue), + choice: engine::types::game_state::ManaChoice::SingleColor(ManaType::Red), count: 1, }) ); } + fn flexible_mana_payment_state() -> GameState { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Flexible AI Witness", true, "Draw a card.") + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Blue], + generic: 1, + }) + .id(); + let source = scenario.add_creature(P0, "Flexible AI Source", 1, 1).id(); + let mut runner = scenario.build(); + let ability = AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::AnyCombination { + count: QuantityExpr::Fixed { value: 2 }, + color_options: vec![ManaColor::Blue, ManaColor::Red], + }, + restrictions: Vec::new(), + grants: Vec::new(), + expiry: None, + target: None, + }, + ) + .cost(AbilityCost::Tap); + let source_object = runner.state_mut().objects.get_mut(&source).unwrap(); + Arc::make_mut(&mut source_object.abilities).push(ability); + let card_id = runner.state().objects[&spell].card_id; + runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: Vec::new(), + payment_mode: engine::types::game_state::CastPaymentMode::Manual, + }) + .expect("the test spell reaches manual payment"); + runner + .act(GameAction::ActivateAbility { + source_id: source, + ability_index: 0, + }) + .expect("the real mana ability opens its colour prompt"); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::ChooseManaColor { .. } + )); + runner.state().clone() + } + + fn mana_product(colors: &[ManaType]) -> GameAction { + GameAction::ChooseManaColor { + choice: engine::types::game_state::ManaChoice::Combination(colors.to_vec()), + count: 1, + } + } + + /// Reach a live CR 702.126a Improvise payment carrier, then leave its last + /// mana open for the red-first flexible mana ability. `improvise_taps` + /// deliberately differs between the coloured and generic control so each + /// still needs exactly one colour allocation after its artifact payment. + fn red_first_improvise_payment_state(cost: ManaCost, improvise_taps: usize) -> GameState { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = { + let mut builder = scenario.add_spell_to_hand_from_oracle( + P0, + "Improvise Payment Witness", + true, + "Draw a card.", + ); + builder.with_mana_cost(cost); + builder.with_keyword(Keyword::Improvise); + builder.id() + }; + let artifacts: Vec<_> = (0..improvise_taps) + .map(|index| { + let mut builder = + scenario.add_creature(P0, &format!("Improvise Artifact {index}"), 0, 1); + builder.as_artifact(); + builder.id() + }) + .collect(); + let source = scenario + .add_creature(P0, "Red First Mana Source", 1, 1) + .id(); + let mut runner = scenario.build(); + let ability = AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::AnyCombination { + count: QuantityExpr::Fixed { value: 1 }, + color_options: vec![ManaColor::Red, ManaColor::Blue], + }, + restrictions: Vec::new(), + grants: Vec::new(), + expiry: None, + target: None, + }, + ) + .cost(AbilityCost::Tap); + Arc::make_mut( + &mut runner + .state_mut() + .objects + .get_mut(&source) + .unwrap() + .abilities, + ) + .push(ability); + let card_id = runner.state().objects[&spell].card_id; + runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: Vec::new(), + payment_mode: engine::types::game_state::CastPaymentMode::Manual, + }) + .expect("the Improvise spell reaches manual payment"); + for artifact in artifacts { + runner + .act(GameAction::TapForConvoke { + object_id: artifact, + mana_type: ManaType::Colorless, + }) + .expect("each artifact pays one generic mana through Improvise"); + } + runner + .act(GameAction::ActivateAbility { + source_id: source, + ability_index: 0, + }) + .expect("the real mana ability opens its red-first colour prompt"); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::ChooseManaColor { .. } + )); + runner.state().clone() + } + + struct FlexibleManaPolicy(Arc); + + impl TacticalPolicy for FlexibleManaPolicy { + fn id(&self) -> PolicyId { + PolicyId::PaymentSelection + } + + fn decision_kinds(&self) -> &'static [DecisionKind] { + &[DecisionKind::ActivateAbility] + } + + fn activation( + &self, + _: &crate::features::DeckFeatures, + _: &GameState, + _: PlayerId, + ) -> Option { + Some(1.0) + } + + fn verdict(&self, context: &PolicyContext<'_>) -> PolicyVerdict { + self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + match &context.candidate.action { + GameAction::ChooseManaColor { + choice: engine::types::game_state::ManaChoice::Combination(colors), + .. + } if colors == &[ManaType::Blue, ManaType::Red] => { + PolicyVerdict::critical(15.0, PolicyReason::new("flexible_mana_test")) + } + GameAction::ChooseManaColor { + choice: engine::types::game_state::ManaChoice::Combination(colors), + .. + } if colors == &[ManaType::Red, ManaType::Blue] => { + PolicyVerdict::strong(5.0, PolicyReason::new("flexible_mana_test")) + } + _ => PolicyVerdict::neutral(PolicyReason::new("flexible_mana_test")), + } + } + } + + fn flexible_mana_session( + state: &GameState, + calls: Arc, + ) -> Arc { + let mut session = AiSession::from_game(state); + session.policy_registry_override = + Some(Arc::new(PolicyRegistry::for_tests(vec![Box::new( + FlexibleManaPolicy(calls), + )]))); + Arc::new(session) + } + #[test] - fn choose_mana_color_preemption_selects_demanded_red() { - // {1}{R} spell, source offered [Blue, Red] → must produce Red. - let state = choose_mana_color_state( - vec![ManaType::Blue, ManaType::Red], - Some(pending_cast_with_cost( - vec![engine::types::mana::ManaCostShard::Red], - 1, - )), + fn affiliated_flexible_mana_uses_witnessed_support_in_public_and_enabled_beam_paths() { + let state = flexible_mana_payment_state(); + let all = engine::ai_support::legal_actions(&state); + let expected_all = vec![ + mana_product(&[ManaType::Blue, ManaType::Blue]), + mana_product(&[ManaType::Blue, ManaType::Red]), + mana_product(&[ManaType::Red, ManaType::Blue]), + mana_product(&[ManaType::Red, ManaType::Red]), + ]; + assert_eq!( + all, expected_all, + "live AnyCombination exposes all four products" ); - let config = create_config(AiDifficulty::Medium, Platform::Native); - let mut rng = SmallRng::seed_from_u64(1); + let witnessed: Vec<_> = all + .iter() + .filter_map(|action| engine::ai_support::witness_payment_continuation(&state, action)) + .collect(); + let expected = expected_all[..3].to_vec(); assert_eq!( - choose_action(&state, PlayerId(0), &config, &mut rng), - Some(GameAction::ChooseManaColor { - choice: engine::types::game_state::ManaChoice::SingleColor(ManaType::Red), - count: 1, - }) + witnessed + .iter() + .map(|accepted| accepted.action.clone()) + .collect::>(), + expected, + "only the three products that can finish the announced root survive" + ); + + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let session = flexible_mana_session(&state, Arc::clone(&calls)); + let mut disabled = create_config(AiDifficulty::Hard, Platform::Native).into_measurement(43); + disabled.search.enabled = false; + disabled.temperature = 0.25; + let scored = score_candidates_with_session(&state, P0, &disabled, &session); + assert_eq!( + scored + .iter() + .map(|(action, _)| action.clone()) + .collect::>(), + expected, + "the public scorer retains every witnessed successor and rejects RR" ); - } + assert!(calls.load(std::sync::atomic::Ordering::Relaxed) >= 3); + assert_eq!(score_of(&scored, &expected[0]), 0.45); + assert_eq!(score_of(&scored, &expected[1]), 15.45); + assert_eq!(score_of(&scored, &expected[2]), 5.45); - #[test] - fn choose_mana_color_preemption_demanded_color_first() { - // Demanded color is first here; paired with selects_demanded_color - // (demanded second) this proves demand-driven, not positional. - let state = choose_mana_color_state( - vec![ManaType::Blue, ManaType::Red], - Some(pending_cast_with_cost( - vec![engine::types::mana::ManaCostShard::Blue], - 2, - )), + let max_score = scored + .iter() + .map(|(_, score)| *score) + .fold(f64::NEG_INFINITY, f64::max); + let weights: Vec<_> = scored + .iter() + .map(|(_, score)| ((*score - max_score) / disabled.temperature).exp()) + .collect(); + let total: f64 = weights.iter().sum(); + let mut threshold_rng = SmallRng::seed_from_u64(0); + let threshold = threshold_rng.random::() * total; + assert!( + weights[0] < threshold && threshold <= weights[0] + weights[1], + "the seeded full-support softmax threshold lies in BR's interval" ); - let config = create_config(AiDifficulty::Medium, Platform::Native); - let mut rng = SmallRng::seed_from_u64(1); + let mut direct_rng = SmallRng::seed_from_u64(0); assert_eq!( - choose_action(&state, PlayerId(0), &config, &mut rng), - Some(GameAction::ChooseManaColor { - choice: engine::types::game_state::ManaChoice::SingleColor(ManaType::Blue), - count: 1, - }) + softmax_select_pairs(&scored, disabled.temperature, &mut direct_rng), + Some(expected[1].clone()), + "the full accepted support selects BR, not stable-first BB" + ); + calls.store(0, std::sync::atomic::Ordering::Relaxed); + let mut chooser_rng = SmallRng::seed_from_u64(0); + assert_eq!( + choose_action_with_session(&state, P0, &disabled, &mut chooser_rng, &session), + Some(expected[1].clone()), + "the disabled public chooser uses the ordinary full-support softmax path" + ); + assert!( + calls.load(std::sync::atomic::Ordering::Relaxed) > 0, + "the public chooser reaches tactical policy scoring after its counter reset" + ); + + calls.store(0, std::sync::atomic::Ordering::Relaxed); + let mut enabled = create_config(AiDifficulty::Hard, Platform::Native).into_measurement(43); + enabled.search.enabled = true; + enabled.search.max_branching = 3; + enabled.search.planner_mode = PlannerMode::BeamOnly; + enabled.search.determinization_samples = 0; + let context = build_ai_context_with_session(&state, P0, &enabled, Arc::clone(&session)); + let policies = session.policy_registry_override.as_deref().unwrap(); + let services = PlannerServices::with_deadline(P0, &enabled, policies, context, None); + let decision = build_decision_context(&state); + let prepared = prepare_payment_candidates(&state, decision.candidates.clone()); + let validated = services.validate_candidates( + &state, + prepared + .iter() + .map(|candidate| candidate.candidate.clone()) + .collect(), + ); + let gated = gate_candidates( + &state, + &decision, + validated, + P0, + &enabled, + &services.context, + ); + let beam = rank_root_payment_candidates(&state, &decision, &prepared, &gated, &services, 3); + assert_eq!( + beam.iter() + .map(|candidate| candidate.candidate.action.clone()) + .collect::>(), + vec![ + expected[1].clone(), + expected[2].clone(), + expected[0].clone() + ], + "the enabled root beam ranks BR > RB > BB and retains width three" ); + assert!(beam + .iter() + .all(|candidate| candidate.payment_successor.is_some())); + + calls.store(0, std::sync::atomic::Ordering::Relaxed); + let enabled_scored = score_candidates_with_session(&state, P0, &enabled, &session); + assert_eq!(enabled_scored.len(), 3); + assert!(enabled_scored + .iter() + .all(|(action, _)| expected.contains(action))); + assert!(calls.load(std::sync::atomic::Ordering::Relaxed) > 0); + + calls.store(0, std::sync::atomic::Ordering::Relaxed); + let mut rng = SmallRng::seed_from_u64(0); + let chosen = choose_action_with_session(&state, P0, &enabled, &mut rng, &session); + assert!(chosen + .as_ref() + .is_some_and(|action| expected.contains(action))); + assert!(calls.load(std::sync::atomic::Ordering::Relaxed) > 0); } #[test] - fn choose_mana_color_preemption_no_pending_cast_uses_first() { - // No pending cast (e.g. a mana-ability color choice at priority) → - // first option, identical to the old behavior. - let state = choose_mana_color_state(vec![ManaType::Red, ManaType::Blue], None); - let config = create_config(AiDifficulty::Medium, Platform::Native); - let mut rng = SmallRng::seed_from_u64(1); - assert_eq!( - choose_action(&state, PlayerId(0), &config, &mut rng), - Some(GameAction::ChooseManaColor { - choice: engine::types::game_state::ManaChoice::SingleColor(ManaType::Red), - count: 1, - }) + fn improvise_mana_only_strands_mandatory_blue() { + let coloured = red_first_improvise_payment_state( + ManaCost::Cost { + shards: vec![ManaCostShard::Blue], + generic: 2, + }, + 2, + ); + let generic = red_first_improvise_payment_state(ManaCost::generic(2), 1); + let red = mana_product(&[ManaType::Red]); + let blue = mana_product(&[ManaType::Blue]); + + let coloured_actions = engine::ai_support::legal_actions(&coloured); + assert!( + coloured_actions.contains(&red), + "the live Metallic-Rebuke-style carrier offers a red allocation" + ); + assert!( + coloured_actions.contains(&blue), + "the live Metallic-Rebuke-style carrier offers a blue allocation" + ); + assert!(matches!( + engine::ai_support::classify_payment_continuation(&coloured), + engine::ai_support::PaymentContinuationState::Affiliated(_) + )); + assert!( + engine::ai_support::witness_payment_continuation(&coloured, &red).is_none(), + "red cannot pay the mandatory blue shard after Improvise covers only generic mana" + ); + assert!( + engine::ai_support::witness_payment_continuation(&coloured, &blue).is_some(), + "blue finalizes the coloured Improvise cast" + ); + let generic_actions = engine::ai_support::legal_actions(&generic); + assert!( + generic_actions.contains(&red), + "the paired generic control offers a red allocation" + ); + assert!( + generic_actions.contains(&blue), + "the paired generic control offers a blue allocation" + ); + assert!( + engine::ai_support::witness_payment_continuation(&generic, &red).is_some(), + "red remains a valid final allocation when no mandatory blue shard exists" ); } @@ -6614,14 +6923,19 @@ mod tests { // ---- U2: PV threading + rung witnesses (drive `run_iterative_deepening`) ---- - /// Rebuild the root beam exactly as `score_candidates_core` does (validate -> - /// gate -> tactical rank -> #4878 stable sort -> truncate) so tests can drive - /// `run_iterative_deepening` directly and inspect the witness state it leaves - /// on `services`. `&PlannerServices` — reads only (validate/tactical_score are - /// `&self`); the caller owns construction so it controls the deadline/TT. + /// Reach the production root beam seam so iterative-deepening tests observe + /// the same validation, payment-successor retention, rank, and width path + /// that public scoring uses. fn build_root_beam(state: &GameState, services: &PlannerServices<'_>) -> Vec { let ctx = build_decision_context(state); - let candidates = services.validate_candidates(state, ctx.candidates.clone()); + let prepared = prepare_payment_candidates(state, ctx.candidates.clone()); + let candidates = services.validate_candidates( + state, + prepared + .iter() + .map(|candidate| candidate.candidate.clone()) + .collect(), + ); let gated = gate_candidates( state, &ctx, @@ -6630,30 +6944,25 @@ mod tests { services.config, &services.context, ); - let mut ranked: Vec = gated - .iter() - .map(|g| { - let tactical = services.tactical_score( + let mut gated: Vec<_> = gated + .into_iter() + .filter(|candidate| { + priority_action_is_allowed_by_loop_guards( state, - &ctx, - &g.candidate, services.ai_player, - SearchDepth::Root, - ); - RankedCandidate { - candidate: g.candidate.clone(), - score: tactical + g.penalty, - } + &candidate.candidate.action, + ) }) .collect(); - ranked.sort_by(|a, b| { - b.score - .partial_cmp(&a.score) - .unwrap_or(Ordering::Equal) - .then_with(|| a.candidate.action.cmp_stable(&b.candidate.action)) - }); - ranked.truncate(services.config.search.max_branching as usize); - ranked + gated.sort_by(|left, right| left.candidate.action.cmp_stable(&right.candidate.action)); + rank_root_payment_candidates( + state, + &ctx, + &prepared, + &gated, + services, + services.config.search.max_branching as usize, + ) } fn score_of(scored: &[(GameAction, f64)], action: &GameAction) -> f64 { @@ -6664,6 +6973,65 @@ mod tests { .unwrap_or_else(|| panic!("action {action:?} absent from scored output")) } + #[test] + fn retained_root_payment_successor_bypasses_inapplicable_fallback() { + let state = make_state(); + let retained = apply_candidate( + &state, + &CandidateAction { + action: GameAction::PassPriority, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Pass), + }, + ) + .expect("reach-guard: a concrete root successor exists"); + let hostile = CandidateAction { + action: GameAction::ActivateAbility { + source_id: ObjectId(99999), + ability_index: 0, + }, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Mana), + }; + assert!( + apply_candidate(&state, &hostile).is_none(), + "reach-guard: the fallback action is inapplicable at the root" + ); + let mut config = create_config(AiDifficulty::Hard, Platform::Native).into_measurement(43); + config.search.planner_mode = PlannerMode::BeamOnly; + let policies = PolicyRegistry::shared(); + let mut hostile_services = PlannerServices::new_default(PlayerId(0), &config, policies); + let hostile_result = run_iterative_deepening( + &state, + vec![RankedCandidate::with_payment_successor( + hostile, + 0.0, + retained.clone(), + )], + 0.1, + &config, + &mut hostile_services, + ); + let mut control_services = PlannerServices::new_default(PlayerId(0), &config, policies); + let control_result = run_iterative_deepening( + &state, + vec![RankedCandidate::with_payment_successor( + CandidateAction { + action: GameAction::PassPriority, + metadata: ActionMetadata::for_actor(Some(PlayerId(0)), TacticalClass::Pass), + }, + 0.0, + retained, + )], + 0.1, + &config, + &mut control_services, + ); + assert_eq!(hostile_result[0].1, control_result[0].1); + assert!( + hostile_result[0].1 > -900.0, + "the retained successor prevents the failed-apply penalty" + ); + } + /// Fixture with several cheap castable creatures + an opponent threat, so the /// search tree has rich interior branching (subtrees far exceed a tiny node /// cap => genuine budget starvation) AND a value gradient (casting a creature @@ -6880,14 +7248,8 @@ mod tests { // (no tactical term interfering with the demonstration). let (pass, cast) = pass_and_first_cast(&state); let ranked = vec![ - RankedCandidate { - candidate: pass.clone(), - score: 0.0, - }, - RankedCandidate { - candidate: cast.clone(), - score: 0.0, - }, + RankedCandidate::new(pass.clone(), 0.0), + RankedCandidate::new(cast.clone(), 0.0), ]; let a = ranked[0].candidate.action.clone(); @@ -6990,14 +7352,8 @@ mod tests { // from ranked[0] = pass — making a rung-0 rotate observable. let (pass, cast) = pass_and_first_cast(&state); let ranked = vec![ - RankedCandidate { - candidate: pass.clone(), - score: 0.0, - }, - RankedCandidate { - candidate: cast.clone(), - score: 0.0, - }, + RankedCandidate::new(pass.clone(), 0.0), + RankedCandidate::new(cast.clone(), 0.0), ]; let a = ranked[0].candidate.action.clone(); diff --git a/crates/phase-ai/src/session.rs b/crates/phase-ai/src/session.rs index d13a30e3d9..0af65ec01b 100644 --- a/crates/phase-ai/src/session.rs +++ b/crates/phase-ai/src/session.rs @@ -22,6 +22,8 @@ use crate::features::DeckFeatures; use crate::plan::{derive_snapshot, PlanSnapshot}; use crate::planner::quick_state_hash; use crate::policies::registry::PolicyId; +#[cfg(test)] +use crate::policies::PolicyRegistry; use crate::projection::{project_to, BailReason, Projection, ProjectionHorizon, ProjectionKey}; use crate::strategy_profile::StrategyProfile; use crate::synergy::SynergyGraph; @@ -32,7 +34,7 @@ use crate::synergy::SynergyGraph; const COMMANDER_ANALYSIS_WEIGHT: u32 = 4; /// Per-game cache shared by all decisions. -#[derive(Debug, Clone, Default)] +#[derive(Clone, Default)] pub struct AiSession { pub deck_profile: HashMap, pub features: HashMap, @@ -44,6 +46,23 @@ pub struct AiSession { /// `turn_number` + `active_player`, so stale entries from prior turns /// never match — no explicit invalidation needed. pub projection_cache: Arc>>>, + #[cfg(test)] + pub(crate) policy_registry_override: Option>, +} + +impl std::fmt::Debug for AiSession { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AiSession") + .field("deck_profile", &self.deck_profile) + .field("features", &self.features) + .field("plan", &self.plan) + .field("strategy", &self.strategy) + .field("synergy", &self.synergy) + .field("memory", &self.memory) + .field("projection_cache", &self.projection_cache) + .finish() + } } impl AiSession { @@ -84,6 +103,8 @@ impl AiSession { synergy, memory: Arc::default(), projection_cache: Arc::default(), + #[cfg(test)] + policy_registry_override: None, } } From 240cfc8e701c7b55f676fe7ad9333a0b142aa1ae Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:38:08 -0700 Subject: [PATCH 18/63] refactor(parser): retain generic activated abilities as native IR (#6792) * refactor(parser): add activated mana normalization shell stage * refactor(parser): emit generic activated abilities as native IR * test(parser): satisfy clippy in activated router assertion * fix(parser): preserve activated mana conditions --------- Co-authored-by: matthewevans --- crates/engine/src/parser/oracle.rs | 99 +-- crates/engine/src/parser/oracle_effect/mod.rs | 3 + crates/engine/src/parser/oracle_ir/context.rs | 2 +- .../src/parser/oracle_ir/effect_chain.rs | 12 +- .../src/parser/oracle_ir/snapshot_tests.rs | 164 +++++ ...cle_ir__snapshot_tests__aetherling_ir.snap | 576 ++++++++++++----- ...ot_tests__barbarian_ring_activated_ir.snap | 314 +++++++++ ...sts__barbarian_ring_activated_lowered.snap | 128 ++++ ...le_ir__snapshot_tests__batterskull_ir.snap | 102 ++- ..._snapshot_tests__birds_of_paradise_ir.snap | 115 +++- ..._ir__snapshot_tests__bomat_courier_ir.snap | 154 +++-- ...napshot_tests__boseiju_who_endures_ir.snap | 99 ++- ...t_tests__component_pouch_activated_ir.snap | 423 +++++++++++++ ...ts__component_pouch_activated_lowered.snap | 168 +++++ ...ir__snapshot_tests__experiment_one_ir.snap | 104 ++- ..._snapshot_tests__figure_of_destiny_ir.snap | 598 +++++++++++------- ..._snapshot_tests__ghost_lit_stalker_ir.snap | 132 ++-- ...acle_ir__snapshot_tests__jade_mage_ir.snap | 148 +++-- ...ir__snapshot_tests__llanowar_elves_ir.snap | 99 ++- ...r__snapshot_tests__mother_of_runes_ir.snap | 154 +++-- ...r__snapshot_tests__repeat_offender_ir.snap | 225 +++++-- ..._snapshot_tests__stoneforge_mystic_ir.snap | 149 +++-- ..._snapshot_tests__sylvan_safekeeper_ir.snap | 148 +++-- ..._thespians_stage_generic_activated_ir.snap | 235 +++++++ ...pians_stage_generic_activated_lowered.snap | 82 +++ ...__snapshot_tests__walking_ballista_ir.snap | 223 +++++-- .../parser/oracle_pipeline_snapshot_tests.rs | 3 +- crates/engine/src/parser/oracle_tests.rs | 21 + 28 files changed, 3749 insertions(+), 931 deletions(-) create mode 100644 crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__barbarian_ring_activated_ir.snap create mode 100644 crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__barbarian_ring_activated_lowered.snap create mode 100644 crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__component_pouch_activated_ir.snap create mode 100644 crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__component_pouch_activated_lowered.snap create mode 100644 crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__thespians_stage_generic_activated_ir.snap create mode 100644 crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__thespians_stage_generic_activated_lowered.snap diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index 97927aabf3..6ec166a236 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -95,7 +95,7 @@ use super::oracle_replacement::{ use super::oracle_saga::{is_saga_chapter, parse_saga_chapters}; use super::oracle_spacecraft::parse_spacecraft_threshold_lines; use super::oracle_special::{ - attach_die_result_branches_to_chain, normalize_self_refs_for_static, + attach_die_result_branches_to_chain, find_terminal_roll_die, normalize_self_refs_for_static, parse_cumulative_upkeep_keyword, parse_defiler_cost_reduction, parse_die_result_branches_ir, parse_harmonize_keyword, parse_mayhem_keyword, parse_solve_condition, try_parse_die_roll_table, }; @@ -5045,7 +5045,7 @@ pub(crate) fn parse_oracle_ir( // path serves both forms) to gate this ability. let aw_condition = strip_ability_word_with_name(cost_text) .and_then(|(aw_name, _)| ability_word_to_condition(&aw_name)); - let (mut def, effect_text) = parse_activated_ability_definition( + let (mut ir, effect_text) = parse_activated_ability_ir( cost_text, effect_text, &line, @@ -5059,23 +5059,31 @@ pub(crate) fn parse_oracle_ir( // `condition` (a resolution-time gate, CR 608.2c + Shelldock Isle // ruling, which the engine deliberately does not use for activation // legality). Applied AFTER the call because - // `parse_activated_ability_definition` overwrites - // `activation_restrictions` from the cost-text constraints. + // `parse_activated_ability_ir` captures the cost-text constraints in + // the activation shell before this outer router stamp is applied. if matches!(aw_condition, Some(StaticCondition::SourceIsHarnessed)) { - def.activation_restrictions + ir.shell + .activation_restrictions .push(ActivationRestriction::SourceIsHarnessed); } if ability_cant_be_copied { - def.cant_be_copied = true; + ir.shell.cant_be_copied = true; } - def.min_x_value = min_x_value; + ir.shell.min_x_value = ir.shell.min_x_value.max(min_x_value); i += 1; - // CR 706: If the activated ability ends with "roll a dN", consume - // subsequent d20 table lines and attach them as die result branches. - if has_roll_die_pattern(&effect_text.to_lowercase()) { - i = attach_die_result_branches_to_chain(&mut def, &lines, i); + // CR 706.3b: consume a following table only when the same native IR + // lowers to a terminal empty roll. Textual roll markers alone do not + // own subsequent lines. + let mut lowered_for_die_guard = lower_ability_ir(&ir); + if has_roll_die_pattern(&effect_text.to_lowercase()) + && find_terminal_roll_die(&mut lowered_for_die_guard).is_some() + { + let (branches, next_line) = + parse_die_result_branches_ir(&lines, i, AbilityKind::Spell); + ir.die_results = branches; + i = next_line; } - emitter.ability_at(item_line, def); + emitter.ability_ir_at(item_line, ir); continue; } @@ -5179,7 +5187,7 @@ pub(crate) fn parse_oracle_ir( if let Some(colon_pos) = find_activated_colon(&effect_text) { let cost_text = effect_text[..colon_pos].trim(); let activated_effect_text = effect_text[colon_pos + 1..].trim(); - let (def, _) = parse_activated_ability_definition( + let (ir, _) = parse_activated_ability_ir( cost_text, activated_effect_text, &line, @@ -5187,7 +5195,7 @@ pub(crate) fn parse_oracle_ir( Some(PrintedAbilityIndex::placeholder()), &mut ctx, ); - emitter.ability_at(item_line, def); + emitter.ability_ir_at(item_line, ir); i += 1; continue; } @@ -6751,14 +6759,14 @@ fn non_self_exile_cost_zone(cost: &AbilityCost) -> Option { } } -fn parse_activated_ability_definition( +fn parse_activated_ability_ir( cost_text: &str, effect_text: &str, description: &str, card_name: &str, current_ability_index: Option, ctx: &mut ParseContext, -) -> (AbilityDefinition, String) { +) -> (AbilityIr, String) { let (effect_text, activation_mana_payment_restriction) = strip_activated_mana_payment_restriction(effect_text); let (effect_text, constraints) = strip_activated_constraints(effect_text); @@ -6784,34 +6792,35 @@ fn parse_activated_ability_definition( // Retry with `~` normalization if the first pass left an Unimplemented node // or emitted a target-fallback warning. - let mut def = parse_activated_with_self_ref_fallback(&effect_text, card_name, ctx); + let mut ir = parse_activated_ability_ir_with_self_ref_fallback(&effect_text, card_name, ctx); ctx.current_ability_exile_cost_zone = prev_exile_zone; ctx.current_ability_index = prev_ability_index; - normalize_activated_mana_instead_delta(&mut def); - if def.activation_zone.is_none() { - def.activation_zone = activation_zone_from_self_cost(&cost); - } + let lowered_for_activation_zone = lower_ability_ir(&ir); // CR 113.6m: fall back to the effect-side derivation — an ability whose // effect moves the source out of a non-battlefield zone functions only // from that zone. Cost-based derivation keeps priority. - if def.activation_zone.is_none() { - def.activation_zone = activation_zone_from_self_effect(&def); - } - def.cost = Some(cost); - def.description = Some(description.to_string()); + ir.shell.activation_zone = lowered_for_activation_zone + .activation_zone + .or_else(|| activation_zone_from_self_cost(&cost)) + .or_else(|| activation_zone_from_self_effect(&lowered_for_activation_zone)); + ir.shell.cost = Some(cost); + ir.shell.description = Some(description.to_string()); if !constraints.restrictions.is_empty() { - def.activation_restrictions = constraints.restrictions; + ir.shell.activation_restrictions = constraints.restrictions; } - def.activation_mana_payment_restriction = activation_mana_payment_restriction; - def.activator_filter = constraints.activator_filter.or_else(|| { + ir.shell.activation_mana_payment_restriction = activation_mana_payment_restriction; + ir.shell.activator_filter = constraints.activator_filter.or_else(|| { constraints .any_player_may_activate .then_some(PlayerFilter::All) }); - extract_cost_reduction_from_chain(&mut def); - extract_mana_spend_trigger_from_chain(&mut def); - (def, effect_text) + ir.shell.stages = vec![ + ShellStage::NormalizeActivatedManaInstead, + ShellStage::ExtractCostReduction, + ShellStage::ExtractManaSpendTrigger, + ]; + (ir, effect_text) } /// CR 106.6: Strip the exact terminal rider "Spend only mana of the chosen @@ -8409,31 +8418,31 @@ pub(super) fn has_unimplemented(def: &AbilityDefinition) -> bool { /// fell back to `TargetFilter::Any` because the bare card-name wasn't /// recognized as a self-reference. Warnings from the discarded pass are /// dropped so they don't pollute coverage output. -pub(super) fn parse_activated_with_self_ref_fallback( +pub(super) fn parse_activated_ability_ir_with_self_ref_fallback( effect_text: &str, card_name: &str, ctx: &mut ParseContext, -) -> AbilityDefinition { +) -> AbilityIr { // Pre-diagnostics stay in ctx naturally — only manage trial-parse diagnostics. let pre_snapshot = ctx.diagnostics.len(); ctx.subject = None; ctx.actor = None; - let def = parse_effect_chain_with_context(effect_text, AbilityKind::Activated, ctx); + let ir = parse_ability_ir_with_context(effect_text, AbilityKind::Activated, ctx); let first_has_target_fallback = ctx.diagnostics[pre_snapshot..] .iter() .any(|d| matches!(d, OracleDiagnostic::TargetFallback { .. })); - let first_clean = !has_unimplemented(&def) && !first_has_target_fallback; + let first_clean = !has_unimplemented(&lower_ability_ir(&ir)) && !first_has_target_fallback; if first_clean { // First parse is clean — keep its diagnostics. - return def; + return ir; } let normalized = normalize_self_refs_for_static(effect_text, card_name); if normalized == effect_text { // No normalization change — keep first-pass diagnostics. - return def; + return ir; } // Save first-pass diagnostics for potential restoration. @@ -8442,11 +8451,11 @@ pub(super) fn parse_activated_with_self_ref_fallback( ctx.subject = None; ctx.actor = None; - let alt = parse_effect_chain_with_context(&normalized, AbilityKind::Activated, ctx); + let alt = parse_ability_ir_with_context(&normalized, AbilityKind::Activated, ctx); let alt_has_target_fallback = ctx.diagnostics[pre_snapshot..] .iter() .any(|d| matches!(d, OracleDiagnostic::TargetFallback { .. })); - let alt_clean = !has_unimplemented(&alt) && !alt_has_target_fallback; + let alt_clean = !has_unimplemented(&lower_ability_ir(&alt)) && !alt_has_target_fallback; if alt_clean { // Normalized pass is strictly better — keep only its diagnostics (already in ctx). @@ -8458,11 +8467,11 @@ pub(super) fn parse_activated_with_self_ref_fallback( ctx.diagnostics.truncate(pre_snapshot); ctx.diagnostics.extend(first_diagnostics); ctx.diagnostics.extend(alt_diagnostics); - def + ir } } -fn normalize_activated_mana_instead_delta(def: &mut AbilityDefinition) { +pub(crate) fn normalize_activated_mana_instead_delta(def: &mut AbilityDefinition) { let Effect::Mana { produced: ManaProduction::Colorless { @@ -8476,7 +8485,11 @@ fn normalize_activated_mana_instead_delta(def: &mut AbilityDefinition) { let Some(sub) = def.sub_ability.as_mut() else { return; }; - let Some(AbilityCondition::ConditionInstead { inner }) = sub.condition.take() else { + let Some(condition) = sub.condition.take() else { + return; + }; + let AbilityCondition::ConditionInstead { inner } = condition else { + sub.condition = Some(condition); return; }; let Effect::Mana { diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index d55e5f7e43..687f5f561d 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -26977,6 +26977,9 @@ pub(crate) fn lower_ability_ir(ir: &AbilityIr) -> AbilityDefinition { apply_ability_shell_envelope(&mut def, &ir.shell); for stage in &ir.shell.stages { match stage { + ShellStage::NormalizeActivatedManaInstead => { + crate::parser::oracle::normalize_activated_mana_instead_delta(&mut def); + } ShellStage::ExtractCostReduction => { crate::parser::oracle::extract_cost_reduction_from_chain(&mut def); } diff --git a/crates/engine/src/parser/oracle_ir/context.rs b/crates/engine/src/parser/oracle_ir/context.rs index 433c058e04..9c790c8e5e 100644 --- a/crates/engine/src/parser/oracle_ir/context.rs +++ b/crates/engine/src/parser/oracle_ir/context.rs @@ -133,7 +133,7 @@ pub(crate) struct ParseContext { /// clause state cannot leak across trigger lines. pub pending_trigger_subject_clause: Option, /// CR 608.2k: Source zone of the current ability's `AbilityCost::Exile` - /// component, if any. Set by `parse_activated_ability_definition` after the + /// component, if any. Set by `parse_activated_ability_ir` after the /// cost is parsed and before the effect text is parsed, then restored after /// the ability. Consumed by `parse_cost_paid_object_reference` to /// disambiguate "the exiled card" — a cost-paid-object reference diff --git a/crates/engine/src/parser/oracle_ir/effect_chain.rs b/crates/engine/src/parser/oracle_ir/effect_chain.rs index 944f6c7ffb..6835009e1e 100644 --- a/crates/engine/src/parser/oracle_ir/effect_chain.rs +++ b/crates/engine/src/parser/oracle_ir/effect_chain.rs @@ -229,7 +229,7 @@ pub(crate) struct AbilityShellIr { /// this field by *reading the lowered def* /// (`activation_zone_from_self_cost` / `activation_zone_from_self_effect`), /// which a shell stamped before lowering cannot express. That is one of the - /// reasons `parse_activated_ability_definition` is scoped to its own unit + /// reasons `parse_activated_ability_ir` is scoped to its own unit /// rather than to T8 — this field is here for the recognizers that know /// their zone from the printed keyword (Channel, Forecast), not for that one. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -337,7 +337,7 @@ fn is_zero(v: &u32) -> bool { /// /// # Why an ordered `Vec` and not a set of booleans /// -/// Both stages are folds that rewrite the `sub_ability` chain *and* write a root +/// These stages are folds that rewrite the `sub_ability` chain *and* write a root /// field, so their position relative to the field stamps is behavior-load-bearing /// — a plain field bag cannot express "run these two, in this order, after the /// stamps": @@ -353,10 +353,14 @@ fn is_zero(v: &u32) -> bool { /// /// Variants are named for the transform, not for a call site, so the list stays /// a description of *what runs* rather than of *who asked*. -// Both variants are constructed by the T8-A2 recognizers (Channel, Boast, -// Exhaust, Forecast), each of which lists them in this order. +// The extraction variants are constructed by the T8-A2 recognizers (Channel, +// Boast, Exhaust, Forecast), each of which lists them in this order. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub(crate) enum ShellStage { + /// CR 106.6: normalize an activated mana ability's `instead` alternative + /// into the additional-mana delta used by the existing mana authority. + /// Runs `oracle::normalize_activated_mana_instead_delta`. + NormalizeActivatedManaInstead, /// CR 601.2f: fold a trailing self-referential "this ability costs {X} less /// to activate" node out of the `sub_ability` chain into `cost_reduction`. /// Runs `oracle::extract_cost_reduction_from_chain`. diff --git a/crates/engine/src/parser/oracle_ir/snapshot_tests.rs b/crates/engine/src/parser/oracle_ir/snapshot_tests.rs index 321fe1b865..5fc9feef72 100644 --- a/crates/engine/src/parser/oracle_ir/snapshot_tests.rs +++ b/crates/engine/src/parser/oracle_ir/snapshot_tests.rs @@ -12,6 +12,18 @@ use crate::types::ability::MultiTargetSpec; use crate::types::ability::{Effect, TriggerCondition}; use crate::types::game_state::DistributionUnit; +fn ability_has_unimplemented(def: &crate::types::ability::AbilityDefinition) -> bool { + matches!(def.effect.as_ref(), Effect::Unimplemented { .. }) + || def + .sub_ability + .as_deref() + .is_some_and(ability_has_unimplemented) + || def + .else_ability + .as_deref() + .is_some_and(ability_has_unimplemented) +} + /// Parse Oracle text through both IR and lowering layers. fn parse_two_layer( oracle_text: &str, @@ -37,6 +49,158 @@ fn parse_two_layer_with_keywords( (ir, lowered) } +/// CR 707.9a + CR 602.1a: generic activated abilities are emitted as native +/// spell IR, so the source-ordered lowerer can stamp the second printed ability +/// before its self-retention copy effect is finalized. +#[test] +fn thespians_stage_generic_activated_router_is_ir_native() { + let (ir, lowered) = parse_two_layer( + "{T}: Add {C}.\n{2}, {T}: This land becomes a copy of target land, except it has this ability.", + "Thespian's Stage", + &["Land"], + &[], + ); + + assert_eq!(ir.items.len(), 2); + assert!(matches!(&ir.items[0].node, OracleNodeIr::Spell(_))); + assert!(matches!(&ir.items[1].node, OracleNodeIr::Spell(_))); + assert_eq!(lowered.abilities.len(), 2); + assert!( + !ability_has_unimplemented(&lowered.abilities[1]), + "the copy ability must lower without a residual fallback: {:?}", + lowered.abilities[1] + ); + assert!(matches!( + lowered.abilities[1].effect.as_ref(), + Effect::BecomeCopy { additional_modifications, .. } + if additional_modifications.iter().any(|modification| matches!( + modification, + crate::types::ability::ContinuousModification::RetainPrintedAbilityFromSource { + source_ability_index: 1 + } + )) + )); + + insta::assert_json_snapshot!("thespians_stage_generic_activated_ir", &ir); + insta::assert_json_snapshot!("thespians_stage_generic_activated_lowered", &lowered); +} + +/// CR 207.2c + CR 602.1: an ability-word label on an activated ability does +/// not impose a condition; the generic activation envelope is retained in IR. +#[test] +fn barbarian_ring_ability_word_activated_router_is_ir_native() { + let (ir, lowered) = parse_two_layer( + "{T}: Add {R}. This land deals 1 damage to you.\nThreshold — {R}, {T}, Sacrifice this land: It deals 2 damage to any target. Activate only if there are seven or more cards in your graveyard.", + "Barbarian Ring", + &["Land"], + &[], + ); + + assert_eq!(ir.items.len(), 2); + let OracleNodeIr::Spell(activated) = &ir.items[1].node else { + panic!( + "expected native activated spell IR, got {:?}", + ir.items[1].node + ); + }; + assert!(!activated.shell.activation_restrictions.is_empty()); + assert_eq!(lowered.abilities.len(), 2); + let ability = &lowered.abilities[1]; + assert!( + !ability_has_unimplemented(ability), + "ability-word activated route must not fall back: {ability:?}" + ); + assert!( + ability.condition.is_none(), + "Threshold has no rules meaning here" + ); + assert!(matches!( + ability.cost.as_ref(), + Some(crate::types::ability::AbilityCost::Composite { .. }) + )); + assert!(matches!(ability.effect.as_ref(), Effect::DealDamage { .. })); + assert!( + !ability.activation_restrictions.is_empty(), + "explicit Activate only restriction must survive" + ); + + insta::assert_json_snapshot!("barbarian_ring_activated_ir", &ir); + insta::assert_json_snapshot!("barbarian_ring_activated_lowered", &lowered); +} + +/// CR 706.3b: an activated terminal die roll owns only its contiguous result +/// rows and preserves them as native branch IR until final lowering. +#[test] +fn component_pouch_activated_die_table_is_ir_native() { + let (ir, lowered) = parse_two_layer( + "{T}, Remove a component counter from this artifact: Add two mana of different colors.\n{T}: Roll a d20.\n1—9 | Put a component counter on this artifact.\n10—20 | Put two component counters on this artifact.", + "Component Pouch", + &["Artifact"], + &[], + ); + + assert_eq!(ir.items.len(), 2, "result rows must not become items"); + let OracleNodeIr::Spell(roll) = &ir.items[1].node else { + panic!( + "expected native roll ability IR, got {:?}", + ir.items[1].node + ); + }; + assert_eq!( + roll.die_results + .iter() + .map(|branch| (branch.min, branch.max)) + .collect::>(), + vec![(1, 9), (10, 20)] + ); + assert!(matches!( + lowered.abilities[1].effect.as_ref(), + Effect::RollDie { results, .. } if results.len() == 2 + )); + + insta::assert_json_snapshot!("component_pouch_activated_ir", &ir); + insta::assert_json_snapshot!("component_pouch_activated_lowered", &lowered); +} + +/// CR 706.3b: the typed terminal-roll guard must leave the next line to the +/// ordinary router when a die roll has a following non-roll instruction. +#[test] +fn nonterminal_activated_die_roll_does_not_consume_following_ability() { + let (ir, lowered) = parse_two_layer( + "{T}: Roll a d20, then draw a card.\n{T}: Add {C}.", + "Nonterminal Activated Die Fixture", + &["Artifact"], + &[], + ); + + assert_eq!( + ir.items.len(), + 2, + "the following activation must remain routable" + ); + assert!(matches!(&ir.items[0].node, OracleNodeIr::Spell(_))); + assert!(matches!(&ir.items[1].node, OracleNodeIr::Spell(_))); + assert_eq!(lowered.abilities.len(), 2); + let first = &lowered.abilities[0]; + assert!(matches!(first.effect.as_ref(), Effect::RollDie { .. })); + assert!(matches!( + first.sub_ability.as_deref().map(|sub| sub.effect.as_ref()), + Some(Effect::Draw { .. }) + )); + assert!( + lowered + .abilities + .iter() + .all(|ability| !ability_has_unimplemented(ability)), + "both ordinary activations must parse after the nonterminal roll: {:?}", + lowered.abilities + ); + assert!(matches!( + lowered.abilities[1].effect.as_ref(), + Effect::Mana { .. } + )); +} + /// CR 706.3b: ordinary trigger dispatch retains a die-result table in native /// trigger IR and attaches it to the terminal roll before finalization. #[test] diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__aetherling_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__aetherling_ir.snap index 19b7ea9bae..25799e6d17 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__aetherling_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__aetherling_ir.snap @@ -22,82 +22,156 @@ expression: "&ir" "fragment": "{U}: Exile ~. Return it to the battlefield under its owner's control at the beginning of the next end step." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "ChangeZone", - "origin": null, - "destination": "Exile", - "target": { - "type": "SelfRef" - }, - "owner_library": false, - "enter_transformed": false, - "enter_tapped": false, - "enters_attacking": false - }, - "cost": { - "type": "Mana", - "cost": { - "type": "Cost", - "shards": [ - "Blue" - ], - "generic": 0 - } - }, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "CreateDelayedTrigger", - "condition": { - "type": "AtNextPhase", - "phase": "End" - }, - "effect": { - "kind": "Activated", - "effect": { - "type": "ChangeZone", - "origin": "Exile", - "destination": "Battlefield", - "target": { - "type": "ParentTarget" + "Spell": { + "source_text": "Exile ~. Return it to the battlefield under its owner's control at the beginning of the next end step", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 7, + "precision": "ChainRelative", + "ordinal_within_span": 0 }, - "owner_library": false, - "enter_transformed": false, - "enter_tapped": false, - "enters_attacking": false + "fragment": "Exile ~" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, + "parsed": { + "effect": { + "type": "ChangeZone", + "origin": null, + "destination": "Exile", + "target": { + "type": "SelfRef" + }, + "owner_library": false, + "enter_transformed": false, + "enter_tapped": false, + "enters_attacking": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "uses_tracked_set": true + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 9, + "end_byte": 101, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "Return it to the battlefield under its owner's control at the beginning of the next end step" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "ChangeZone", + "origin": null, + "destination": "Battlefield", + "target": { + "type": "ParentTarget" + }, + "owner_library": false, + "enter_transformed": false, + "enter_tapped": false, + "enters_attacking": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": { + "type": "AtNextPhase", + "phase": "End" + }, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null, + "cost": { + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [ + "Blue" + ], + "generic": 0 + } }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "sub_link": "SequentialSibling" + "description": "{U}: Exile ~. Return it to the battlefield under its owner's control at the beginning of the next end step.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] }, - "duration": null, - "description": "{U}: Exile ~. Return it to the battlefield under its owner's control at the beginning of the next end step.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [] } } }, @@ -119,51 +193,109 @@ expression: "&ir" "fragment": "{U}: ~ can't be blocked this turn." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "GenericEffect", - "static_abilities": [ + "Spell": { + "source_text": "~ can't be blocked this turn", + "body": { + "clauses": [ { - "mode": "CantBeBlocked", - "affected": { - "type": "SelfRef" + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 28, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "~ can't be blocked this turn" }, - "modifications": [ - { - "type": "AddStaticMode", - "mode": "CantBeBlocked" + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null } - ], + }, + "parsed": { + "effect": { + "type": "GenericEffect", + "static_abilities": [ + { + "mode": "CantBeBlocked", + "affected": { + "type": "SelfRef" + }, + "modifications": [ + { + "type": "AddStaticMode", + "mode": "CantBeBlocked" + } + ], + "condition": null, + "affected_zone": null, + "effect_zone": null, + "active_zones": [], + "characteristic_defining": false, + "description": "can't be blocked" + } + ], + "duration": "UntilEndOfTurn", + "target": null + }, + "duration": "UntilEndOfTurn", + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, "condition": null, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "can't be blocked" + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } ], - "duration": "UntilEndOfTurn", - "target": null + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": { - "type": "Mana", + "shell": { + "sub_link": null, "cost": { - "type": "Cost", - "shards": [ - "Blue" - ], - "generic": 0 - } + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [ + "Blue" + ], + "generic": 0 + } + }, + "description": "{U}: ~ can't be blocked this turn.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] }, - "sub_ability": null, - "duration": "UntilEndOfTurn", - "description": "{U}: ~ can't be blocked this turn.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [] } } }, @@ -185,38 +317,96 @@ expression: "&ir" "fragment": "{1}: ~ gets +1/-1 until end of turn." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "Pump", - "power": { - "type": "Fixed", - "value": 1 - }, - "toughness": { - "type": "Fixed", - "value": -1 - }, - "target": { - "type": "SelfRef" - } + "Spell": { + "source_text": "~ gets +1/-1 until end of turn", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 30, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "~ gets +1/-1 until end of turn" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Pump", + "power": { + "type": "Fixed", + "value": 1 + }, + "toughness": { + "type": "Fixed", + "value": -1 + }, + "target": { + "type": "SelfRef" + } + }, + "duration": "UntilEndOfTurn", + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": { - "type": "Mana", + "shell": { + "sub_link": null, "cost": { - "type": "Cost", - "shards": [], - "generic": 1 - } + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [], + "generic": 1 + } + }, + "description": "{1}: ~ gets +1/-1 until end of turn.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] }, - "sub_ability": null, - "duration": "UntilEndOfTurn", - "description": "{1}: ~ gets +1/-1 until end of turn.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [] } } }, @@ -238,38 +428,96 @@ expression: "&ir" "fragment": "{1}: ~ gets -1/+1 until end of turn." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "Pump", - "power": { - "type": "Fixed", - "value": -1 - }, - "toughness": { - "type": "Fixed", - "value": 1 - }, - "target": { - "type": "SelfRef" - } + "Spell": { + "source_text": "~ gets -1/+1 until end of turn", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 30, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "~ gets -1/+1 until end of turn" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Pump", + "power": { + "type": "Fixed", + "value": -1 + }, + "toughness": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "SelfRef" + } + }, + "duration": "UntilEndOfTurn", + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": { - "type": "Mana", + "shell": { + "sub_link": null, "cost": { - "type": "Cost", - "shards": [], - "generic": 1 - } + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [], + "generic": 1 + } + }, + "description": "{1}: ~ gets -1/+1 until end of turn.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] }, - "sub_ability": null, - "duration": "UntilEndOfTurn", - "description": "{1}: ~ gets -1/+1 until end of turn.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__barbarian_ring_activated_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__barbarian_ring_activated_ir.snap new file mode 100644 index 0000000000..12d64aae01 --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__barbarian_ring_activated_ir.snap @@ -0,0 +1,314 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&ir" +--- +{ + "items": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 0 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 38, + "precision": "Exact", + "ordinal_within_span": 0 + }, + "fragment": "{T}: Add {R}. ~ deals 1 damage to you." + }, + "node": { + "Spell": { + "source_text": "Add {R}. ~ deals 1 damage to you", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 7, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Add {R}" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Mana", + "produced": { + "type": "Fixed", + "colors": [ + "Red" + ] + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 9, + "end_byte": 32, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "~ deals 1 damage to you" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "DealDamage", + "amount": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "Controller" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null, + "cost": { + "type": "Tap" + }, + "description": "{T}: Add {R}. ~ deals 1 damage to you.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] + }, + "die_results": [] + } + } + }, + { + "id": 1, + "source": { + "id": { + "item": 1, + "ordinal": 0 + }, + "span": { + "first_line": 1, + "last_line": 1, + "start_byte": 39, + "end_byte": 174, + "precision": "Exact", + "ordinal_within_span": 0 + }, + "fragment": "Threshold — {R}, {T}, Sacrifice ~: It deals 2 damage to any target. Activate only if there are seven or more cards in your graveyard." + }, + "node": { + "Spell": { + "source_text": "It deals 2 damage to any target", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 31, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "It deals 2 damage to any target" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "DealDamage", + "amount": { + "type": "Fixed", + "value": 2 + }, + "target": { + "type": "Any" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null, + "cost": { + "type": "Composite", + "costs": [ + { + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [ + "Red" + ], + "generic": 0 + } + }, + { + "type": "Tap" + }, + { + "type": "Sacrifice", + "target": { + "type": "SelfRef" + }, + "count": 1 + } + ] + }, + "activation_restrictions": [ + { + "type": "RequiresCondition", + "data": { + "condition": { + "type": "QuantityComparison", + "lhs": { + "type": "Ref", + "qty": { + "type": "GraveyardSize", + "player": { + "type": "Controller" + } + } + }, + "comparator": "GE", + "rhs": { + "type": "Fixed", + "value": 7 + } + } + } + } + ], + "description": "Threshold — {R}, {T}, Sacrifice ~: It deals 2 damage to any target. Activate only if there are seven or more cards in your graveyard.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] + }, + "die_results": [] + } + } + } + ], + "source_text": "{T}: Add {R}. This land deals 1 damage to you.\nThreshold — {R}, {T}, Sacrifice this land: It deals 2 damage to any target. Activate only if there are seven or more cards in your graveyard.", + "card_name": "Barbarian Ring" +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__barbarian_ring_activated_lowered.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__barbarian_ring_activated_lowered.snap new file mode 100644 index 0000000000..26f3a4d590 --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__barbarian_ring_activated_lowered.snap @@ -0,0 +1,128 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&lowered" +--- +{ + "abilities": [ + { + "kind": "Activated", + "effect": { + "type": "Mana", + "produced": { + "type": "Fixed", + "colors": [ + "Red" + ] + } + }, + "cost": { + "type": "Tap" + }, + "sub_ability": { + "kind": "Spell", + "effect": { + "type": "DealDamage", + "amount": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "Controller" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false, + "sub_link": "SequentialSibling" + }, + "duration": null, + "description": "{T}: Add {R}. ~ deals 1 damage to you.", + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false, + "is_mana_ability": true + }, + { + "kind": "Activated", + "effect": { + "type": "DealDamage", + "amount": { + "type": "Fixed", + "value": 2 + }, + "target": { + "type": "Any" + } + }, + "cost": { + "type": "Composite", + "costs": [ + { + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [ + "Red" + ], + "generic": 0 + } + }, + { + "type": "Tap" + }, + { + "type": "Sacrifice", + "target": { + "type": "SelfRef" + }, + "count": 1 + } + ] + }, + "sub_ability": null, + "duration": null, + "description": "Threshold — {R}, {T}, Sacrifice ~: It deals 2 damage to any target. Activate only if there are seven or more cards in your graveyard.", + "target_prompt": null, + "activation_restrictions": [ + { + "type": "RequiresCondition", + "data": { + "condition": { + "type": "QuantityComparison", + "lhs": { + "type": "Ref", + "qty": { + "type": "GraveyardSize", + "player": { + "type": "Controller" + } + } + }, + "comparator": "GE", + "rhs": { + "type": "Fixed", + "value": 7 + } + } + } + } + ], + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + } + ], + "triggers": [], + "statics": [], + "replacements": [], + "extractedKeywords": [] +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__batterskull_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__batterskull_ir.snap index 7fcd0a1baa..a7083c0258 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__batterskull_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__batterskull_ir.snap @@ -106,31 +106,89 @@ expression: "&ir" "fragment": "{3}: Return ~ to its owner's hand." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "Bounce", - "target": { - "type": "SelfRef" - }, - "destination": null + "Spell": { + "source_text": "Return ~ to its owner's hand", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 28, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Return ~ to its owner's hand" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Bounce", + "target": { + "type": "SelfRef" + }, + "destination": null + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": { - "type": "Mana", + "shell": { + "sub_link": null, "cost": { - "type": "Cost", - "shards": [], - "generic": 3 - } + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [], + "generic": 3 + } + }, + "description": "{3}: Return ~ to its owner's hand.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] }, - "sub_ability": null, - "duration": null, - "description": "{3}: Return ~ to its owner's hand.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [] } } }, diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__birds_of_paradise_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__birds_of_paradise_ir.snap index 2b930d9f79..aee0efdaf5 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__birds_of_paradise_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__birds_of_paradise_ir.snap @@ -50,37 +50,94 @@ expression: "&ir" "fragment": "{T}: Add one mana of any color." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "Mana", - "produced": { - "type": "AnyOneColor", - "count": { - "type": "Fixed", - "value": 1 - }, - "color_options": [ - "White", - "Blue", - "Black", - "Red", - "Green" - ] - } + "Spell": { + "source_text": "Add one mana of any color", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 25, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Add one mana of any color" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Mana", + "produced": { + "type": "AnyOneColor", + "count": { + "type": "Fixed", + "value": 1 + }, + "color_options": [ + "White", + "Blue", + "Black", + "Red", + "Green" + ] + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": { - "type": "Tap" + "shell": { + "sub_link": null, + "cost": { + "type": "Tap" + }, + "description": "{T}: Add one mana of any color.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] }, - "sub_ability": null, - "duration": null, - "description": "{T}: Add one mana of any color.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "is_mana_ability": true + "die_results": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__bomat_courier_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__bomat_courier_ir.snap index 3504c919a1..d4b7d6417c 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__bomat_courier_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__bomat_courier_ir.snap @@ -184,61 +184,119 @@ expression: "&ir" "fragment": "{R}, Discard your hand, Sacrifice ~: Put all cards exiled with ~ into their owners' hands." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "ChangeZoneAll", - "origin": "Exile", - "destination": "Hand", - "target": { - "type": "ExiledBySource" - } - }, - "cost": { - "type": "Composite", - "costs": [ + "Spell": { + "source_text": "Put all cards exiled with ~ into their owners' hands", + "body": { + "clauses": [ { - "type": "Mana", - "cost": { - "type": "Cost", - "shards": [ - "Red" - ], - "generic": 0 - } - }, - { - "type": "Discard", - "count": { - "type": "Ref", - "qty": { - "type": "HandSize", - "player": { - "type": "Controller" - } + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 52, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Put all cards exiled with ~ into their owners' hands" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null } }, - "filter": null, - "random": false, - "self_ref": false - }, - { - "type": "Sacrifice", - "target": { - "type": "SelfRef" + "parsed": { + "effect": { + "type": "ChangeZoneAll", + "origin": "Exile", + "destination": "Hand", + "target": { + "type": "ExiledBySource" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null }, - "count": 1 + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null, + "cost": { + "type": "Composite", + "costs": [ + { + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [ + "Red" + ], + "generic": 0 + } + }, + { + "type": "Discard", + "count": { + "type": "Ref", + "qty": { + "type": "HandSize", + "player": { + "type": "Controller" + } + } + }, + "filter": null, + "random": false, + "self_ref": false + }, + { + "type": "Sacrifice", + "target": { + "type": "SelfRef" + }, + "count": 1 + } + ] + }, + "description": "{R}, Discard your hand, Sacrifice ~: Put all cards exiled with ~ into their owners' hands.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" ] }, - "sub_ability": null, - "duration": null, - "description": "{R}, Discard your hand, Sacrifice ~: Put all cards exiled with ~ into their owners' hands.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__boseiju_who_endures_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__boseiju_who_endures_ir.snap index caeb85a31a..8915a6b6c4 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__boseiju_who_endures_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__boseiju_who_endures_ir.snap @@ -22,29 +22,86 @@ expression: "&ir" "fragment": "{T}: Add {G}." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "Mana", - "produced": { - "type": "Fixed", - "colors": [ - "Green" - ] - } + "Spell": { + "source_text": "Add {G}", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 7, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Add {G}" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Mana", + "produced": { + "type": "Fixed", + "colors": [ + "Green" + ] + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": { - "type": "Tap" + "shell": { + "sub_link": null, + "cost": { + "type": "Tap" + }, + "description": "{T}: Add {G}.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] }, - "sub_ability": null, - "duration": null, - "description": "{T}: Add {G}.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "is_mana_ability": true + "die_results": [] } } }, diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__component_pouch_activated_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__component_pouch_activated_ir.snap new file mode 100644 index 0000000000..0837a486df --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__component_pouch_activated_ir.snap @@ -0,0 +1,423 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&ir" +--- +{ + "items": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 0 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 73, + "precision": "Exact", + "ordinal_within_span": 0 + }, + "fragment": "{T}, Remove a component counter from ~: Add two mana of different colors." + }, + "node": { + "Spell": { + "source_text": "Add two mana of different colors", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 32, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Add two mana of different colors" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Mana", + "produced": { + "type": "ChoiceAmongCombinations", + "options": [ + [ + "White", + "Blue" + ], + [ + "White", + "Black" + ], + [ + "White", + "Red" + ], + [ + "White", + "Green" + ], + [ + "Blue", + "Black" + ], + [ + "Blue", + "Red" + ], + [ + "Blue", + "Green" + ], + [ + "Black", + "Red" + ], + [ + "Black", + "Green" + ], + [ + "Red", + "Green" + ] + ] + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null, + "cost": { + "type": "Composite", + "costs": [ + { + "type": "Tap" + }, + { + "type": "RemoveCounter", + "count": 1, + "counter_type": { + "type": "OfType", + "data": "component" + }, + "target": null, + "selection": "SingleObject" + } + ] + }, + "description": "{T}, Remove a component counter from ~: Add two mana of different colors.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] + }, + "die_results": [] + } + } + }, + { + "id": 1, + "source": { + "id": { + "item": 1, + "ordinal": 0 + }, + "span": { + "first_line": 1, + "last_line": 1, + "start_byte": 74, + "end_byte": 90, + "precision": "Exact", + "ordinal_within_span": 0 + }, + "fragment": "{T}: Roll a d20." + }, + "node": { + "Spell": { + "source_text": "Roll a d20", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 10, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Roll a d20" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "RollDie", + "count": { + "type": "Fixed", + "value": 1 + }, + "sides": 20, + "results": [] + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null, + "cost": { + "type": "Tap" + }, + "description": "{T}: Roll a d20.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] + }, + "die_results": [ + { + "min": 1, + "max": 9, + "effect": { + "source_text": "Put a component counter on ~.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 28, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Put a component counter on ~" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "PutCounter", + "counter_type": "component", + "count": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "SelfRef" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null + }, + "die_results": [] + } + }, + { + "min": 10, + "max": 20, + "effect": { + "source_text": "Put two component counters on ~.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 31, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Put two component counters on ~" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "PutCounter", + "counter_type": "component", + "count": { + "type": "Fixed", + "value": 2 + }, + "target": { + "type": "SelfRef" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null + }, + "die_results": [] + } + } + ] + } + } + } + ], + "source_text": "{T}, Remove a component counter from this artifact: Add two mana of different colors.\n{T}: Roll a d20.\n1—9 | Put a component counter on this artifact.\n10—20 | Put two component counters on this artifact.", + "card_name": "Component Pouch" +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__component_pouch_activated_lowered.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__component_pouch_activated_lowered.snap new file mode 100644 index 0000000000..c5951da2a3 --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__component_pouch_activated_lowered.snap @@ -0,0 +1,168 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&lowered" +--- +{ + "abilities": [ + { + "kind": "Activated", + "effect": { + "type": "Mana", + "produced": { + "type": "ChoiceAmongCombinations", + "options": [ + [ + "White", + "Blue" + ], + [ + "White", + "Black" + ], + [ + "White", + "Red" + ], + [ + "White", + "Green" + ], + [ + "Blue", + "Black" + ], + [ + "Blue", + "Red" + ], + [ + "Blue", + "Green" + ], + [ + "Black", + "Red" + ], + [ + "Black", + "Green" + ], + [ + "Red", + "Green" + ] + ] + } + }, + "cost": { + "type": "Composite", + "costs": [ + { + "type": "Tap" + }, + { + "type": "RemoveCounter", + "count": 1, + "counter_type": { + "type": "OfType", + "data": "component" + }, + "target": null, + "selection": "SingleObject" + } + ] + }, + "sub_ability": null, + "duration": null, + "description": "{T}, Remove a component counter from ~: Add two mana of different colors.", + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false, + "is_mana_ability": true + }, + { + "kind": "Activated", + "effect": { + "type": "RollDie", + "count": { + "type": "Fixed", + "value": 1 + }, + "sides": 20, + "results": [ + { + "min": 1, + "max": 9, + "effect": { + "kind": "Spell", + "effect": { + "type": "PutCounter", + "counter_type": "component", + "count": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "SelfRef" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + } + }, + { + "min": 10, + "max": 20, + "effect": { + "kind": "Spell", + "effect": { + "type": "PutCounter", + "counter_type": "component", + "count": { + "type": "Fixed", + "value": 2 + }, + "target": { + "type": "SelfRef" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + } + } + ] + }, + "cost": { + "type": "Tap" + }, + "sub_ability": null, + "duration": null, + "description": "{T}: Roll a d20.", + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + } + ], + "triggers": [], + "statics": [], + "replacements": [], + "extractedKeywords": [] +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__experiment_one_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__experiment_one_ir.snap index 863f1b9856..8f8f6adcff 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__experiment_one_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__experiment_one_ir.snap @@ -50,32 +50,90 @@ expression: "&ir" "fragment": "Remove two +1/+1 counters from ~: Regenerate it. (The next time ~ would be destroyed this turn, instead tap it, remove it from combat, and heal all damage on it.)" }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "Regenerate", - "target": { - "type": "ParentTarget" - } + "Spell": { + "source_text": "Regenerate it", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 13, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Regenerate it" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Regenerate", + "target": { + "type": "ParentTarget" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": { - "type": "RemoveCounter", - "count": 2, - "counter_type": { - "type": "OfType", - "data": "P1P1" + "shell": { + "sub_link": null, + "cost": { + "type": "RemoveCounter", + "count": 2, + "counter_type": { + "type": "OfType", + "data": "P1P1" + }, + "target": null, + "selection": "SingleObject" }, - "target": null, - "selection": "SingleObject" + "description": "Remove two +1/+1 counters from ~: Regenerate it.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] }, - "sub_ability": null, - "duration": null, - "description": "Remove two +1/+1 counters from ~: Regenerate it.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__figure_of_destiny_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__figure_of_destiny_ir.snap index 508b2784a2..8894357035 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__figure_of_destiny_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__figure_of_destiny_ir.snap @@ -22,71 +22,129 @@ expression: "&ir" "fragment": "{R/W}: ~ becomes a Kithkin Spirit with base power and toughness 2/2." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "GenericEffect", - "static_abilities": [ + "Spell": { + "source_text": "~ becomes a Kithkin Spirit with base power and toughness 2/2", + "body": { + "clauses": [ { - "mode": "Continuous", - "affected": { - "type": "SelfRef" - }, - "modifications": [ - { - "type": "SetPower", - "value": 2 - }, - { - "type": "SetToughness", - "value": 2 - }, - { - "type": "AddType", - "core_type": "Creature" + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 }, - { - "type": "RemoveAllSubtypes", - "set": "Creature" + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 60, + "precision": "ChainRelative", + "ordinal_within_span": 0 }, - { - "type": "AddSubtype", - "subtype": "Kithkin" - }, - { - "type": "AddSubtype", - "subtype": "Spirit" + "fragment": "~ becomes a Kithkin Spirit with base power and toughness 2/2" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null } - ], + }, + "parsed": { + "effect": { + "type": "GenericEffect", + "static_abilities": [ + { + "mode": "Continuous", + "affected": { + "type": "SelfRef" + }, + "modifications": [ + { + "type": "SetPower", + "value": 2 + }, + { + "type": "SetToughness", + "value": 2 + }, + { + "type": "AddType", + "core_type": "Creature" + }, + { + "type": "RemoveAllSubtypes", + "set": "Creature" + }, + { + "type": "AddSubtype", + "subtype": "Kithkin" + }, + { + "type": "AddSubtype", + "subtype": "Spirit" + } + ], + "condition": null, + "affected_zone": null, + "effect_zone": null, + "active_zones": [], + "characteristic_defining": false, + "description": "become a Kithkin Spirit with base power and toughness 2/2" + } + ], + "duration": "Permanent", + "target": null + }, + "duration": "Permanent", + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, "condition": null, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "become a Kithkin Spirit with base power and toughness 2/2" + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } ], - "duration": "Permanent", - "target": null + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": { - "type": "Mana", + "shell": { + "sub_link": null, "cost": { - "type": "Cost", - "shards": [ - "RedWhite" - ], - "generic": 0 - } + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [ + "RedWhite" + ], + "generic": 0 + } + }, + "description": "{R/W}: ~ becomes a Kithkin Spirit with base power and toughness 2/2.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] }, - "sub_ability": null, - "duration": "Permanent", - "description": "{R/W}: ~ becomes a Kithkin Spirit with base power and toughness 2/2.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [] } } }, @@ -108,89 +166,147 @@ expression: "&ir" "fragment": "{R/W}{R/W}{R/W}: If ~ is a Spirit, it becomes a Kithkin Spirit Warrior with base power and toughness 4/4." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "GenericEffect", - "static_abilities": [ + "Spell": { + "source_text": "If ~ is a Spirit, it becomes a Kithkin Spirit Warrior with base power and toughness 4/4", + "body": { + "clauses": [ { - "mode": "Continuous", - "affected": { - "type": "SelfRef" - }, - "modifications": [ - { - "type": "SetPower", - "value": 4 - }, - { - "type": "SetToughness", - "value": 4 - }, - { - "type": "AddType", - "core_type": "Creature" + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 }, - { - "type": "RemoveAllSubtypes", - "set": "Creature" + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 87, + "precision": "ChainRelative", + "ordinal_within_span": 0 }, - { - "type": "AddSubtype", - "subtype": "Kithkin" - }, - { - "type": "AddSubtype", - "subtype": "Spirit" + "fragment": "If ~ is a Spirit, it becomes a Kithkin Spirit Warrior with base power and toughness 4/4" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "GenericEffect", + "static_abilities": [ + { + "mode": "Continuous", + "affected": { + "type": "SelfRef" + }, + "modifications": [ + { + "type": "SetPower", + "value": 4 + }, + { + "type": "SetToughness", + "value": 4 + }, + { + "type": "AddType", + "core_type": "Creature" + }, + { + "type": "RemoveAllSubtypes", + "set": "Creature" + }, + { + "type": "AddSubtype", + "subtype": "Kithkin" + }, + { + "type": "AddSubtype", + "subtype": "Spirit" + }, + { + "type": "AddSubtype", + "subtype": "Warrior" + } + ], + "condition": null, + "affected_zone": null, + "effect_zone": null, + "active_zones": [], + "characteristic_defining": false, + "description": "become a Kithkin Spirit Warrior with base power and toughness 4/4" + } + ], + "duration": "Permanent", + "target": null }, - { - "type": "AddSubtype", - "subtype": "Warrior" + "duration": "Permanent", + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": { + "type": "SourceMatchesFilter", + "filter": { + "type": "Typed", + "type_filters": [ + { + "Subtype": "Spirit" + } + ], + "controller": null, + "properties": [] } - ], - "condition": null, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "become a Kithkin Spirit Warrior with base power and toughness 4/4" + }, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } ], - "duration": "Permanent", - "target": null + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": { - "type": "Mana", + "shell": { + "sub_link": null, "cost": { - "type": "Cost", - "shards": [ - "RedWhite", - "RedWhite", - "RedWhite" - ], - "generic": 0 - } - }, - "sub_ability": null, - "duration": "Permanent", - "description": "{R/W}{R/W}{R/W}: If ~ is a Spirit, it becomes a Kithkin Spirit Warrior with base power and toughness 4/4.", - "target_prompt": null, - "condition": { - "type": "SourceMatchesFilter", - "filter": { - "type": "Typed", - "type_filters": [ - { - "Subtype": "Spirit" - } - ], - "controller": null, - "properties": [] - } + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [ + "RedWhite", + "RedWhite", + "RedWhite" + ], + "generic": 0 + } + }, + "description": "{R/W}{R/W}{R/W}: If ~ is a Spirit, it becomes a Kithkin Spirit Warrior with base power and toughness 4/4.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] }, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [] } } }, @@ -212,100 +328,158 @@ expression: "&ir" "fragment": "{R/W}{R/W}{R/W}{R/W}{R/W}{R/W}: If ~ is a Warrior, it becomes a Kithkin Spirit Warrior Avatar with base power and toughness 8/8, flying, and first strike." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "GenericEffect", - "static_abilities": [ + "Spell": { + "source_text": "If ~ is a Warrior, it becomes a Kithkin Spirit Warrior Avatar with base power and toughness 8/8, flying, and first strike", + "body": { + "clauses": [ { - "mode": "Continuous", - "affected": { - "type": "SelfRef" - }, - "modifications": [ - { - "type": "AddKeyword", - "keyword": "Flying" - }, - { - "type": "AddKeyword", - "keyword": "FirstStrike" - }, - { - "type": "RemoveAllSubtypes", - "set": "Creature" - }, - { - "type": "AddSubtype", - "subtype": "Kithkin" - }, - { - "type": "AddSubtype", - "subtype": "Spirit" - }, - { - "type": "AddSubtype", - "subtype": "Warrior" + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 }, - { - "type": "AddSubtype", - "subtype": "Avatar" + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 121, + "precision": "ChainRelative", + "ordinal_within_span": 0 }, - { - "type": "SetPower", - "value": 8 + "fragment": "If ~ is a Warrior, it becomes a Kithkin Spirit Warrior Avatar with base power and toughness 8/8, flying, and first strike" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "GenericEffect", + "static_abilities": [ + { + "mode": "Continuous", + "affected": { + "type": "SelfRef" + }, + "modifications": [ + { + "type": "AddKeyword", + "keyword": "Flying" + }, + { + "type": "AddKeyword", + "keyword": "FirstStrike" + }, + { + "type": "RemoveAllSubtypes", + "set": "Creature" + }, + { + "type": "AddSubtype", + "subtype": "Kithkin" + }, + { + "type": "AddSubtype", + "subtype": "Spirit" + }, + { + "type": "AddSubtype", + "subtype": "Warrior" + }, + { + "type": "AddSubtype", + "subtype": "Avatar" + }, + { + "type": "SetPower", + "value": 8 + }, + { + "type": "SetToughness", + "value": 8 + } + ], + "condition": null, + "affected_zone": null, + "effect_zone": null, + "active_zones": [], + "characteristic_defining": false, + "description": "become a Kithkin Spirit Warrior Avatar with base power and toughness 8/8, flying, and first strike" + } + ], + "duration": "Permanent", + "target": null }, - { - "type": "SetToughness", - "value": 8 + "duration": "Permanent", + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": { + "type": "SourceMatchesFilter", + "filter": { + "type": "Typed", + "type_filters": [ + { + "Subtype": "Warrior" + } + ], + "controller": null, + "properties": [] } - ], - "condition": null, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "become a Kithkin Spirit Warrior Avatar with base power and toughness 8/8, flying, and first strike" + }, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } ], - "duration": "Permanent", - "target": null + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": { - "type": "Mana", + "shell": { + "sub_link": null, "cost": { - "type": "Cost", - "shards": [ - "RedWhite", - "RedWhite", - "RedWhite", - "RedWhite", - "RedWhite", - "RedWhite" - ], - "generic": 0 - } - }, - "sub_ability": null, - "duration": "Permanent", - "description": "{R/W}{R/W}{R/W}{R/W}{R/W}{R/W}: If ~ is a Warrior, it becomes a Kithkin Spirit Warrior Avatar with base power and toughness 8/8, flying, and first strike.", - "target_prompt": null, - "condition": { - "type": "SourceMatchesFilter", - "filter": { - "type": "Typed", - "type_filters": [ - { - "Subtype": "Warrior" - } - ], - "controller": null, - "properties": [] - } + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [ + "RedWhite", + "RedWhite", + "RedWhite", + "RedWhite", + "RedWhite", + "RedWhite" + ], + "generic": 0 + } + }, + "description": "{R/W}{R/W}{R/W}{R/W}{R/W}{R/W}: If ~ is a Warrior, it becomes a Kithkin Spirit Warrior Avatar with base power and toughness 8/8, flying, and first strike.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] }, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__ghost_lit_stalker_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__ghost_lit_stalker_ir.snap index 6da7c393fe..8707e46188 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__ghost_lit_stalker_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__ghost_lit_stalker_ir.snap @@ -22,49 +22,107 @@ expression: "&ir" "fragment": "{4}{B}, {T}: Target player discards two cards. Activate only as a sorcery." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "Discard", - "count": { - "type": "Fixed", - "value": 2 - }, - "target": { - "type": "Player" - } - }, - "cost": { - "type": "Composite", - "costs": [ + "Spell": { + "source_text": "Target player discards two cards", + "body": { + "clauses": [ { - "type": "Mana", - "cost": { - "type": "Cost", - "shards": [ - "Black" - ], - "generic": 4 + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 32, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Target player discards two cards" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Discard", + "count": { + "type": "Fixed", + "value": 2 + }, + "target": { + "type": "Player" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null, + "cost": { + "type": "Composite", + "costs": [ + { + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [ + "Black" + ], + "generic": 4 + } + }, + { + "type": "Tap" } - }, + ] + }, + "activation_restrictions": [ { - "type": "Tap" + "type": "AsSorcery" } + ], + "description": "{4}{B}, {T}: Target player discards two cards. Activate only as a sorcery.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" ] }, - "sub_ability": null, - "duration": null, - "description": "{4}{B}, {T}: Target player discards two cards. Activate only as a sorcery.", - "target_prompt": null, - "activation_restrictions": [ - { - "type": "AsSorcery" - } - ], - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [] } } }, diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jade_mage_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jade_mage_ir.snap index 80373041a6..b71d0b63eb 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jade_mage_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jade_mage_ir.snap @@ -22,55 +22,113 @@ expression: "&ir" "fragment": "{2}{G}: Create a 1/1 green Saproling creature token." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "Token", - "name": "Saproling", - "power": { - "type": "Fixed", - "value": 1 - }, - "toughness": { - "type": "Fixed", - "value": 1 - }, - "types": [ - "Creature", - "Saproling" - ], - "colors": [ - "Green" + "Spell": { + "source_text": "Create a 1/1 green Saproling creature token", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 43, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Create a 1/1 green Saproling creature token" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Token", + "name": "Saproling", + "power": { + "type": "Fixed", + "value": 1 + }, + "toughness": { + "type": "Fixed", + "value": 1 + }, + "types": [ + "Creature", + "Saproling" + ], + "colors": [ + "Green" + ], + "keywords": [], + "tapped": false, + "count": { + "type": "Fixed", + "value": 1 + }, + "owner": { + "type": "Controller" + }, + "enters_attacking": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } ], - "keywords": [], - "tapped": false, - "count": { - "type": "Fixed", - "value": 1 - }, - "owner": { - "type": "Controller" - }, - "enters_attacking": false + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": { - "type": "Mana", + "shell": { + "sub_link": null, "cost": { - "type": "Cost", - "shards": [ - "Green" - ], - "generic": 2 - } + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [ + "Green" + ], + "generic": 2 + } + }, + "description": "{2}{G}: Create a 1/1 green Saproling creature token.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] }, - "sub_ability": null, - "duration": null, - "description": "{2}{G}: Create a 1/1 green Saproling creature token.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__llanowar_elves_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__llanowar_elves_ir.snap index b673dd8ab0..1fcecc4582 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__llanowar_elves_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__llanowar_elves_ir.snap @@ -22,29 +22,86 @@ expression: "&ir" "fragment": "{T}: Add {G}." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "Mana", - "produced": { - "type": "Fixed", - "colors": [ - "Green" - ] - } + "Spell": { + "source_text": "Add {G}", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 7, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Add {G}" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Mana", + "produced": { + "type": "Fixed", + "colors": [ + "Green" + ] + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": { - "type": "Tap" + "shell": { + "sub_link": null, + "cost": { + "type": "Tap" + }, + "description": "{T}: Add {G}.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] }, - "sub_ability": null, - "duration": null, - "description": "{T}: Add {G}.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "is_mana_ability": true + "die_results": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__mother_of_runes_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__mother_of_runes_ir.snap index b5a18c7fb3..3123375c0d 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__mother_of_runes_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__mother_of_runes_ir.snap @@ -22,69 +22,111 @@ expression: "&ir" "fragment": "{T}: Target creature you control gains protection from the color of your choice until end of turn." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "Choose", - "choice_type": "Color", - "persist": true - }, - "cost": { - "type": "Tap" - }, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "GenericEffect", - "static_abilities": [ - { - "mode": "Continuous", - "affected": { - "type": "ParentTarget" + "Spell": { + "source_text": "Target creature you control gains protection from the color of your choice until end of turn", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 92, + "precision": "ChainRelative", + "ordinal_within_span": 0 }, - "modifications": [ - { - "type": "AddKeyword", - "keyword": { - "Protection": "ChosenColor" + "fragment": "Target creature you control gains protection from the color of your choice until end of turn" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "GenericEffect", + "static_abilities": [ + { + "mode": "Continuous", + "affected": { + "type": "ParentTarget" + }, + "modifications": [ + { + "type": "AddKeyword", + "keyword": { + "Protection": "ChosenColor" + } + } + ], + "condition": null, + "affected_zone": null, + "effect_zone": null, + "active_zones": [], + "characteristic_defining": false, + "description": "gain protection from the color of your choice" } + ], + "duration": "UntilEndOfTurn", + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] } - ], + }, + "duration": "UntilEndOfTurn", + "sub_ability": null, + "distribute": null, + "multi_target": null, "condition": null, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "gain protection from the color of your choice" - } - ], - "duration": "UntilEndOfTurn", - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null, + "cost": { + "type": "Tap" }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "description": "{T}: Target creature you control gains protection from the color of your choice until end of turn.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] }, - "duration": "UntilEndOfTurn", - "description": "{T}: Target creature you control gains protection from the color of your choice until end of turn.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__repeat_offender_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__repeat_offender_ir.snap index e6accdb463..dffb307397 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__repeat_offender_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__repeat_offender_ir.snap @@ -22,70 +22,177 @@ expression: "&ir" "fragment": "{2}{B}: If ~ is suspected, put a +1/+1 counter on it. Otherwise, suspect it. (A suspected creature has menace and can't block.)" }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "PutCounter", - "counter_type": "P1P1", - "count": { - "type": "Fixed", - "value": 1 - }, - "target": { - "type": "SelfRef" - } + "Spell": { + "source_text": "If ~ is suspected, put a +1/+1 counter on it. Otherwise, suspect it", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 44, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "If ~ is suspected, put a +1/+1 counter on it" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "PutCounter", + "counter_type": "P1P1", + "count": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "SelfRef" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": { + "type": "SourceMatchesFilter", + "filter": { + "type": "Typed", + "type_filters": [], + "controller": null, + "properties": [ + { + "type": "Suspected" + } + ] + } + }, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 46, + "end_byte": 67, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "Otherwise, suspect it" + }, + "disposition": { + "BranchOtherwise": { + "else_def": { + "kind": "Activated", + "effect": { + "type": "Suspect", + "target": { + "type": "ParentTarget" + }, + "scope": { + "type": "Single" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "kind": "Bound" + } + }, + "parsed": { + "effect": { + "type": "Unimplemented", + "name": "otherwise_placeholder", + "description": null + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": { - "type": "Mana", + "shell": { + "sub_link": null, "cost": { - "type": "Cost", - "shards": [ - "Black" - ], - "generic": 2 - } - }, - "sub_ability": null, - "else_ability": { - "kind": "Activated", - "effect": { - "type": "Suspect", - "target": { - "type": "SelfRef" - }, - "scope": { - "type": "Single" + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [ + "Black" + ], + "generic": 2 } }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "duration": null, - "description": "{2}{B}: If ~ is suspected, put a +1/+1 counter on it. Otherwise, suspect it.", - "target_prompt": null, - "condition": { - "type": "SourceMatchesFilter", - "filter": { - "type": "Typed", - "type_filters": [], - "controller": null, - "properties": [ - { - "type": "Suspected" - } - ] - } + "description": "{2}{B}: If ~ is suspected, put a +1/+1 counter on it. Otherwise, suspect it.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] }, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__stoneforge_mystic_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__stoneforge_mystic_ir.snap index 1c1048c211..d6cfc9f3fc 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__stoneforge_mystic_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__stoneforge_mystic_ir.snap @@ -220,59 +220,116 @@ expression: "&ir" "fragment": "{1}{W}, {T}: You may put an Equipment card from your hand onto the battlefield." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "ChangeZone", - "origin": "Hand", - "destination": "Battlefield", - "target": { - "type": "Typed", - "type_filters": [ + "Spell": { + "source_text": "You may put an Equipment card from your hand onto the battlefield", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 65, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "You may put an Equipment card from your hand onto the battlefield" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "ChangeZone", + "origin": "Hand", + "destination": "Battlefield", + "target": { + "type": "Typed", + "type_filters": [ + { + "Subtype": "Equipment" + } + ], + "controller": "You", + "properties": [ + { + "type": "InZone", + "zone": "Hand" + } + ] + }, + "owner_library": false, + "enter_transformed": false, + "enter_tapped": false, + "enters_attacking": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": true, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null, + "cost": { + "type": "Composite", + "costs": [ { - "Subtype": "Equipment" - } - ], - "controller": "You", - "properties": [ + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [ + "White" + ], + "generic": 1 + } + }, { - "type": "InZone", - "zone": "Hand" + "type": "Tap" } ] }, - "owner_library": false, - "enter_transformed": false, - "enter_tapped": false, - "enters_attacking": false - }, - "cost": { - "type": "Composite", - "costs": [ - { - "type": "Mana", - "cost": { - "type": "Cost", - "shards": [ - "White" - ], - "generic": 1 - } - }, - { - "type": "Tap" - } + "description": "{1}{W}, {T}: You may put an Equipment card from your hand onto the battlefield.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" ] }, - "sub_ability": null, - "duration": null, - "description": "{1}{W}, {T}: You may put an Equipment card from your hand onto the battlefield.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": true, - "target_choice_timing": "Resolution", - "forward_result": false + "die_results": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__sylvan_safekeeper_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__sylvan_safekeeper_ir.snap index c0f53d3814..0e95cb8c92 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__sylvan_safekeeper_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__sylvan_safekeeper_ir.snap @@ -22,60 +22,118 @@ expression: "&ir" "fragment": "Sacrifice a land: Target creature you control gains shroud until end of turn." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "GenericEffect", - "static_abilities": [ + "Spell": { + "source_text": "Target creature you control gains shroud until end of turn", + "body": { + "clauses": [ { - "mode": "Continuous", - "affected": { - "type": "ParentTarget" + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 58, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Target creature you control gains shroud until end of turn" }, - "modifications": [ - { - "type": "AddKeyword", - "keyword": "Shroud" + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null } - ], + }, + "parsed": { + "effect": { + "type": "GenericEffect", + "static_abilities": [ + { + "mode": "Continuous", + "affected": { + "type": "ParentTarget" + }, + "modifications": [ + { + "type": "AddKeyword", + "keyword": "Shroud" + } + ], + "condition": null, + "affected_zone": null, + "effect_zone": null, + "active_zones": [], + "characteristic_defining": false, + "description": "gain shroud" + } + ], + "duration": "UntilEndOfTurn", + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] + } + }, + "duration": "UntilEndOfTurn", + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, "condition": null, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "gain shroud" + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } ], - "duration": "UntilEndOfTurn", - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - } + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": { - "type": "Sacrifice", - "target": { - "type": "Typed", - "type_filters": [ - "Land" - ], - "controller": null, - "properties": [] + "shell": { + "sub_link": null, + "cost": { + "type": "Sacrifice", + "target": { + "type": "Typed", + "type_filters": [ + "Land" + ], + "controller": null, + "properties": [] + }, + "count": 1 }, - "count": 1 + "description": "Sacrifice a land: Target creature you control gains shroud until end of turn.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] }, - "sub_ability": null, - "duration": "UntilEndOfTurn", - "description": "Sacrifice a land: Target creature you control gains shroud until end of turn.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__thespians_stage_generic_activated_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__thespians_stage_generic_activated_ir.snap new file mode 100644 index 0000000000..ccebc7b4cb --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__thespians_stage_generic_activated_ir.snap @@ -0,0 +1,235 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&ir" +--- +{ + "items": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 0 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 13, + "precision": "Exact", + "ordinal_within_span": 0 + }, + "fragment": "{T}: Add {C}." + }, + "node": { + "Spell": { + "source_text": "Add {C}", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 7, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Add {C}" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Mana", + "produced": { + "type": "Colorless", + "count": { + "type": "Fixed", + "value": 1 + } + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null, + "cost": { + "type": "Tap" + }, + "description": "{T}: Add {C}.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] + }, + "die_results": [] + } + } + }, + { + "id": 1, + "source": { + "id": { + "item": 1, + "ordinal": 0 + }, + "span": { + "first_line": 1, + "last_line": 1, + "start_byte": 14, + "end_byte": 84, + "precision": "Exact", + "ordinal_within_span": 0 + }, + "fragment": "{2}, {T}: ~ becomes a copy of target land, except it has this ability." + }, + "node": { + "Spell": { + "source_text": "~ becomes a copy of target land, except it has this ability", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 59, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "~ becomes a copy of target land, except it has this ability" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "BecomeCopy", + "target": { + "type": "Typed", + "type_filters": [ + "Land" + ], + "controller": null, + "properties": [] + }, + "duration": "Permanent", + "additional_modifications": [ + { + "type": "RetainPrintedAbilityFromSource", + "source_ability_index": 0 + } + ] + }, + "duration": "Permanent", + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null, + "cost": { + "type": "Composite", + "costs": [ + { + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [], + "generic": 2 + } + }, + { + "type": "Tap" + } + ] + }, + "description": "{2}, {T}: ~ becomes a copy of target land, except it has this ability.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] + }, + "die_results": [] + } + } + } + ], + "source_text": "{T}: Add {C}.\n{2}, {T}: This land becomes a copy of target land, except it has this ability.", + "card_name": "Thespian's Stage" +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__thespians_stage_generic_activated_lowered.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__thespians_stage_generic_activated_lowered.snap new file mode 100644 index 0000000000..ecc8d62da3 --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__thespians_stage_generic_activated_lowered.snap @@ -0,0 +1,82 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&lowered" +--- +{ + "abilities": [ + { + "kind": "Activated", + "effect": { + "type": "Mana", + "produced": { + "type": "Colorless", + "count": { + "type": "Fixed", + "value": 1 + } + } + }, + "cost": { + "type": "Tap" + }, + "sub_ability": null, + "duration": null, + "description": "{T}: Add {C}.", + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false, + "is_mana_ability": true + }, + { + "kind": "Activated", + "effect": { + "type": "BecomeCopy", + "target": { + "type": "Typed", + "type_filters": [ + "Land" + ], + "controller": null, + "properties": [] + }, + "duration": "Permanent", + "additional_modifications": [ + { + "type": "RetainPrintedAbilityFromSource", + "source_ability_index": 1 + } + ] + }, + "cost": { + "type": "Composite", + "costs": [ + { + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [], + "generic": 2 + } + }, + { + "type": "Tap" + } + ] + }, + "sub_ability": null, + "duration": "Permanent", + "description": "{2}, {T}: ~ becomes a copy of target land, except it has this ability.", + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + } + ], + "triggers": [], + "statics": [], + "replacements": [], + "extractedKeywords": [] +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__walking_ballista_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__walking_ballista_ir.snap index 4417195f20..fbfbf7e5ce 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__walking_ballista_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__walking_ballista_ir.snap @@ -1,6 +1,5 @@ --- source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs -assertion_line: 981 expression: "&ir" --- { @@ -84,35 +83,93 @@ expression: "&ir" "fragment": "{4}: Put a +1/+1 counter on ~." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "PutCounter", - "counter_type": "P1P1", - "count": { - "type": "Fixed", - "value": 1 - }, - "target": { - "type": "SelfRef" - } + "Spell": { + "source_text": "Put a +1/+1 counter on ~", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 24, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Put a +1/+1 counter on ~" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "PutCounter", + "counter_type": "P1P1", + "count": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "SelfRef" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": { - "type": "Mana", + "shell": { + "sub_link": null, "cost": { - "type": "Cost", - "shards": [], - "generic": 4 - } + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [], + "generic": 4 + } + }, + "description": "{4}: Put a +1/+1 counter on ~.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] }, - "sub_ability": null, - "duration": null, - "description": "{4}: Put a +1/+1 counter on ~.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [] } } }, @@ -134,36 +191,94 @@ expression: "&ir" "fragment": "Remove a +1/+1 counter from ~: It deals 1 damage to any target." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "DealDamage", - "amount": { - "type": "Fixed", - "value": 1 - }, - "target": { - "type": "Any" - } + "Spell": { + "source_text": "It deals 1 damage to any target", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 31, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "It deals 1 damage to any target" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "DealDamage", + "amount": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "Any" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": { - "type": "RemoveCounter", - "count": 1, - "counter_type": { - "type": "OfType", - "data": "P1P1" + "shell": { + "sub_link": null, + "cost": { + "type": "RemoveCounter", + "count": 1, + "counter_type": { + "type": "OfType", + "data": "P1P1" + }, + "target": null, + "selection": "SingleObject" }, - "target": null, - "selection": "SingleObject" + "description": "Remove a +1/+1 counter from ~: It deals 1 damage to any target.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] }, - "sub_ability": null, - "duration": null, - "description": "Remove a +1/+1 counter from ~: It deals 1 damage to any target.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [] } } } diff --git a/crates/engine/src/parser/oracle_pipeline_snapshot_tests.rs b/crates/engine/src/parser/oracle_pipeline_snapshot_tests.rs index 08c76a329c..31e27da586 100644 --- a/crates/engine/src/parser/oracle_pipeline_snapshot_tests.rs +++ b/crates/engine/src/parser/oracle_pipeline_snapshot_tests.rs @@ -544,7 +544,8 @@ fn zack_fair_activated_parses_counter_move_and_attach_sub_chain() { let effect = "Target creature you control gains indestructible until end of turn. Put Zack Fair's counters on that creature and attach an Equipment that was attached to Zack Fair to that creature."; let mut ctx = ParseContext::default(); - let def = parse_activated_with_self_ref_fallback(effect, "Zack Fair", &mut ctx); + let ir = parse_activated_ability_ir_with_self_ref_fallback(effect, "Zack Fair", &mut ctx); + let def = lower_ability_ir(&ir); fn has_effect(def: &AbilityDefinition, pred: &dyn Fn(&Effect) -> bool) -> bool { if pred(&def.effect) { diff --git a/crates/engine/src/parser/oracle_tests.rs b/crates/engine/src/parser/oracle_tests.rs index 9d699a3046..ee50378e7f 100644 --- a/crates/engine/src/parser/oracle_tests.rs +++ b/crates/engine/src/parser/oracle_tests.rs @@ -8540,6 +8540,27 @@ fn parses_urza_tower_conditional_mana_as_delta() { } } +#[test] +fn activated_mana_instead_delta_preserves_non_instead_condition() { + let mut ability = parse( + "{T}: Add {C}. If you control an Urza's Mine and an Urza's Power-Plant, add {C}{C}{C} instead.", + "Urza's Tower", + &[], + &["Land"], + &["Urza's", "Tower"], + ) + .abilities + .remove(0); + let before = ability.clone(); + + normalize_activated_mana_instead_delta(&mut ability); + + assert_eq!( + ability, before, + "the normalizer must leave an already-lowered non-replacement condition intact" + ); +} + /// CR 205.3i + CR 614.1a + CR 605.1a: All three Urza lands share a single /// parsed shape — an activated mana ability (`{T}: Add {C}.` per CR 605.1a) /// plus a conditional `Add {C}{C}{C} instead` sub-ability whose "instead" From 6719cc549f2850e396e4801189f2479b7064673a Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:54:45 -0700 Subject: [PATCH 19/63] Fix activated ability target and cost ordering (#6749) * refactor trigger collection source overlay * extract trigger collection mutations * journal pending activation trigger collection * fix trigger collection journal lint * make pending activation trigger journal durable * route activated targets before sacrifice costs * retain activation trigger transaction in pending casts * stage target activation triggers before costs * preserve pending activation trigger provenance * fix activation cancellation after paid costs * keep parked activation cost triggers local * route direct activation roots through target selection * update x activation target ordering regression * fix(PR-6749): complete target-first activation costs * fix(PR-6749): dispatch remaining activation costs * fix(PR-6749): compare sacrifice cost by reference * fix(PR-6749): match sacrifice assertion bindings * fix(PR-6749): preserve activation declaration ordering * fix(PR-6749): preserve target errors before X choice * fix(PR-6749): type unresolved X targeting * fix(PR-6749): align activation eligibility targets * fix(PR-6749): preserve X target and cost ordering * fix(PR-6749): order target-first cost payment * fix target-first activation cost routing * defer symbolic x counter cost after mana * fix counter cost regression filter * preserve target-first mana payment boundary * fix activation mana payment boundary * fix target first mana and trigger X binding * fix ai activation mill continuation --------- Co-authored-by: matthewevans --- .../src/ai_support/payment_continuation.rs | 8 + crates/engine/src/game/ability_utils.rs | 170 ++- crates/engine/src/game/casting.rs | 904 +++++++------- crates/engine/src/game/casting_costs.rs | 1069 ++++++++++++++--- crates/engine/src/game/casting_targets.rs | 214 ++-- crates/engine/src/game/casting_tests.rs | 353 +++++- crates/engine/src/game/engine.rs | 56 +- crates/engine/src/game/engine_casting.rs | 9 +- crates/engine/src/game/engine_modes.rs | 69 +- crates/engine/src/game/engine_priority.rs | 35 + crates/engine/src/game/engine_tests.rs | 36 +- crates/engine/src/game/quantity.rs | 15 +- crates/engine/src/game/triggers.rs | 846 +++++++++++-- crates/engine/src/game/visibility.rs | 12 + crates/engine/src/types/game_state.rs | 96 ++ ...onsilver_sacrifice_source_relative_6017.rs | 21 +- .../integration/cost_x_carrier_runtime.rs | 4 +- .../tests/integration/cost_zone_pipeline.rs | 96 ++ .../integration/greater_good_activation.rs | 43 +- ...01_cauldron_of_essence_sacrifice_target.rs | 22 +- crates/engine/tests/integration/main.rs | 1 + .../mogg_fanatic_target_before_cost.rs | 570 +++++++++ 22 files changed, 3621 insertions(+), 1028 deletions(-) create mode 100644 crates/engine/tests/integration/mogg_fanatic_target_before_cost.rs diff --git a/crates/engine/src/ai_support/payment_continuation.rs b/crates/engine/src/ai_support/payment_continuation.rs index be1f147f80..c102286df8 100644 --- a/crates/engine/src/ai_support/payment_continuation.rs +++ b/crates/engine/src/ai_support/payment_continuation.rs @@ -397,6 +397,11 @@ fn classify_parked_cost_move_root(state: &GameState) -> PaymentContinuationState PendingCostMoveResume::DelveManaPayment { player, .. } => { classify_global_root(state, *player) } + // CR 602.2b: The parked mill leg retains the announced activation's + // serialized payment root until the replacement choice completes. + PendingCostMoveResume::ActivationMillPayment { player, pending } => { + PaymentContinuationState::Affiliated(root_from_pending_cast(pending, *player)) + } // These are distinct cost/resolution continuations. They intentionally // retain their existing policies rather than being misidentified from a // coincidental PendingCast elsewhere in state. @@ -619,6 +624,9 @@ fn pending_cost_move_contains_root( Some(PendingCostMoveResume::ManaAbilityPayment { pending, cursor }) => { pending_mana_contains_root(pending, root) || cursor_contains_root(cursor, root) } + Some(PendingCostMoveResume::ActivationMillPayment { pending, .. }) => { + pending_matches_root(pending, root) + } Some(PendingCostMoveResume::CollectEvidencePayment { resume, .. }) => match resume.as_ref() { CollectEvidenceResume::Casting { pending_cast, .. } => { diff --git a/crates/engine/src/game/ability_utils.rs b/crates/engine/src/game/ability_utils.rs index b0395dcde7..b1c85a6b8e 100644 --- a/crates/engine/src/game/ability_utils.rs +++ b/crates/engine/src/game/ability_utils.rs @@ -423,15 +423,67 @@ impl SlotAccumulator { } } +/// Result of target construction while an ability is being announced. +/// +/// `RequiresChosenX` is distinct from an illegal target set: CR 601.2b requires +/// announcing X before the CR 601.2c target declaration can be evaluated. +pub(crate) enum TargetSlotBuildOutcome { + Slots(Vec), + RequiresChosenX, +} + +enum TargetSlotBuildError { + Engine(EngineError), + RequiresChosenX, +} + +impl From for TargetSlotBuildError { + fn from(error: EngineError) -> Self { + Self::Engine(error) + } +} + +pub(crate) fn unresolved_x_target_construction_error() -> EngineError { + EngineError::ActionNotAllowed( + "Target count requires a resolved quantity before target selection".to_string(), + ) +} + +impl From for EngineError { + fn from(error: TargetSlotBuildError) -> Self { + match error { + TargetSlotBuildError::Engine(error) => error, + TargetSlotBuildError::RequiresChosenX => unresolved_x_target_construction_error(), + } + } +} + +/// CR 601.2b/c + CR 602.2b: Collect target slots while preserving the +/// announce-time distinction between an unresolved X and an illegal target set. +pub(crate) fn build_target_slots_for_announcement( + state: &GameState, + ability: &ResolvedAbility, +) -> Result { + let mut acc = SlotAccumulator::default(); + match collect_target_slots(state, ability, &mut acc) { + Ok(()) => Ok(TargetSlotBuildOutcome::Slots(acc.slots)), + Err(TargetSlotBuildError::RequiresChosenX) => Ok(TargetSlotBuildOutcome::RequiresChosenX), + Err(TargetSlotBuildError::Engine(error)) => Err(error), + } +} + /// CR 601.2c / CR 602.2b: Collect all target slots for an ability chain. Each targeting /// effect in the chain produces a slot whose legal targets are computed from the game state. pub fn build_target_slots( state: &GameState, ability: &ResolvedAbility, ) -> Result, EngineError> { - let mut acc = SlotAccumulator::default(); - collect_target_slots(state, ability, &mut acc)?; - Ok(acc.slots) + match build_target_slots_for_announcement(state, ability)? { + TargetSlotBuildOutcome::Slots(slots) => Ok(slots), + TargetSlotBuildOutcome::RequiresChosenX => { + Err(TargetSlotBuildError::RequiresChosenX.into()) + } + } } /// CR 601.2b + CR 702.33a/702.194c: "instead" spells with a target-dependent @@ -2260,7 +2312,7 @@ fn collect_target_slots( state: &GameState, ability: &ResolvedAbility, acc: &mut SlotAccumulator, -) -> Result<(), EngineError> { +) -> Result<(), TargetSlotBuildError> { let resolved_chooser = ability.target_chooser.as_ref().and_then(|filter| { crate::game::targeting::resolve_effect_player_ref(state, ability, filter) // A chooser equal to the controller is the CR-601.2c default; leave the @@ -2342,7 +2394,7 @@ fn collect_target_slots_inner( state: &GameState, ability: &ResolvedAbility, acc: &mut SlotAccumulator, -) -> Result<(), EngineError> { +) -> Result<(), TargetSlotBuildError> { if let Some(sub_ability) = ability.sub_ability.as_deref().filter(|sub| { matches!( sub.condition, @@ -2364,6 +2416,10 @@ fn collect_target_slots_inner( } } + if target_slot_construction_needs_chosen_x_at_announcement(state, ability) { + return Err(TargetSlotBuildError::RequiresChosenX); + } + // CR 609.7 + CR 601.2c: A source-scoped `PreventDamage` ("prevent all damage // target instant or sorcery spell would deal this turn") surfaces the // choosable source spell as a target slot. Declared FIRST (CR 601.2c @@ -2377,9 +2433,7 @@ fn collect_target_slots_inner( let legal_targets = legal_targets_for_ability_filter(state, ability, src_leaf, &acc.slots); if legal_targets.is_empty() && !ability.optional_targeting { - return Err(EngineError::ActionNotAllowed( - "No legal targets available".to_string(), - )); + return Err(no_legal_target_slots()); } acc.push(TargetSelectionSlot { legal_targets, @@ -2403,9 +2457,7 @@ fn collect_target_slots_inner( let legal_targets = legal_targets_for_ability_filter(state, ability, filter, &acc.slots); if legal_targets.is_empty() && !ability.optional_targeting { - return Err(EngineError::ActionNotAllowed( - "No legal targets available".to_string(), - )); + return Err(no_legal_target_slots()); } acc.push(TargetSelectionSlot { legal_targets, @@ -2431,9 +2483,7 @@ fn collect_target_slots_inner( let legal_targets = legal_targets_for_ability_filter(state, ability, filter, &acc.slots); if legal_targets.is_empty() && !ability.optional_targeting { - return Err(EngineError::ActionNotAllowed( - "No legal targets available".to_string(), - )); + return Err(no_legal_target_slots()); } acc.push(TargetSelectionSlot { legal_targets, @@ -2471,9 +2521,7 @@ fn collect_target_slots_inner( let legal_targets = legal_targets_for_ability_filter(state, ability, filter, &acc.slots); if legal_targets.is_empty() && !ability.optional_targeting { - return Err(EngineError::ActionNotAllowed( - "No legal targets available".to_string(), - )); + return Err(no_legal_target_slots()); } acc.push(TargetSelectionSlot { legal_targets, @@ -2500,9 +2548,7 @@ fn collect_target_slots_inner( let legal_targets = legal_targets_for_ability_filter(state, ability, filter, &acc.slots); if legal_targets.is_empty() && !ability.optional_targeting { - return Err(EngineError::ActionNotAllowed( - "No legal targets available".to_string(), - )); + return Err(no_legal_target_slots()); } acc.push(TargetSelectionSlot { legal_targets, @@ -2518,9 +2564,7 @@ fn collect_target_slots_inner( let legal_targets = legal_targets_for_ability_filter(state, ability, target, &acc.slots); if legal_targets.is_empty() && !ability.optional_targeting { - return Err(EngineError::ActionNotAllowed( - "No legal targets available".to_string(), - )); + return Err(no_legal_target_slots()); } acc.push(TargetSelectionSlot { legal_targets, @@ -2561,9 +2605,7 @@ fn collect_target_slots_inner( let legal_targets = legal_targets_for_ability_filter(state, ability, filter, &acc.slots); if legal_targets.is_empty() && !ability.optional_targeting { - return Err(EngineError::ActionNotAllowed( - "No legal targets available".to_string(), - )); + return Err(no_legal_target_slots()); } acc.push(TargetSelectionSlot { legal_targets, @@ -2608,9 +2650,7 @@ fn collect_target_slots_inner( // No spec means a single mandatory source (defensive — the parser // always attaches an "up to two"/"two" spec for this effect). if source_legal.is_empty() { - return Err(EngineError::ActionNotAllowed( - "No legal targets available".to_string(), - )); + return Err(no_legal_target_slots()); } acc.push(TargetSelectionSlot { legal_targets: source_legal, @@ -2643,9 +2683,7 @@ fn collect_target_slots_inner( let recipient_legal = legal_targets_for_ability_filter(state, ability, recipient, &acc.slots); if recipient_legal.is_empty() { - return Err(EngineError::ActionNotAllowed( - "No legal targets available".to_string(), - )); + return Err(no_legal_target_slots()); } acc.push(TargetSelectionSlot { legal_targets: recipient_legal, @@ -2691,9 +2729,7 @@ fn collect_target_slots_inner( // selection-time recompute so both paths agree. let player_targets = companion_target_player_legal_targets(state, ability); if player_targets.is_empty() && !ability.optional_targeting { - return Err(EngineError::ActionNotAllowed( - "No legal targets available".to_string(), - )); + return Err(no_legal_target_slots()); } acc.push(TargetSelectionSlot { legal_targets: player_targets, @@ -2712,9 +2748,7 @@ fn collect_target_slots_inner( let legal_targets = legal_targets_for_ability_filter(state, ability, &filter, &acc.slots); if legal_targets.is_empty() && !ability.optional_targeting { - return Err(EngineError::ActionNotAllowed( - "No legal targets available".to_string(), - )); + return Err(no_legal_target_slots()); } acc.push(TargetSelectionSlot { legal_targets, @@ -2731,9 +2765,7 @@ fn collect_target_slots_inner( let legal_targets = legal_targets_for_ability_filter(state, ability, &filter, &acc.slots); if legal_targets.is_empty() && !ability.optional_targeting { - return Err(EngineError::ActionNotAllowed( - "No legal targets available".to_string(), - )); + return Err(no_legal_target_slots()); } acc.push(TargetSelectionSlot { legal_targets, @@ -2767,9 +2799,7 @@ fn collect_target_slots_inner( } } else { if legal_targets.is_empty() && !ability.optional_targeting { - return Err(EngineError::ActionNotAllowed( - "No legal targets available".to_string(), - )); + return Err(no_legal_target_slots()); } acc.push(TargetSelectionSlot { legal_targets, @@ -2802,6 +2832,10 @@ fn collect_target_slots_inner( Ok(()) } +fn no_legal_target_slots() -> TargetSlotBuildError { + EngineError::ActionNotAllowed("No legal targets available".to_string()).into() +} + fn legal_choices_for_ability_filter( state: &GameState, ability: &ResolvedAbility, @@ -3009,15 +3043,7 @@ pub fn ability_target_legality_needs_chosen_x( } fn ability_target_legality_needs_chosen_x_inner(ability: &ResolvedAbility) -> bool { - triggers::extract_target_filter_from_effect(&ability.effect) - .is_some_and(|filter| target_filter_needs_chosen_x(ability, filter)) - || ability.multi_target.as_ref().is_some_and(|spec| { - quantity_expr_has_unresolved_x(ability, &spec.min) - || spec - .max - .as_ref() - .is_some_and(|expr| quantity_expr_has_unresolved_x(ability, expr)) - }) + target_slot_construction_needs_chosen_x(ability) || ability .sub_ability .as_deref() @@ -3028,6 +3054,42 @@ fn ability_target_legality_needs_chosen_x_inner(ability: &ResolvedAbility) -> bo .is_some_and(ability_target_legality_needs_chosen_x_inner) } +/// CR 601.2b/c: The current chain link cannot determine either its target +/// filter or its target count until its announced X value is available. +/// +/// This deliberately excludes sub-abilities. `collect_target_slots` traverses +/// links in declaration order, so an earlier missing mandatory target remains +/// an immediate illegal-target error instead of being masked by a later +/// X-dependent target instruction. +fn target_slot_construction_needs_chosen_x(ability: &ResolvedAbility) -> bool { + ability.chosen_x.is_none() + && (triggers::extract_target_filter_from_effect(&ability.effect) + .is_some_and(|filter| target_filter_needs_chosen_x(ability, filter)) + || ability.multi_target.as_ref().is_some_and(|spec| { + quantity_expr_has_unresolved_x(ability, &spec.min) + || spec + .max + .as_ref() + .is_some_and(|expr| quantity_expr_has_unresolved_x(ability, expr)) + })) +} + +/// CR 107.3m + CR 603.3b: A triggered ability's target filter can refer to the +/// X paid for the spell that produced its source. That X is not `chosen_x` on +/// the trigger, but it is already bound on the trigger source before targets +/// are chosen; only defer target construction when neither authority exists. +fn target_slot_construction_needs_chosen_x_at_announcement( + state: &GameState, + ability: &ResolvedAbility, +) -> bool { + target_slot_construction_needs_chosen_x(ability) + && ability + .trigger_source + .as_ref() + .and_then(|source| source.source_read(state).cost_x_paid()) + .is_none() +} + fn target_filter_needs_chosen_x(ability: &ResolvedAbility, filter: &TargetFilter) -> bool { ability.chosen_x.is_none() && target_filter_contains_chosen_x_ref(filter) } @@ -5483,7 +5545,7 @@ fn collect_target_slots_after_deferred_effect( state: &GameState, sub_ability: Option<&ResolvedAbility>, acc: &mut SlotAccumulator, -) -> Result<(), EngineError> { +) -> Result<(), TargetSlotBuildError> { let Some(sub_ability) = sub_ability else { return Ok(()); }; diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 3d19b3e94e..a932575ca3 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -40,9 +40,11 @@ use super::ability_utils::{ ability_target_legality_needs_chosen_x, additional_cost_instead_spell_has_legal_targets, assign_targets_in_chain, auto_select_targets, auto_select_targets_for_ability, begin_target_selection, begin_target_selection_for_ability, build_resolved_from_def, - build_target_slots, compute_unavailable_modes, filter_references_target_player, - flatten_targets_in_chain, has_legal_target_assignment_for_ability, modal_choice_for_player, + build_target_slots, build_target_slots_for_announcement, compute_unavailable_modes, + filter_references_target_player, flatten_targets_in_chain, + has_legal_target_assignment_for_ability, modal_choice_for_player, simple_legal_target_assignment_exists_for_ability, target_constraints_from_modal, + unresolved_x_target_construction_error, TargetSlotBuildOutcome, }; use super::casting_costs::{self, check_additional_cost_or_pay}; use super::engine::EngineError; @@ -15410,7 +15412,7 @@ pub fn pay_unless_cost( } /// Walk a cost tree and return the waterbend mana cost if present. -fn find_waterbend_cost(cost: &AbilityCost) -> Option<&ManaCost> { +pub(super) fn find_waterbend_cost(cost: &AbilityCost) -> Option<&ManaCost> { match cost { AbilityCost::Waterbend { cost } => Some(cost), AbilityCost::Composite { costs } => costs.iter().find_map(find_waterbend_cost), @@ -15791,7 +15793,9 @@ pub(super) fn find_collect_evidence_activation_cost(cost: &AbilityCost) -> Optio /// selection across the battlefield/graveyard union. Returns `(count, /// materials)`. Recurses into `Composite` (the synthesized craft cost is a /// `Composite[Mana, Exile{SelfRef}, ExileMaterials]`). -fn find_craft_materials_cost(cost: &AbilityCost) -> Option<(CostObjectCount, &TargetFilter)> { +pub(super) fn find_craft_materials_cost( + cost: &AbilityCost, +) -> Option<(CostObjectCount, &TargetFilter)> { match cost { AbilityCost::ExileMaterials { materials, count } => Some((*count, materials)), AbilityCost::Composite { costs } => costs.iter().find_map(find_craft_materials_cost), @@ -15812,7 +15816,7 @@ pub(super) fn find_tap_creatures_cost( } } -fn find_targeted_remove_counter_cost( +pub(super) fn find_targeted_remove_counter_cost( cost: &AbilityCost, ) -> Option<( u32, @@ -15960,7 +15964,7 @@ pub(crate) fn find_eligible_unattach_for_cost_targets( .collect() } -fn find_one_of_cost(cost: &AbilityCost) -> Option<&Vec> { +pub(super) fn find_one_of_cost(cost: &AbilityCost) -> Option<&Vec> { match cost { AbilityCost::OneOf { costs } => Some(costs), AbilityCost::Composite { costs } => costs.iter().find_map(find_one_of_cost), @@ -16227,7 +16231,7 @@ pub(crate) fn find_eligible_remove_counter_for_cost_targets( .collect() } -fn find_eligible_tap_creatures_for_cost( +pub(super) fn find_eligible_tap_creatures_for_cost( state: &GameState, player: PlayerId, source: ObjectId, @@ -16645,8 +16649,8 @@ pub fn can_activate_ability_now_with_restriction_gates( return has_target; } - match build_target_slots(&simulated, &resolved) { - Ok(target_slots) => { + match build_target_slots_for_announcement(&simulated, &resolved) { + Ok(TargetSlotBuildOutcome::Slots(target_slots)) => { if target_slots.is_empty() { return true; } @@ -16660,15 +16664,15 @@ pub fn can_activate_ability_now_with_restriction_gates( &ability_def.target_constraints, ) } - Err(_) => { - ability_target_legality_needs_chosen_x(&resolved, ability_def.distribute.as_ref()) - && ability_def.cost.as_ref().is_some_and(|cost| { - casting_costs::extract_x_mana_cost(cost).is_some() - || find_non_self_sacrifice_cost(cost) - .is_some_and(|(count, _)| count == u32::MAX) - || casting_costs::activation_cost_needs_x_choice(&resolved, cost) - }) + Ok(TargetSlotBuildOutcome::RequiresChosenX) => { + ability_def.cost.as_ref().is_some_and(|cost| { + casting_costs::extract_x_mana_cost(cost).is_some() + || find_non_self_sacrifice_cost(cost) + .is_some_and(|(count, _)| count == u32::MAX) + || casting_costs::activation_cost_needs_x_choice(&resolved, cost) + }) } + Err(_) => false, } } @@ -16827,6 +16831,10 @@ pub(super) fn try_finalize_pending_activation_mana_leg( pending.activation_cost = remaining; pending.activation_ability_index = Some(ability_index); pending.activation_residual = ActivationResidual::ManaLeg; + let target_first_interactive_suffix = matches!( + pending.activation_target_selection, + ActivationTargetSelection::Settled + ) && pending.activation_cost.is_some(); let pending_source_id = pending.object_id; state.pending_cast = Some(Box::new(pending)); let waiting = casting_costs::maybe_pause_for_phyrexian_choice( @@ -16842,7 +16850,15 @@ pub(super) fn try_finalize_pending_activation_mana_leg( if let Some(waiting) = waiting { return Ok(Some(waiting)); } - casting_costs::finalize_automatic_mana_payment(state, player, events).map(Some) + if target_first_interactive_suffix { + // CR 601.2g-h + CR 602.2b: A target-first activation has already + // declared its targets but still has an unpaid interactive suffix. Its + // mana leg therefore exposes ManaPayment rather than invalidating that + // target declaration before the suffix can be paid. + casting_costs::enter_payment_step(state, player, None, events).map(Some) + } else { + casting_costs::finalize_automatic_mana_payment(state, player, events).map(Some) + } } /// CR 602.2b + CR 605.3b + CR 616.1: Finalize an activation mana cost that @@ -16870,6 +16886,10 @@ pub(super) fn finalize_pending_activation_mana_payment( activation_payment_context(state, pending.object_id, Some(ability_index)); let activation_ctx = activation_context.as_payment_context(); let source_id = pending.object_id; + let target_first_interactive_suffix = matches!( + pending.activation_target_selection, + ActivationTargetSelection::Settled + ) && pending.activation_cost.is_some(); state.pending_cast = Some(Box::new(pending)); if let Some(waiting) = casting_costs::maybe_pause_for_phyrexian_choice( state, @@ -16883,7 +16903,14 @@ pub(super) fn finalize_pending_activation_mana_payment( ) { return Ok(waiting); } - casting_costs::finalize_automatic_mana_payment(state, player, events) + if target_first_interactive_suffix { + // CR 601.2g-h + CR 602.2b: Preserve the manual-payment boundary only + // while target declaration still precedes an unpaid interactive suffix; + // otherwise an unaffordable activation is illegal immediately. + casting_costs::enter_payment_step(state, player, None, events) + } else { + casting_costs::finalize_automatic_mana_payment(state, player, events) + } } #[allow(clippy::too_many_arguments)] @@ -17141,9 +17168,32 @@ pub fn handle_activate_ability( // CR 603.4: Stamp the printed-ability index for per-turn resolution tracking // before any branch path that pushes this ability onto the stack. resolved.ability_index = Some(ability_index); + // CR 602.2b + CR 601.2b/c: an X announcement can determine how many + // targets an ability has. Before X is chosen, target-slot construction may + // reject that specific class of otherwise legal activation; defer only that + // X-dependent case through the X round-trip. Every other target-build + // failure remains an immediate activation error. + let has_effect_targets = match build_target_slots_for_announcement(state, &resolved) { + Ok(TargetSlotBuildOutcome::Slots(target_slots)) => !target_slots.is_empty(), + // A typed outcome preserves the distinction between a target slot that + // cannot yet be evaluated because X is unannounced and a genuinely + // illegal target set. Only the former enters the X round-trip. + Ok(TargetSlotBuildOutcome::RequiresChosenX) + if activation_cost.as_ref().is_some_and(|cost| { + casting_costs::extract_x_mana_cost(cost).is_some() + || casting_costs::activation_cost_needs_x_choice(&resolved, cost) + }) => + { + true + } + Ok(TargetSlotBuildOutcome::RequiresChosenX) => { + return Err(unresolved_x_target_construction_error()); + } + Err(error) => return Err(error), + }; - // CR 118.3: Pre-check for non-self sacrifice costs — must detour to WaitingFor - // before any cost payment, regardless of whether targets were auto-selected. + // CR 602.2b + CR 601.2b-i: announcement-only choices are resolved before + // entering the shared target-before-cost boundary below. if let Some(ref cost) = activation_cost { // CR 606.3: `can_activate_ability_now` gates legal-action generation, // but direct `GameAction::ActivateAbility` submissions must be rejected @@ -17177,6 +17227,7 @@ pub fn handle_activate_ability( ); pending_x.activation_cost = remaining; pending_x.activation_ability_index = Some(ability_index); + pending_x.deferred_target_selection = has_effect_targets; // CR 601.2g + CR 601.2h: if a non-self battlefield-removal sub-cost // (Sacrifice / battlefield Exile / ReturnToHand) is still // outstanding in the residual after X-announcement, mark the @@ -17213,6 +17264,7 @@ pub fn handle_activate_ability( let mut pending_x = PendingCast::new(source_id, CardId(0), resolved, mana_cost); pending_x.activation_cost = remaining; pending_x.activation_ability_index = Some(ability_index); + pending_x.deferred_target_selection = has_effect_targets; // CR 601.2f + CR 601.2h: POSITIVE signal — the residual non-mana tail // in `activation_cost` is still OUTSTANDING after mana payment, so // `push_activated_ability_to_stack` must re-surface a non-self discard @@ -17250,9 +17302,11 @@ pub fn handle_activate_ability( // here — it must fall through to the general target-first path below // (CR 601.2c: targets are chosen before costs are paid), where the // mana-first `Composite` ordering keeps the post-target payment atomic. - let loyalty_no_targets = crate::types::ability::is_loyalty_ability_cost(cost) - && build_target_slots(state, &resolved)?.is_empty(); - if find_non_self_battlefield_removal_cost(cost).is_some() || loyalty_no_targets { + let loyalty_no_targets = + crate::types::ability::is_loyalty_ability_cost(cost) && !has_effect_targets; + if !has_effect_targets + && (find_non_self_battlefield_removal_cost(cost).is_some() || loyalty_no_targets) + { if let Some((mana_cost, remaining)) = casting_costs::extract_mana_leg(cost) { let mut pending_leg = PendingCast::new(source_id, CardId(0), resolved, mana_cost); pending_leg.activation_cost = remaining; @@ -17263,145 +17317,146 @@ pub fn handle_activate_ability( } } - if let Some((count, sac_filter)) = find_non_self_sacrifice_cost(cost) { - let eligible = find_eligible_sacrifice_targets(state, player, source_id, sac_filter); - let (min_count, max_count) = sacrifice_cost_bounds(count, eligible.len()); - if eligible.len() < min_count { - return Err(EngineError::ActionNotAllowed( - "Not enough eligible permanents to sacrifice".into(), - )); + if !has_effect_targets { + // CR 602.2b + CR 601.2c/h: The no-target route shares the same + // serialized interactive-cost dispatcher as a target-first + // activation after target declaration. In particular, its handlers + // remove the paid cost leg before resuming, so a completed exile, + // craft, or collect-evidence cost cannot be prompted a second time. + let mut pending_interactive = + PendingCast::new(source_id, CardId(0), resolved.clone(), ManaCost::NoCost); + pending_interactive.activation_cost = Some(cost.clone()); + pending_interactive.activation_ability_index = Some(ability_index); + let initial_activation_cost = pending_interactive.activation_cost.clone(); + if let Some(waiting_for) = + casting_costs::surface_next_unpaid_interactive_activation_cost( + state, + player, + &mut pending_interactive, + events, + )? + { + return Ok(waiting_for); + } + if pending_interactive.activation_cost != initial_activation_cost { + return casting_costs::finish_activated_ability_at_payment_boundary( + state, + player, + pending_interactive, + events, + ); } - let mut pending_sac = - PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); - pending_sac.activation_cost = Some(cost.clone()); - pending_sac.activation_ability_index = Some(ability_index); - pending_sac.deferred_target_selection = true; - return Ok(WaitingFor::PayCost { - player, - kind: PayCostKind::Sacrifice, - choices: eligible, - count: max_count, - min_count, - resume: CostResume::Spell { - spell: Box::new(pending_sac), - }, - }); - } - // CR 601.2h + CR 701.9a: A resolved zero-card FromHand discard leg (e.g. Bomat - // Courier's "Discard your hand" on an empty hand) is paid by doing nothing — the - // helper returns `Ok(None)` so we FALL THROUGH to the following cost detection - // rather than surfacing a dead `PayCost { count: 0 }`. - if let Some((count, eligible)) = - resolve_non_self_discard_requirement(state, player, source_id, cost)? - { - let mut pending_discard = - PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); - pending_discard.activation_cost = Some(cost.clone()); - pending_discard.activation_ability_index = Some(ability_index); - return Ok(WaitingFor::PayCost { - player, - kind: PayCostKind::Discard, - choices: eligible, - count, - min_count: 0, - resume: CostResume::Spell { - spell: Box::new(pending_discard), - }, - }); - } + // CR 601.2h + CR 701.9a: A resolved zero-card FromHand discard leg (e.g. Bomat + // Courier's "Discard your hand" on an empty hand) is paid by doing nothing — the + // helper returns `Ok(None)` so we FALL THROUGH to the following cost detection + // rather than surfacing a dead `PayCost { count: 0 }`. + if let Some((count, eligible)) = + resolve_non_self_discard_requirement(state, player, source_id, cost)? + { + let mut pending_discard = + PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); + pending_discard.activation_cost = Some(cost.clone()); + pending_discard.activation_ability_index = Some(ability_index); + return Ok(WaitingFor::PayCost { + player, + kind: PayCostKind::Discard, + choices: eligible, + count, + min_count: 0, + resume: CostResume::Spell { + spell: Box::new(pending_discard), + }, + }); + } - // CR 701.59a + CR 602.2b: Pre-check for a collect-evidence activation - // cost (Kylox's Voltstrider — "Collect evidence 6: This Vehicle becomes - // an artifact creature ..."). Collect evidence is an INTERACTIVE cost: - // the player chooses which graveyard cards (total mana value >= N) to - // exile, so it must detour to `WaitingFor::CollectEvidenceChoice` and be - // paid BEFORE the ability reaches the stack — exactly like the - // ExileAggregate / non-self exile detours. Without this detour the cost - // is a silent no-op in `pay_ability_cost` (it is documented there as - // "intercepted before reaching pay_ability_cost"), so the ability would - // resolve for free. CR 701.59b payability was already enforced by the - // `is_payable` gate above; `begin_cost_payment` re-checks it defensively. - // The resume (`CollectEvidenceResume::Casting`, made activation-aware) - // pushes the activated ability to the stack once the cards are exiled. - // This is the SINGLE-AUTHORITY interactive-cost dispatch: the call site - // never inspects cost components beyond routing to the resolver. - if let Some(amount) = find_collect_evidence_activation_cost(cost) { - let mut pending = PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); - pending.activation_cost = Some(cost.clone()); - pending.activation_ability_index = Some(ability_index); - return super::effects::collect_evidence::begin_cost_payment( - state, - player, - amount, - pending, - SpellCostSource::Other, - ); - } + // CR 701.59a + CR 602.2b: Pre-check for a collect-evidence activation + // cost (Kylox's Voltstrider — "Collect evidence 6: This Vehicle becomes + // an artifact creature ..."). Collect evidence is an INTERACTIVE cost: + // the player chooses which graveyard cards (total mana value >= N) to + // exile, so it must detour to `WaitingFor::CollectEvidenceChoice` and be + // paid BEFORE the ability reaches the stack — exactly like the + // ExileAggregate / non-self exile detours. Without this detour the cost + // is a silent no-op in `pay_ability_cost` (it is documented there as + // "intercepted before reaching pay_ability_cost"), so the ability would + // resolve for free. CR 701.59b payability was already enforced by the + // `is_payable` gate above; `begin_cost_payment` re-checks it defensively. + // The resume (`CollectEvidenceResume::Casting`, made activation-aware) + // pushes the activated ability to the stack once the cards are exiled. + // This is the SINGLE-AUTHORITY interactive-cost dispatch: the call site + // never inspects cost components beyond routing to the resolver. + if let Some(amount) = find_collect_evidence_activation_cost(cost) { + let mut pending = + PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); + pending.activation_cost = Some(cost.clone()); + pending.activation_ability_index = Some(ability_index); + return super::effects::collect_evidence::begin_cost_payment( + state, + player, + amount, + pending, + SpellCostSource::Other, + ); + } - // CR 117.1 + CR 601.2b + CR 602.2b: Pre-check for an `ExileWithAggregate` - // cost (Baron Helmut Zemo's Boast — "Exile any number of black cards from - // your graveyard with fifteen or more black mana symbols among their mana - // costs"). The player chooses any subset of the eligible cards whose - // aggregate satisfies the threshold; the handler validates the threshold - // and (CR 608.2c) publishes the exiled cards as the tracked set the - // `CastCopyOfCard` effect consumes. The effect target is `TrackedSet` - // (resolution-time), not a declared target, so no target-selection - // detour is needed. - if let Some((filter, function, property, comparator, value, zone)) = - find_exile_with_aggregate_cost(cost) - { - let eligible = super::cost_payability::eligible_exile_with_aggregate_objects( - state, player, source_id, filter, zone, - ); - // CR 118.3: payability was pre-checked above; re-derive the maximal - // aggregate (exile-all) here so an unsatisfiable threshold fails fast. - let total = - super::quantity::aggregate_property_over(state, &eligible, function, property); - if !comparator.evaluate(total, value) { - return Err(EngineError::ActionNotAllowed( - "Not enough eligible cards to reach the exile threshold".into(), - )); + // CR 117.1 + CR 601.2b + CR 602.2b: Pre-check for an `ExileWithAggregate` + // cost (Baron Helmut Zemo's Boast — "Exile any number of black cards from + // your graveyard with fifteen or more black mana symbols among their mana + // costs"). The player chooses any subset of the eligible cards whose + // aggregate satisfies the threshold; the handler validates the threshold + // and (CR 608.2c) publishes the exiled cards as the tracked set the + // `CastCopyOfCard` effect consumes. The effect target is `TrackedSet` + // (resolution-time), not a declared target, so no target-selection + // detour is needed. + if let Some((filter, function, property, comparator, value, zone)) = + find_exile_with_aggregate_cost(cost) + { + let eligible = super::cost_payability::eligible_exile_with_aggregate_objects( + state, player, source_id, filter, zone, + ); + // CR 118.3: payability was pre-checked above; re-derive the maximal + // aggregate (exile-all) here so an unsatisfiable threshold fails fast. + let total = + super::quantity::aggregate_property_over(state, &eligible, function, property); + if !comparator.evaluate(total, value) { + return Err(EngineError::ActionNotAllowed( + "Not enough eligible cards to reach the exile threshold".into(), + )); + } + let mut pending_agg = + PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); + pending_agg.activation_cost = Some(cost.clone()); + pending_agg.activation_ability_index = Some(ability_index); + let max_count = eligible.len(); + return Ok(WaitingFor::PayCost { + player, + kind: PayCostKind::ExileAggregate { + zone, + function, + property, + comparator, + value, + filter: filter.clone(), + }, + choices: eligible, + count: max_count, + // CR 601.2b: "any number" reaching the threshold — the threshold + // (not a fixed cardinality) is enforced by the handler. A nonzero + // GE/Sum threshold can never be met by the empty set, so at least + // one card is required; `min_count: 1` is the loose lower bound. + min_count: 1, + resume: CostResume::Spell { + spell: Box::new(pending_agg), + }, + }); } - let mut pending_agg = - PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); - pending_agg.activation_cost = Some(cost.clone()); - pending_agg.activation_ability_index = Some(ability_index); - let max_count = eligible.len(); - return Ok(WaitingFor::PayCost { - player, - kind: PayCostKind::ExileAggregate { - zone, - function, - property, - comparator, - value, - filter: filter.clone(), - }, - choices: eligible, - count: max_count, - // CR 601.2b: "any number" reaching the threshold — the threshold - // (not a fixed cardinality) is enforced by the handler. A nonzero - // GE/Sum threshold can never be met by the empty set, so at least - // one card is required; `min_count: 1` is the loose lower bound. - min_count: 1, - resume: CostResume::Spell { - spell: Box::new(pending_agg), - }, - }); - } - // CR 118.3 + CR 602.2b: Pre-check for non-self exile-from-hand/graveyard - // costs. Untargeted abilities can detour to `WaitingFor::ExileForCost` - // immediately; targeted abilities must choose their effect targets first - // (CR 601.2c), then `casting_targets::pay_activation_costs_after_target_selection` - // surfaces this same cost prompt before the ability reaches the stack. - if let Some((count, zone, filter)) = find_non_self_exile(cost) { - let has_effect_targets = { - let slots = build_target_slots(state, &resolved)?; - !slots.is_empty() - }; - if !has_effect_targets { + // CR 118.3 + CR 602.2b: Pre-check for non-self exile-from-hand/graveyard + // costs. Untargeted abilities can detour to `WaitingFor::ExileForCost` + // immediately; targeted abilities must choose their effect targets first + // (CR 601.2c), then `casting_targets::pay_activation_costs_after_target_selection` + // surfaces this same cost prompt before the ability reaches the stack. + if let Some((count, zone, filter)) = find_non_self_exile(cost) { let narrow_zone = ExileCostSourceZone::try_from_zone(zone) .expect("find_non_self_exile restricts zone to Hand or Graveyard"); let eligible = find_eligible_exile_for_cost_targets( @@ -17431,227 +17486,235 @@ pub fn handle_activate_ability( }, }); } - } - // CR 702.167a/b: Pre-check for a craft materials cost — detour to - // `WaitingFor::PayCost { kind: ExileMaterials }` so the player selects - // which permanents/graveyard cards to exile across the dual-zone union. - // The full `Composite` cost (Mana + self-exile + materials) stays in - // `activation_cost`; the mana and self-exile are paid by - // `push_activated_ability_to_stack` after the selection completes - // (CR 601.2h: remaining costs paid in any order). Mirrors the non-self - // exile detour above. - if let Some((count, materials)) = find_craft_materials_cost(cost) { - let eligible = super::cost_payability::eligible_craft_materials( - state, player, source_id, materials, - ); - let min_count = count.min_count(); - let max_count = count.max_count(eligible.len()); - if eligible.len() < min_count { - return Err(EngineError::ActionNotAllowed( - "Not enough eligible materials to craft".into(), - )); + // CR 702.167a/b: Pre-check for a craft materials cost — detour to + // `WaitingFor::PayCost { kind: ExileMaterials }` so the player selects + // which permanents/graveyard cards to exile across the dual-zone union. + // The full `Composite` cost (Mana + self-exile + materials) stays in + // `activation_cost`; the mana and self-exile are paid by + // `push_activated_ability_to_stack` after the selection completes + // (CR 601.2h: remaining costs paid in any order). Mirrors the non-self + // exile detour above. + if let Some((count, materials)) = find_craft_materials_cost(cost) { + let eligible = super::cost_payability::eligible_craft_materials( + state, player, source_id, materials, + ); + let min_count = count.min_count(); + let max_count = count.max_count(eligible.len()); + if eligible.len() < min_count { + return Err(EngineError::ActionNotAllowed( + "Not enough eligible materials to craft".into(), + )); + } + let mut pending_craft = + PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); + pending_craft.activation_cost = Some(cost.clone()); + pending_craft.activation_ability_index = Some(ability_index); + return Ok(WaitingFor::PayCost { + player, + kind: PayCostKind::ExileMaterials { + materials: materials.clone(), + }, + choices: eligible, + count: max_count, + // CR 702.167a: "one or more" material costs set `min_count < count`; + // exact material costs set both bounds to the same value. + min_count, + resume: CostResume::Spell { + spell: Box::new(pending_craft), + }, + }); } - let mut pending_craft = - PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); - pending_craft.activation_cost = Some(cost.clone()); - pending_craft.activation_ability_index = Some(ability_index); - return Ok(WaitingFor::PayCost { - player, - kind: PayCostKind::ExileMaterials { - materials: materials.clone(), - }, - choices: eligible, - count: max_count, - // CR 702.167a: "one or more" material costs set `min_count < count`; - // exact material costs set both bounds to the same value. - min_count, - resume: CostResume::Spell { - spell: Box::new(pending_craft), - }, - }); - } - // CR 118.12a: Pre-check for OneOf costs — detour to WaitingFor before any cost payment. - if let Some(costs) = find_one_of_cost(cost) { - let payable = - payable_one_of_activation_branches(state, player, source_id, costs, ability_index); - if payable.is_empty() { - return Err(EngineError::ActionNotAllowed( - "Cannot pay activation cost".to_string(), - )); + // CR 118.12a: Pre-check for OneOf costs — detour to WaitingFor before any cost payment. + if let Some(costs) = find_one_of_cost(cost) { + let payable = payable_one_of_activation_branches( + state, + player, + source_id, + costs, + ability_index, + ); + if payable.is_empty() { + return Err(EngineError::ActionNotAllowed( + "Cannot pay activation cost".to_string(), + )); + } + let mut pending_one_of = + PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); + pending_one_of.activation_cost = Some(cost.clone()); + pending_one_of.activation_ability_index = Some(ability_index); + return Ok(WaitingFor::ActivationCostOneOfChoice { + player, + costs: payable, + pending_cast: Box::new(pending_one_of), + }); } - let mut pending_one_of = - PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); - pending_one_of.activation_cost = Some(cost.clone()); - pending_one_of.activation_ability_index = Some(ability_index); - return Ok(WaitingFor::ActivationCostOneOfChoice { - player, - costs: payable, - pending_cast: Box::new(pending_one_of), - }); - } - // CR 118.3: Pre-check for ReturnToHand costs — same WaitingFor detour pattern as - // Sacrifice above. Ordering matters for Composite costs: Sacrifice wins if both are - // present, but no real cards combine them. - if let Some((count, filter)) = find_return_to_hand_cost(cost) { - let eligible = find_eligible_return_to_hand_targets(state, player, source_id, filter); - if eligible.len() < count as usize { - return Err(EngineError::ActionNotAllowed( - "No eligible permanents to return".into(), - )); + // CR 118.3: Pre-check for ReturnToHand costs — same WaitingFor detour pattern as + // Sacrifice above. Ordering matters for Composite costs: Sacrifice wins if both are + // present, but no real cards combine them. + if let Some((count, filter)) = find_return_to_hand_cost(cost) { + let eligible = + find_eligible_return_to_hand_targets(state, player, source_id, filter); + if eligible.len() < count as usize { + return Err(EngineError::ActionNotAllowed( + "No eligible permanents to return".into(), + )); + } + let mut pending_return = + PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); + pending_return.activation_cost = Some(cost.clone()); + pending_return.activation_ability_index = Some(ability_index); + return Ok(WaitingFor::PayCost { + player, + kind: PayCostKind::ReturnToHand, + choices: eligible, + count: count as usize, + min_count: 0, + resume: CostResume::Spell { + spell: Box::new(pending_return), + }, + }); } - let mut pending_return = - PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); - pending_return.activation_cost = Some(cost.clone()); - pending_return.activation_ability_index = Some(ability_index); - return Ok(WaitingFor::PayCost { - player, - kind: PayCostKind::ReturnToHand, - choices: eligible, - count: count as usize, - min_count: 0, - resume: CostResume::Spell { - spell: Box::new(pending_return), - }, - }); - } - // CR 118.3 + CR 122.1 + CR 602.2b: Pre-check targeted - // remove-counter activation costs. The player chooses which matching - // permanent supplies the counter before automatic cost components are - // paid and the ability is put on the stack. - if let Some((count, counter_type, target, selection)) = - find_targeted_remove_counter_cost(cost) - { - let required_count = match selection { - CounterCostSelection::SingleObject => count, - CounterCostSelection::AmongObjects => 1, - }; - let eligible = find_eligible_remove_counter_for_cost_targets( - state, - player, - source_id, - target, - counter_type, - required_count, - ); - if eligible.is_empty() { - return Err(EngineError::ActionNotAllowed( - "No eligible permanents with counters".into(), - )); + // CR 118.3 + CR 122.1 + CR 602.2b: Pre-check targeted + // remove-counter activation costs. The player chooses which matching + // permanent supplies the counter before automatic cost components are + // paid and the ability is put on the stack. + if let Some((count, counter_type, target, selection)) = + find_targeted_remove_counter_cost(cost) + { + let required_count = match selection { + CounterCostSelection::SingleObject => count, + CounterCostSelection::AmongObjects => 1, + }; + let eligible = find_eligible_remove_counter_for_cost_targets( + state, + player, + source_id, + target, + counter_type, + required_count, + ); + if eligible.is_empty() { + return Err(EngineError::ActionNotAllowed( + "No eligible permanents with counters".into(), + )); + } + if selection == CounterCostSelection::AmongObjects { + let removable_count = eligible + .iter() + .filter_map(|object_id| state.objects.get(object_id)) + .map(|obj| { + removable_counter_count_for_cost_selection(obj, counter_type, selection) + }) + .fold(0, u32::saturating_add); + if removable_count < count { + return Err(EngineError::ActionNotAllowed( + "Not enough eligible counters to remove".into(), + )); + } + } + let mut pending_counter = + PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); + pending_counter.activation_cost = Some(cost.clone()); + pending_counter.activation_ability_index = Some(ability_index); + let max_count = match selection { + CounterCostSelection::SingleObject => 1, + CounterCostSelection::AmongObjects => eligible.len(), + }; + return Ok(WaitingFor::PayCost { + player, + kind: PayCostKind::RemoveCounter { + counter_type: counter_type.clone(), + count, + selection, + }, + choices: eligible, + count: max_count, + min_count: match selection { + CounterCostSelection::SingleObject => 0, + CounterCostSelection::AmongObjects => 1, + }, + resume: CostResume::Spell { + spell: Box::new(pending_counter), + }, + }); } - if selection == CounterCostSelection::AmongObjects { - let removable_count = eligible - .iter() - .filter_map(|object_id| state.objects.get(object_id)) - .map(|obj| { - removable_counter_count_for_cost_selection(obj, counter_type, selection) - }) - .fold(0, u32::saturating_add); - if removable_count < count { + + // CR 118.3: Pre-check for tap-creatures activation costs. Non-mana + // activated abilities use the same WaitingFor flow as flashback tap + // costs; completion resumes through `finish_pending_cost_or_cast`. + if let Some((requirement, filter)) = find_tap_creatures_cost(cost) { + // CR 602.1a: Activated-ability tap costs are fixed-count today + // (Convoke-style). The aggregate "total power N" form is reserved for + // Crew/Saddle/Teamwork, which are not dispatched through this path. + let count = requirement.fixed_count().ok_or_else(|| { + EngineError::ActionNotAllowed( + "Aggregate-power tap cost is not valid for this activation".into(), + ) + })?; + let eligible = + find_eligible_tap_creatures_for_cost(state, player, source_id, cost, filter); + if eligible.len() < count as usize { return Err(EngineError::ActionNotAllowed( - "Not enough eligible counters to remove".into(), + "Not enough eligible creatures to tap".into(), )); } + let mut pending_tap = + PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); + pending_tap.activation_cost = Some(cost.clone()); + pending_tap.activation_ability_index = Some(ability_index); + return Ok(WaitingFor::PayCost { + player, + kind: PayCostKind::TapCreatures { aggregate: None }, + choices: eligible, + count: count as usize, + min_count: 0, + resume: CostResume::Spell { + spell: Box::new(pending_tap), + }, + }); } - let mut pending_counter = - PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); - pending_counter.activation_cost = Some(cost.clone()); - pending_counter.activation_ability_index = Some(ability_index); - let max_count = match selection { - CounterCostSelection::SingleObject => 1, - CounterCostSelection::AmongObjects => eligible.len(), - }; - return Ok(WaitingFor::PayCost { - player, - kind: PayCostKind::RemoveCounter { - counter_type: counter_type.clone(), - count, - selection, - }, - choices: eligible, - count: max_count, - min_count: match selection { - CounterCostSelection::SingleObject => 0, - CounterCostSelection::AmongObjects => 1, - }, - resume: CostResume::Spell { - spell: Box::new(pending_counter), - }, - }); - } - // CR 118.3: Pre-check for tap-creatures activation costs. Non-mana - // activated abilities use the same WaitingFor flow as flashback tap - // costs; completion resumes through `finish_pending_cost_or_cast`. - if let Some((requirement, filter)) = find_tap_creatures_cost(cost) { - // CR 602.1a: Activated-ability tap costs are fixed-count today - // (Convoke-style). The aggregate "total power N" form is reserved for - // Crew/Saddle/Teamwork, which are not dispatched through this path. - let count = requirement.fixed_count().ok_or_else(|| { - EngineError::ActionNotAllowed( - "Aggregate-power tap cost is not valid for this activation".into(), - ) - })?; - let eligible = - find_eligible_tap_creatures_for_cost(state, player, source_id, cost, filter); - if eligible.len() < count as usize { - return Err(EngineError::ActionNotAllowed( - "Not enough eligible creatures to tap".into(), - )); + // CR 601.2c + CR 601.2h + CR 602.2b: For no-target activations, use + // the serialized residual dispatcher for interactive cost kinds not + // covered by the earlier specialized detours. Its selected-cost + // handlers remove exactly one leg before re-entering the payment + // boundary, including repeated and chosen-OneOf costs. + { + let mut pending_interactive = + PendingCast::new(source_id, CardId(0), resolved.clone(), ManaCost::NoCost); + pending_interactive.activation_cost = Some(cost.clone()); + pending_interactive.activation_ability_index = Some(ability_index); + if let Some(waiting_for) = + casting_costs::surface_next_unpaid_interactive_activation_cost( + state, + player, + &mut pending_interactive, + events, + )? + { + return Ok(waiting_for); + } } - let mut pending_tap = - PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); - pending_tap.activation_cost = Some(cost.clone()); - pending_tap.activation_ability_index = Some(ability_index); - return Ok(WaitingFor::PayCost { - player, - kind: PayCostKind::TapCreatures { aggregate: None }, - choices: eligible, - count: count as usize, - min_count: 0, - resume: CostResume::Spell { - spell: Box::new(pending_tap), - }, - }); - } - // CR 601.2c + CR 601.2h + CR 602.2b: For no-target activations, use - // the serialized residual dispatcher for interactive cost kinds not - // covered by the earlier specialized detours. Its selected-cost - // handlers remove exactly one leg before re-entering the payment - // boundary, including repeated and chosen-OneOf costs. - if build_target_slots(state, &resolved)?.is_empty() { - let mut pending_interactive = - PendingCast::new(source_id, CardId(0), resolved.clone(), ManaCost::NoCost); - pending_interactive.activation_cost = Some(cost.clone()); - pending_interactive.activation_ability_index = Some(ability_index); - if let Some(waiting_for) = - casting_costs::surface_next_unpaid_interactive_activation_cost( + // Waterbend cost: detour to ManaPayment with Waterbend mode. + if let Some(wb_cost) = find_waterbend_cost(cost) { + let mut pending_wb = + PendingCast::new(source_id, CardId(0), resolved, wb_cost.clone()); + pending_wb.activation_cost = Some(cost.clone()); + pending_wb.activation_ability_index = Some(ability_index); + state.pending_cast = Some(Box::new(pending_wb)); + return casting_costs::enter_payment_step( state, player, - &pending_interactive, - )? - { - return Ok(waiting_for); + Some(ConvokeMode::Waterbend), + events, + ); } } - - // Waterbend cost: detour to ManaPayment with Waterbend mode. - if let Some(wb_cost) = find_waterbend_cost(cost) { - let mut pending_wb = PendingCast::new(source_id, CardId(0), resolved, wb_cost.clone()); - pending_wb.activation_cost = Some(cost.clone()); - pending_wb.activation_ability_index = Some(ability_index); - state.pending_cast = Some(Box::new(pending_wb)); - return casting_costs::enter_payment_step( - state, - player, - Some(ConvokeMode::Waterbend), - events, - ); - } } let target_slots = build_target_slots(state, &resolved)?; @@ -17662,113 +17725,26 @@ pub fn handle_activate_ability( { let mut resolved = resolved; assign_targets_in_chain(state, &mut resolved, &targets)?; - - if let Some(ref cost) = ability_def.cost { - if variable_speed_payment_range(cost, effective_speed(state, player)).is_some() { - return Ok(begin_variable_speed_payment( - state, - player, - source_id, - resolved, - cost.clone(), - ability_index, - ActivationTargetSelection::Settled, - )); - } - stamp_self_ref_discard_cost_paid_object(state, source_id, &mut resolved, cost); - if let Some(waiting) = try_finalize_activation_mana_payment( - state, - player, - source_id, - ability_index, - &resolved, - cost, - ActivationTargetSelection::Settled, - events, - )? { - return Ok(waiting); - } - if let PaymentOutcome::Paused { remaining_cost } = pay_ability_cost_for_activation( - state, - player, - source_id, - cost, - Some(ability_index), - events, - )? { - let pending = pending_activation_after_cost_pause( - source_id, - resolved.clone(), - ability_index, - remaining_cost, - ); - if let Some(pending) = - casting_costs::attach_pending_cast_to_cost_move(state, Box::new(pending)) - { - state.pending_cast = Some(pending); - } - return Ok(state.waiting_for.clone()); - } - } - - let assigned_targets = flatten_targets_in_chain(&resolved); - emit_targeting_events(state, &assigned_targets, source_id, player, events); - - // CR 702.170b: plot's grant targets SelfRef (a context-ref), so - // `build_target_slots` yields no slot and plot never takes this target - // branch — it is intercepted in the no-target path below. Guard the - // invariant: a future plot variant reaching here would silently revert - // to the on-stack model, so relocate the intercept if this ever fires. - debug_assert!( - !is_plot_special_action(&ability_def), - "plot special action reached the target branch; SelfRef should suppress its target slot" - ); - - let entry_id = ObjectId(state.next_object_id); - state.next_object_id += 1; - - stack::push_to_stack( - state, - StackEntry { - id: entry_id, - source_id, - controller: player, - kind: StackEntryKind::ActivatedAbility { - source_id, - ability: Box::new(resolved), - }, - }, - events, - ); - - restrictions::record_ability_activation(state, source_id, ability_index); - // CR 117.1b: Priority permits unbounded activation. `pending_activations` - // is a per-priority-window AI-guard — see `GameState::pending_activations`. - state.pending_activations.push((source_id, ability_index)); - events.push(GameEvent::AbilityActivated { - player_id: player, - source_id, - // CR 606.2: Classify loyalty vs. normal from the source ability cost. - kind: super::planeswalker::activated_ability_kind(state, source_id, ability_index), - }); - // CR 702.142b: Emit additional event when a boast ability is activated. - super::casting_targets::emit_keyword_ability_event_if_tagged( + // CR 602.2b + CR 601.2c: automatic target selection still + // declares targets before any activation cost is paid. + emit_targeting_events( state, + &flatten_targets_in_chain(&resolved), source_id, - ability_index, player, events, ); - priority::clear_priority_passes(state); - return Ok(WaitingFor::Priority { player }); + let mut pending = PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); + pending.activation_cost = ability_def.cost.clone(); + pending.activation_ability_index = Some(ability_index); + pending.target_constraints = target_constraints; + pending.distribute = ability_def.distribute.clone(); + pending.begin_activation_trigger_collection(); + return casting_costs::finish_target_selected_activated_ability_at_payment_boundary( + state, player, pending, events, + ); } - let selection = begin_target_selection_for_ability( - state, - &resolved, - &target_slots, - &target_constraints, - )?; let mut pending_target = PendingCast::new( source_id, CardId(0), @@ -17783,13 +17759,13 @@ pub fn handle_activate_ability( // America's Throw) reaches the `DistributeAmong` step after its costs are // paid. Mirrors the spell target-selection path (`pending_targets.distribute`). pending_target.distribute = ability_def.distribute.clone(); - return Ok(WaitingFor::TargetSelection { + return super::casting_targets::begin_activated_target_selection( + state, player, - pending_cast: Box::new(pending_target), + pending_target, target_slots, - mode_labels: Vec::new(), - selection, - }); + Vec::new(), + ); } if let Some(ref cost) = ability_def.cost { diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index 3014ee38a5..fb10c8f5a8 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -1588,6 +1588,22 @@ pub(crate) fn begin_deferred_target_selection( let mut ability = pending.ability.clone(); assign_targets_in_chain(state, &mut ability, &targets)?; pending.ability = ability; + if pending.activation_ability_index.is_some() { + // CR 602.2b + CR 601.2c: automatic target declaration remains + // before the activation's payment boundary, including after X was + // announced through this deferred route. + super::casting::emit_targeting_events( + state, + &flatten_targets_in_chain(&pending.ability), + pending.object_id, + pending.ability.controller, + events, + ); + pending.begin_activation_trigger_collection(); + return finish_target_selected_activated_ability_at_payment_boundary( + state, player, pending, events, + ); + } return finish_pending_cost_or_cast(state, player, pending, events); } if let Some(targets) = auto_select_targets_for_ability( @@ -1599,9 +1615,35 @@ pub(crate) fn begin_deferred_target_selection( let mut ability = pending.ability.clone(); assign_targets_in_chain(state, &mut ability, &targets)?; pending.ability = ability; + if pending.activation_ability_index.is_some() { + // CR 602.2b + CR 601.2c: automatic target declaration remains + // before the activation's payment boundary, including after X was + // announced through this deferred route. + super::casting::emit_targeting_events( + state, + &flatten_targets_in_chain(&pending.ability), + pending.object_id, + pending.ability.controller, + events, + ); + pending.begin_activation_trigger_collection(); + return finish_target_selected_activated_ability_at_payment_boundary( + state, player, pending, events, + ); + } return finish_pending_cost_or_cast(state, player, pending, events); } + if pending.activation_ability_index.is_some() { + return super::casting_targets::begin_activated_target_selection( + state, + player, + pending, + target_slots, + mode_labels, + ); + } + let selection = begin_target_selection_for_ability( state, &pending.ability, @@ -1721,6 +1763,7 @@ pub(crate) fn handle_discard_for_cost( let cost_event_end = events.len(); if pending.activation_ability_index.is_some() { + pending.mark_activation_cost_committed(); pending.activation_cost = pending .activation_cost .take() @@ -1767,12 +1810,22 @@ fn park_cost_payment_triggers_if_paused( cost_event_end: usize, waiting_for: &WaitingFor, ) { - if !matches!(waiting_for, WaitingFor::Priority { .. }) { - let cost_events: Vec = events[cost_event_start..cost_event_end] - .iter() - .filter(|ev| !matches!(ev, GameEvent::PhaseChanged { .. })) - .cloned() - .collect(); + if matches!(waiting_for, WaitingFor::Priority { .. }) { + return; + } + + let cost_events: Vec = events[cost_event_start..cost_event_end] + .iter() + .filter(|ev| !matches!(ev, GameEvent::PhaseChanged { .. })) + .cloned() + .collect(); + if let Some(mut collection) = state.take_pending_activation_trigger_collection() { + // CR 602.2b + CR 603.3b: A target-first activation owns cost-trigger + // collection until its stack entry exists, even when a later payment + // prompt has parked the PendingCast between cost components. + collection.collect(state, &cost_events); + state.restore_pending_activation_trigger_collection(collection); + } else { crate::game::triggers::collect_triggers_into_deferred(state, &cost_events); } } @@ -2111,6 +2164,7 @@ pub(crate) fn resume_interrupted_cost_payment( } } if pending.activation_ability_index.is_some() { + pending.mark_activation_cost_committed(); pending.activation_cost = pending .activation_cost .take() @@ -2203,7 +2257,7 @@ pub(crate) fn handle_activation_cost_one_of_choice( } if let Some(waiting_for) = - surface_next_unpaid_interactive_activation_cost(state, player, &pending)? + surface_next_unpaid_interactive_activation_cost(state, player, &mut pending, events)? { return Ok(waiting_for); } @@ -2547,11 +2601,24 @@ fn pause_sacrifice_for_cost( /// pipeline cannot collect the same events again. fn settle_sacrifice_for_cost_events( state: &mut GameState, + pending: &mut PendingCast, mut deferred_cost_events: Vec, events: &[GameEvent], current_start: usize, current_end: usize, ) { + if let Some(collection) = pending.activation_trigger_collection.as_mut() { + // CR 602.2b + CR 603.2: an announced target-bearing activation owns + // replacement-paused cost events until its stack commit. Earlier action + // fragments are not present in this action's event buffer, while the + // current fragment is collected once by the eventual stack boundary (or + // the next pending-action staging pass). + if !deferred_cost_events.is_empty() { + collection.collect(state, &deferred_cost_events); + } + return; + } + deferred_cost_events.extend_from_slice(&events[current_start..current_end]); if !deferred_cost_events.is_empty() { crate::game::triggers::collect_triggers_into_deferred(state, &deferred_cost_events); @@ -2615,22 +2682,29 @@ fn finish_sacrifice_for_cost( // before a later cast/activation prompt can hide this action's event span. settle_sacrifice_for_cost_events( state, + &mut pending, deferred_cost_events, events, current_start, current_end, ); - if matches!(completion, PendingSacrificeCostCompletion::SelectedNonSelf) - && pending.activation_ability_index.is_some() - { - pending.activation_cost = pending - .activation_cost - .take() - .and_then(super::casting::remove_selected_non_self_sacrifice_cost); + if pending.activation_ability_index.is_some() { + pending.mark_activation_cost_committed(); + if matches!(completion, PendingSacrificeCostCompletion::SelectedNonSelf) { + pending.activation_cost = pending + .activation_cost + .take() + .and_then(super::casting::remove_selected_non_self_sacrifice_cost); + } } - finish_pending_cost_or_cast(state, player, pending, events) + let waiting_for = finish_pending_cost_or_cast(state, player, pending, events)?; + // CR 602.2b + CR 603.3b: The replacement-resumed sacrifice can itself + // reach a later payment prompt. Park this action's final event fragment in + // the same activation-local transaction as the earlier fragments. + park_cost_payment_triggers_if_paused(state, events, current_start, events.len(), &waiting_for); + Ok(waiting_for) } /// CR 601.2h + CR 602.2b + CR 616.1: Continue the exact unpaid suffix of a @@ -2889,6 +2963,7 @@ pub(crate) fn handle_sacrifice_for_cost( ); if pending.activation_ability_index.is_some() { + pending.mark_activation_cost_committed(); pending.activation_cost = pending .activation_cost .take() @@ -2896,14 +2971,13 @@ pub(crate) fn handle_sacrifice_for_cost( } let waiting_for = finish_pending_cost_or_cast(state, player, pending, events)?; - if !matches!(waiting_for, WaitingFor::Priority { .. }) { - let cost_events: Vec = events[cost_event_start..] - .iter() - .filter(|event| !matches!(event, GameEvent::PhaseChanged { .. })) - .cloned() - .collect(); - crate::game::triggers::collect_triggers_into_deferred(state, &cost_events); - } + park_cost_payment_triggers_if_paused( + state, + events, + cost_event_start, + events.len(), + &waiting_for, + ); Ok(waiting_for) } @@ -2990,6 +3064,7 @@ pub(crate) fn handle_unattach_for_cost( }); } } + pending.mark_activation_cost_committed(); // CR 601.2h: This handler paid exactly one interactive `UnattachFrom` leg. // Keep only the unpaid suffix so a later mana-leg root cannot replay the @@ -3125,6 +3200,7 @@ pub(crate) fn handle_return_to_hand_for_cost( .is_some_and(|obj| obj.zone != Zone::Hand) }) .collect(); + pending.mark_activation_cost_committed(); finish_cost_object_moves( state, player, @@ -3232,6 +3308,8 @@ pub(crate) fn handle_remove_counter_for_cost( }); } + pending.mark_activation_cost_committed(); + if let Some(ability_index) = pending.activation_ability_index { if let Some(cost) = pending.activation_cost.take() { // CR 601.2h + CR 602.2b: Counter selection is already committed, so @@ -3405,6 +3483,8 @@ pub(crate) fn handle_remove_counter_distribution_for_cost( } } + pending.mark_activation_cost_committed(); + if let Some(ability_index) = pending.activation_ability_index { if let Some(cost) = pending.activation_cost.take() { // CR 601.2h + CR 602.2b: The assigned counter payment is complete @@ -3499,6 +3579,8 @@ pub(crate) fn handle_blight_choice( return Ok(state.waiting_for.clone()); } + pending.mark_activation_cost_committed(); + finish_pending_cost_or_cast(state, player, pending, events) } @@ -3512,7 +3594,7 @@ pub(crate) fn handle_blight_choice( pub(crate) fn handle_cost_type_choice( state: &mut GameState, player: PlayerId, - pending: PendingCast, + mut pending: PendingCast, options: &[String], choice: &str, events: &mut Vec, @@ -3530,9 +3612,15 @@ pub(crate) fn handle_cost_type_choice( choice.to_string(), )); } - // The behold cost was stashed in `additional_cost_flow` when the choice was - // raised; resume it now (the object carries the chosen type, so the behold - // dispatch proceeds past the type prompt to the behold selection). + if pending.activation_ability_index.is_some() { + if let Some(waiting_for) = + surface_next_unpaid_interactive_activation_cost(state, player, &mut pending, events)? + { + return Ok(waiting_for); + } + } + // Spell additional costs stash behold in `additional_cost_flow`; activation + // costs retain it in their serialized residual for the shared dispatcher. finish_pending_cost_or_cast(state, player, pending, events) } @@ -3690,6 +3778,7 @@ pub(crate) fn handle_behold_for_cost( if let Some(snapshot) = snapshot { pending.ability.set_cost_paid_object_recursive(snapshot); } + pending.mark_activation_cost_committed(); return finish_cost_object_moves( state, player, @@ -3714,6 +3803,7 @@ pub(crate) fn handle_behold_for_cost( if let Some(snapshot) = snapshot { pending.ability.set_cost_paid_object_recursive(snapshot); } + pending.mark_activation_cost_committed(); finish_pending_cost_or_cast(state, player, pending, events) } @@ -3952,6 +4042,7 @@ fn finish_exile_selection_for_cost( pending.ability.add_cost_paid_object_ids_recursive(chosen); if pending.activation_ability_index.is_some() { + pending.mark_activation_cost_committed(); pending.activation_cost = pending .activation_cost .take() @@ -4151,6 +4242,21 @@ pub(crate) fn finish_activated_ability_at_payment_boundary( ); } + // CR 107.1b + CR 601.2f-h: target declaration may have deferred a + // targeted remove-X-counters cost. Route that exact residual back through + // `enter_payment_step`, its single authority for concretizing X and + // selecting the counter source, before any generic cost payment can see + // the symbolic sentinel. + if pending.ability.chosen_x.is_some() + && pending + .activation_cost + .as_ref() + .is_some_and(cost_has_targeted_symbolic_counter_removal) + { + state.pending_cast = Some(Box::new(pending)); + return enter_payment_step(state, player, None, events); + } + let should_finalize_mana_leg = !matches!( pending.activation_residual, ActivationResidual::ManaLeg | ActivationResidual::XMana @@ -4186,6 +4292,7 @@ pub(crate) fn finish_activated_ability_at_payment_boundary( pending.activation_residual, pending.activation_target_selection, pending.pending_loyalty_activation_player, + pending.activation_trigger_collection.clone(), events, ) } @@ -4207,14 +4314,47 @@ pub(crate) fn finish_target_selected_activated_ability_at_payment_boundary( finish_activated_ability_at_payment_boundary(state, player, pending, events) } +/// Identifies which target-first activation handoff is deciding whether to +/// surface an interactive residual before returning to the payment authority. +#[derive(Clone, Copy)] +pub(crate) enum TargetFirstPaymentHandoff { + BeforeManaPayment, + AfterManaPayment, +} + +/// CR 601.2c + CR 601.2g-h + CR 602.2b: Targeted activations whose handoff +/// must process an unpaid mana leg or bind announced X defer their interactive +/// residual until that common boundary has run. +/// +/// This is deliberately limited to target-first handoffs. The shared +/// interactive-cost dispatcher also resumes ordinary activation and +/// craft/material flows, which must retain their established routing. +pub(crate) fn target_first_activation_defers_interactive_costs_to_payment_boundary( + pending: &PendingCast, + handoff: TargetFirstPaymentHandoff, +) -> bool { + let Some(cost) = pending.activation_cost.as_ref() else { + return false; + }; + + (matches!(handoff, TargetFirstPaymentHandoff::BeforeManaPayment) + && extract_mana_leg(cost).is_some_and(|(mana_cost, _)| !mana_cost.is_without_paying_mana()) + // CR 601.2g-h: This established class keeps non-self battlefield + // removal after the mana window. Other interactive residuals (such as + // exile from hand and unattach) retain their dispatcher order. + && super::casting::find_non_self_battlefield_removal_cost(cost).is_some()) + || (pending.ability.chosen_x.is_some() && cost_has_targeted_symbolic_counter_removal(cost)) +} + /// CR 118.3 + CR 601.2h + CR 602.2b: Surface exactly the next unpaid /// interactive activation-cost component from the serialized residual. Every /// completed handler removes one matching leg, so repeated components and a /// chosen `OneOf` branch naturally re-enter here until no selection remains. pub(crate) fn surface_next_unpaid_interactive_activation_cost( - state: &GameState, + state: &mut GameState, player: PlayerId, - pending: &PendingCast, + pending: &mut PendingCast, + events: &mut Vec, ) -> Result, EngineError> { let Some(cost) = pending.activation_cost.as_ref() else { return Ok(None); @@ -4240,6 +4380,25 @@ pub(crate) fn surface_next_unpaid_interactive_activation_cost( })); } + if let Some(amount) = super::casting::find_collect_evidence_activation_cost(cost) { + let mut pending = pending.clone(); + pending.activation_cost = remove_first_activation_cost_matching( + pending + .activation_cost + .take() + .expect("checked activation cost is present"), + |cost| matches!(cost, AbilityCost::CollectEvidence { .. }), + ); + return super::effects::collect_evidence::begin_cost_payment( + state, + player, + amount, + pending, + SpellCostSource::Other, + ) + .map(Some); + } + if let Some((count, sacrifice_filter)) = super::casting::find_non_self_sacrifice_cost(cost) { let eligible = super::casting::find_eligible_sacrifice_targets( state, @@ -4288,6 +4447,99 @@ pub(crate) fn surface_next_unpaid_interactive_activation_cost( })); } + if let Some((filter, function, property, comparator, value, zone)) = + super::casting::find_exile_with_aggregate_cost(cost) + { + let eligible = super::cost_payability::eligible_exile_with_aggregate_objects( + state, player, source_id, filter, zone, + ); + let total = super::quantity::aggregate_property_over(state, &eligible, function, property); + if !comparator.evaluate(total, value) { + return Err(EngineError::ActionNotAllowed( + "Not enough eligible cards to reach the exile threshold".into(), + )); + } + let mut pending = pending.clone(); + pending.activation_cost = remove_first_activation_cost_matching( + pending + .activation_cost + .take() + .expect("checked activation cost is present"), + |cost| matches!(cost, AbilityCost::ExileWithAggregate { .. }), + ); + return Ok(Some(WaitingFor::PayCost { + player, + kind: PayCostKind::ExileAggregate { + zone, + function, + property, + comparator, + value, + filter: filter.clone(), + }, + choices: eligible.clone(), + count: eligible.len(), + min_count: 1, + resume: CostResume::Spell { + spell: Box::new(pending), + }, + })); + } + + if let Some((count, materials)) = super::casting::find_craft_materials_cost(cost) { + let eligible = + super::cost_payability::eligible_craft_materials(state, player, source_id, materials); + let min_count = count.min_count(); + let max_count = count.max_count(eligible.len()); + if eligible.len() < min_count { + return Err(EngineError::ActionNotAllowed( + "Not enough eligible materials to craft".into(), + )); + } + let mut pending = pending.clone(); + pending.activation_cost = remove_first_activation_cost_matching( + pending + .activation_cost + .take() + .expect("checked activation cost is present"), + |cost| matches!(cost, AbilityCost::ExileMaterials { .. }), + ); + return Ok(Some(WaitingFor::PayCost { + player, + kind: PayCostKind::ExileMaterials { + materials: materials.clone(), + }, + choices: eligible, + count: max_count, + min_count, + resume: CostResume::Spell { + spell: Box::new(pending), + }, + })); + } + + if let Some(costs) = super::casting::find_one_of_cost(cost) { + let payable = super::casting::payable_one_of_activation_branches( + state, + player, + source_id, + costs, + pending + .activation_ability_index + .expect("activation cost dispatcher requires an ability index"), + ); + if payable.is_empty() { + return Err(EngineError::ActionNotAllowed( + "Cannot pay activation cost".to_string(), + )); + } + return Ok(Some(WaitingFor::ActivationCostOneOfChoice { + player, + costs: payable, + pending_cast: Box::new(pending.clone()), + })); + } + if let Some((count, exile_filter)) = super::casting::find_battlefield_exile_cost(cost) { let effective_filter = super::cost_payability::exile_cost_effective_filter(Some(exile_filter)); @@ -4319,8 +4571,14 @@ pub(crate) fn surface_next_unpaid_interactive_activation_cost( } if let Some((count, filter)) = super::casting::find_unattach_from_cost(cost) { + let min_mana_value = + super::ability_utils::distribution_targets(&pending.ability).len() as u32; let eligible = super::casting::find_eligible_unattach_for_cost_targets( - state, player, source_id, filter, 0, + state, + player, + source_id, + filter, + min_mana_value, ); if eligible.len() < count as usize { return Err(EngineError::ActionNotAllowed( @@ -4348,24 +4606,437 @@ pub(crate) fn surface_next_unpaid_interactive_activation_cost( super::casting::find_eligible_return_to_hand_targets(state, player, source_id, filter); if eligible.len() < count as usize { return Err(EngineError::ActionNotAllowed( - "No eligible permanents to return".into(), + "No eligible permanents to return".into(), + )); + } + return Ok(Some(WaitingFor::PayCost { + player, + kind: PayCostKind::ReturnToHand, + choices: eligible, + count: count as usize, + min_count: 0, + resume: CostResume::Spell { + spell: Box::new(pending.clone()), + }, + })); + } + + if let Some((count, counter_type, target, selection)) = + super::casting::find_targeted_remove_counter_cost(cost) + { + let required_count = match selection { + CounterCostSelection::SingleObject => count, + CounterCostSelection::AmongObjects => 1, + }; + let eligible = super::casting::find_eligible_remove_counter_for_cost_targets( + state, + player, + source_id, + target, + counter_type, + required_count, + ); + if eligible.is_empty() { + return Err(EngineError::ActionNotAllowed( + "No eligible permanents with counters".into(), + )); + } + if selection == CounterCostSelection::AmongObjects { + let removable_count = eligible + .iter() + .filter_map(|object_id| state.objects.get(object_id)) + .map(|obj| { + super::casting::removable_counter_count_for_cost_selection( + obj, + counter_type, + selection, + ) + }) + .fold(0, u32::saturating_add); + if removable_count < count { + return Err(EngineError::ActionNotAllowed( + "Not enough eligible counters to remove".into(), + )); + } + } + let mut pending = pending.clone(); + pending.activation_cost = remove_first_activation_cost_matching( + pending + .activation_cost + .take() + .expect("checked activation cost is present"), + |cost| { + matches!( + cost, + AbilityCost::RemoveCounter { + target: Some(_), + .. + } + ) + }, + ); + let max_count = match selection { + CounterCostSelection::SingleObject => 1, + CounterCostSelection::AmongObjects => eligible.len(), + }; + return Ok(Some(WaitingFor::PayCost { + player, + kind: PayCostKind::RemoveCounter { + counter_type: counter_type.clone(), + count, + selection, + }, + choices: eligible, + count: max_count, + min_count: match selection { + CounterCostSelection::SingleObject => 0, + CounterCostSelection::AmongObjects => 1, + }, + resume: CostResume::Spell { + spell: Box::new(pending), + }, + })); + } + + if let Some((requirement, filter)) = super::casting::find_tap_creatures_cost(cost) { + let count = requirement.fixed_count().ok_or_else(|| { + EngineError::ActionNotAllowed( + "Aggregate-power tap cost is not valid for this activation".into(), + ) + })?; + let eligible = super::casting::find_eligible_tap_creatures_for_cost( + state, player, source_id, cost, filter, + ); + if eligible.len() < count as usize { + return Err(EngineError::ActionNotAllowed( + "Not enough eligible creatures to tap".into(), + )); + } + let mut pending = pending.clone(); + pending.activation_cost = remove_first_activation_cost_matching( + pending + .activation_cost + .take() + .expect("checked activation cost is present"), + |cost| matches!(cost, AbilityCost::TapCreatures { .. }), + ); + return Ok(Some(WaitingFor::PayCost { + player, + kind: PayCostKind::TapCreatures { aggregate: None }, + choices: eligible, + count: count as usize, + min_count: 0, + resume: CostResume::Spell { + spell: Box::new(pending), + }, + })); + } + + if let Some(AbilityCost::Mill { count }) = + first_activation_cost_component_matching(cost, |cost| { + matches!(cost, AbilityCost::Mill { .. }) + }) + { + let count = *count; + pending.activation_cost = remove_first_activation_cost_matching( + pending + .activation_cost + .take() + .expect("checked activation cost is present"), + |cost| matches!(cost, AbilityCost::Mill { .. }), + ); + pending.mark_activation_cost_committed(); + let proposed = crate::types::proposed_event::ProposedEvent::Mill { + player_id: player, + count, + destination: Zone::Graveyard, + applied: Default::default(), + }; + match super::replacement::replace_event(state, proposed, events) { + super::replacement::ReplacementResult::Execute(event) => { + if super::effects::mill::apply_mill_after_replacement(state, event, events) + .map_err(|error| { + EngineError::InvalidAction(format!( + "Mill cost could not be paid: {error:?}" + )) + })? + { + return surface_next_unpaid_interactive_activation_cost( + state, player, pending, events, + ); + } + } + // CR 701.17b: A prevented mill or a library with too few cards still + // pays a mill cost by milling as many cards as possible. + super::replacement::ReplacementResult::Prevented => { + return surface_next_unpaid_interactive_activation_cost( + state, player, pending, events, + ); + } + super::replacement::ReplacementResult::NeedsChoice(choosing_player) => { + state.waiting_for = + super::replacement::replacement_choice_waiting_for(choosing_player, state); + } + } + state.pending_cost_move_resume = Some(PendingCostMoveResume::ActivationMillPayment { + player, + pending: Box::new(pending.clone()), + }); + return Ok(Some(state.waiting_for.clone())); + } + + if let Some(AbilityCost::Blight { count }) = + first_activation_cost_component_matching(cost, |cost| { + matches!(cost, AbilityCost::Blight { .. }) + }) + { + let creatures: Vec = state + .battlefield + .iter() + .copied() + .filter(|id| { + state.objects.get(id).is_some_and(|obj| { + obj.controller == player + && obj.card_types.core_types.contains(&CoreType::Creature) + }) + }) + .collect(); + if creatures.is_empty() { + return Err(EngineError::ActionNotAllowed( + "No creature to blight".to_string(), + )); + } + let mut pending = pending.clone(); + pending.activation_cost = remove_first_activation_cost_matching( + pending + .activation_cost + .take() + .expect("checked activation cost is present"), + |cost| matches!(cost, AbilityCost::Blight { .. }), + ); + return Ok(Some(WaitingFor::BlightChoice { + player, + counters: *count, + creatures, + pending_cast: Box::new(pending), + })); + } + + if let Some(AbilityCost::Behold { + count, + filter, + action, + type_choice, + }) = first_activation_cost_component_matching(cost, |cost| { + matches!(cost, AbilityCost::Behold { .. }) + }) { + if let Some(choice_type) = type_choice { + let already_chosen = state.objects.get(&source_id).is_some_and(|obj| { + obj.chosen_attributes.iter().any(|attribute| { + matches!( + attribute, + crate::types::ability::ChosenAttribute::CreatureType(_) + ) + }) + }); + if !already_chosen { + let options = super::filter::feasible_behold_creature_types( + state, player, source_id, filter, *count, + ); + if options.is_empty() { + return Err(EngineError::ActionNotAllowed( + "No creature type is feasible to behold".to_string(), + )); + } + return Ok(Some(WaitingFor::CostTypeChoice { + player, + choice_type: choice_type.clone(), + options, + pending_cast: Box::new(pending.clone()), + })); + } + } + let choices = eligible_behold_choices(state, player, source_id, filter); + if choices.len() < *count as usize { + return Err(EngineError::ActionNotAllowed( + "No eligible object to behold".to_string(), )); } + let mut pending = pending.clone(); + pending.activation_cost = remove_first_activation_cost_matching( + pending + .activation_cost + .take() + .expect("checked activation cost is present"), + |cost| matches!(cost, AbilityCost::Behold { .. }), + ); return Ok(Some(WaitingFor::PayCost { player, - kind: PayCostKind::ReturnToHand, - choices: eligible, - count: count as usize, + kind: PayCostKind::Behold { action: *action }, + choices, + count: *count as usize, min_count: 0, resume: CostResume::Spell { - spell: Box::new(pending.clone()), + spell: Box::new(pending), }, })); } + if let Some(AbilityCost::Reveal { count, filter }) = + first_activation_cost_component_matching(cost, |cost| { + matches!(cost, AbilityCost::Reveal { .. }) + }) + { + if let Some(filter) = filter { + let choices = + super::casting::find_eligible_reveal_targets(state, player, source_id, filter); + if choices.len() < *count as usize { + return Err(EngineError::ActionNotAllowed( + "Not enough eligible cards in hand to reveal".to_string(), + )); + } + let mut pending = pending.clone(); + pending.activation_cost = remove_first_activation_cost_matching( + pending + .activation_cost + .take() + .expect("checked activation cost is present"), + |cost| matches!(cost, AbilityCost::Reveal { .. }), + ); + return Ok(Some(WaitingFor::PayCost { + player, + kind: PayCostKind::Reveal, + choices, + count: *count as usize, + min_count: 0, + resume: CostResume::Spell { + spell: Box::new(pending), + }, + })); + } + + if let Some(obj) = state.objects.get(&source_id) { + pending + .ability + .set_cost_paid_object_recursive(CostPaidObjectSnapshot { + object_id: source_id, + lki: obj.snapshot_for_mana_spent(), + }); + events.push(GameEvent::CardsRevealed { + player, + card_ids: vec![source_id], + card_names: vec![obj.name.clone()], + }); + } + pending.activation_cost = remove_first_activation_cost_matching( + pending + .activation_cost + .take() + .expect("checked activation cost is present"), + |cost| matches!(cost, AbilityCost::Reveal { .. }), + ); + pending.mark_activation_cost_committed(); + return surface_next_unpaid_interactive_activation_cost(state, player, pending, events); + } + + if let Some((waterbend, residual)) = extract_waterbend_activation_cost(cost) { + let mut pending = pending.clone(); + pending.cost = waterbend; + pending.activation_cost = residual; + state.pending_cast = Some(Box::new(pending)); + return enter_payment_step(state, player, Some(ConvokeMode::Waterbend), events).map(Some); + } + Ok(None) } +fn first_activation_cost_component_matching( + cost: &AbilityCost, + predicate: impl Fn(&AbilityCost) -> bool + Copy, +) -> Option<&AbilityCost> { + if predicate(cost) { + return Some(cost); + } + let AbilityCost::Composite { costs } = cost else { + return None; + }; + costs + .iter() + .find_map(|cost| first_activation_cost_component_matching(cost, predicate)) +} + +/// CR 602.2b + CR 701.17a + CR 616.1: Resume an activation after its mill +/// cost's replacement pipeline reaches a terminal outcome. The residual has +/// already had precisely that `Mill` leg removed before it was parked. +pub(crate) fn resume_activation_mill_cost_payment( + state: &mut GameState, + events: &mut Vec, +) -> Result { + let Some(PendingCostMoveResume::ActivationMillPayment { + player, + mut pending, + }) = state.pending_cost_move_resume.take() + else { + unreachable!("matched an activation mill cost continuation") + }; + if let Some(waiting_for) = + surface_next_unpaid_interactive_activation_cost(state, player, &mut pending, events)? + { + return Ok(waiting_for); + } + finish_pending_cost_or_cast(state, player, *pending, events) +} + +fn remove_first_activation_cost_matching( + cost: AbilityCost, + predicate: impl Fn(&AbilityCost) -> bool + Copy, +) -> Option { + remove_first_activation_cost_matching_inner(cost, predicate).0 +} + +fn remove_first_activation_cost_matching_inner( + cost: AbilityCost, + predicate: impl Fn(&AbilityCost) -> bool + Copy, +) -> (Option, bool) { + if predicate(&cost) { + return (None, true); + } + let AbilityCost::Composite { costs } = cost else { + return (Some(cost), false); + }; + let mut removed = false; + let mut remaining = Vec::with_capacity(costs.len()); + for cost in costs { + if removed { + remaining.push(cost); + continue; + } + let (cost, did_remove) = remove_first_activation_cost_matching_inner(cost, predicate); + removed = did_remove; + if let Some(cost) = cost { + remaining.push(cost); + } + } + let cost = match remaining.len() { + 0 => None, + 1 => remaining.into_iter().next(), + _ => Some(AbilityCost::Composite { costs: remaining }), + }; + (cost, removed) +} + +fn extract_waterbend_activation_cost( + cost: &AbilityCost, +) -> Option<(ManaCost, Option)> { + let waterbend = super::casting::find_waterbend_cost(cost)?.clone(); + Some(( + waterbend, + remove_first_activation_cost_matching(cost.clone(), |cost| { + matches!(cost, AbilityCost::Waterbend { .. }) + }), + )) +} + /// Push an activated ability to the stack after costs are paid. /// Shared by: direct path in `handle_activate_ability`, sacrifice detour, and /// waterbend/ManaPayment finalization in the PassPriority handler. @@ -4383,8 +5054,86 @@ pub(super) fn push_activated_ability_to_stack( activation_residual: ActivationResidual, target_selection: ActivationTargetSelection, mut pending_loyalty_activation_player: Option, + activation_trigger_collection: Option>, events: &mut Vec, ) -> Result { + // CR 602.2b + CR 601.2c-h: This is also a defensive entry point for + // resumed activation roots. If a caller still has both unchosen targets and + // an unpaid cost suffix, route it through the same target-first transaction + // as `handle_activate_ability`; never pay the suffix and reopen targets. + if !matches!(target_selection, ActivationTargetSelection::Settled) { + let target_slots = build_target_slots(state, &resolved)?; + let assigned_targets = flatten_targets_in_chain(&resolved); + if !target_slots.is_empty() { + let pending = |resolved: ResolvedAbility| { + let mut pending = + PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); + pending.activation_cost = remaining_cost.cloned(); + pending.activation_ability_index = Some(ability_index); + pending.pending_loyalty_activation_player = pending_loyalty_activation_player; + pending.activation_trigger_collection = activation_trigger_collection.clone(); + pending + }; + + // A fully assigned or divided target set may arrive from a resumed + // root. It is still announced before payment, not pushed directly. + if assigned_targets.len() >= target_slots.len() || resolved.distribution.is_some() { + let mut pending = pending(resolved); + pending.begin_activation_trigger_collection(); + emit_targeting_events(state, &assigned_targets, source_id, player, events); + return finish_target_selected_activated_ability_at_payment_boundary( + state, player, pending, events, + ); + } + + if matches!( + resolved.target_selection_mode, + crate::types::ability::TargetSelectionMode::Random + ) { + let targets = random_select_targets_for_ability(state, &target_slots, &[])?; + assign_targets_in_chain(state, &mut resolved, &targets)?; + let mut pending = pending(resolved); + pending.begin_activation_trigger_collection(); + emit_targeting_events( + state, + &flatten_targets_in_chain(&pending.ability), + source_id, + player, + events, + ); + return finish_target_selected_activated_ability_at_payment_boundary( + state, player, pending, events, + ); + } + + if let Some(targets) = + auto_select_targets_for_ability(state, &resolved, &target_slots, &[])? + { + assign_targets_in_chain(state, &mut resolved, &targets)?; + let mut pending = pending(resolved); + pending.begin_activation_trigger_collection(); + emit_targeting_events( + state, + &flatten_targets_in_chain(&pending.ability), + source_id, + player, + events, + ); + return finish_target_selected_activated_ability_at_payment_boundary( + state, player, pending, events, + ); + } + + return super::casting_targets::begin_activated_target_selection( + state, + player, + pending(resolved), + target_slots, + Vec::new(), + ); + } + } + // Pay the exact activation-cost suffix still outstanding. Interactive cost // handlers remove the leg they paid before this boundary, so a parked mana // root never replays an earlier selection. @@ -4411,9 +5160,13 @@ pub(super) fn push_activated_ability_to_stack( pending_interactive.activation_ability_index = Some(ability_index); pending_interactive.pending_loyalty_activation_player = pending_loyalty_activation_player; pending_interactive.activation_target_selection = target_selection; - if let Some(waiting_for) = - surface_next_unpaid_interactive_activation_cost(state, player, &pending_interactive)? - { + pending_interactive.activation_trigger_collection = activation_trigger_collection.clone(); + if let Some(waiting_for) = surface_next_unpaid_interactive_activation_cost( + state, + player, + &mut pending_interactive, + events, + )? { return Ok(waiting_for); } @@ -4488,6 +5241,7 @@ pub(super) fn push_activated_ability_to_stack( .then_some(player) .or(pending_loyalty_activation_player); pending.activation_target_selection = target_selection; + pending.activation_trigger_collection = activation_trigger_collection.clone(); if let Some(pending) = attach_pending_cast_to_cost_move(state, Box::new(pending)) { state.pending_cast = Some(pending); } @@ -4511,34 +5265,6 @@ pub(super) fn push_activated_ability_to_stack( } if matches!(target_selection, ActivationTargetSelection::Settled) { - let assigned_targets = flatten_targets_in_chain(&resolved); - emit_targeting_events(state, &assigned_targets, source_id, player, events); - return push_ability_entry( - state, - player, - source_id, - ability_index, - resolved, - pending_loyalty_activation_player, - events, - ); - } - - // CR 602.2b: Check if the ability has targets that need selection. - // This handles cases where cost payment (sacrifice, waterbend) detoured - // before target selection in handle_activate_ability. - let target_slots = build_target_slots(state, &resolved)?; - let assigned_targets = flatten_targets_in_chain(&resolved); - // CR 601.2d: A divided-effect ability whose `distribution` is already - // announced has finalized its targets (a legal "up to N" division may fill - // fewer target slots than exist — Captain America's Throw picks 1 of 3), so - // it is ready to go on the stack even though `assigned_targets.len()` is below - // `target_slots.len()`. Without this the re-check would wrongly re-open target - // selection and drop the announced division. - if !target_slots.is_empty() - && (assigned_targets.len() >= target_slots.len() || resolved.distribution.is_some()) - { - emit_targeting_events(state, &assigned_targets, source_id, player, events); return push_ability_entry( state, player, @@ -4546,88 +5272,10 @@ pub(super) fn push_activated_ability_to_stack( ability_index, resolved, pending_loyalty_activation_player, + activation_trigger_collection, events, ); } - if !target_slots.is_empty() { - // CR 115.1 + CR 701.9b: Random-target activated abilities — game picks - // uniformly via `state.rng`, no controller prompt. - if matches!( - resolved.target_selection_mode, - crate::types::ability::TargetSelectionMode::Random - ) { - let targets = random_select_targets_for_ability(state, &target_slots, &[])?; - let mut resolved = resolved; - assign_targets_in_chain(state, &mut resolved, &targets)?; - - let assigned_targets = flatten_targets_in_chain(&resolved); - emit_targeting_events(state, &assigned_targets, source_id, player, events); - - return push_ability_entry( - state, - player, - source_id, - ability_index, - resolved, - pending_loyalty_activation_player, - events, - ); - } - - if let Some(targets) = - auto_select_targets_for_ability(state, &resolved, &target_slots, &[])? - { - let mut resolved = resolved; - assign_targets_in_chain(state, &mut resolved, &targets)?; - - let assigned_targets = flatten_targets_in_chain(&resolved); - emit_targeting_events(state, &assigned_targets, source_id, player, events); - - return push_ability_entry( - state, - player, - source_id, - ability_index, - resolved, - pending_loyalty_activation_player, - events, - ); - } - - // Targets need interactive selection - let selection = begin_target_selection_for_ability(state, &resolved, &target_slots, &[])?; - let mut pending_act = PendingCast::new( - source_id, - CardId(0), - resolved, - crate::types::mana::ManaCost::NoCost, - ); - // CR 602.2b: The remainder of the process for activating an ability is - // identical to the process for casting a spell listed in rules 601.2b–i. - // Note: The engine currently pays non-mana costs (Tap, Sacrifice, etc.) - // before target selection, which is a shortcut that deviates from the - // strict CR 601.2 order (targets at 601.2c, costs at 601.2h). To prevent - // double-payment when target selection resumes, we clear the activation - // cost here — it was already consumed above (issue #897 class). - pending_act.activation_cost = None; - pending_act.activation_ability_index = Some(ability_index); - pending_act.pending_loyalty_activation_player = pending_loyalty_activation_player; - // CR 601.2c + CR 602.2b: first slot's announcer (activator unless the slot - // is "of an opponent's choice"). - let initial_player = target_slots - .first() - .and_then(|slot| slot.chooser) - .unwrap_or(player); - return Ok(WaitingFor::TargetSelection { - player: initial_player, - pending_cast: Box::new(pending_act), - target_slots, - mode_labels: Vec::new(), - selection, - }); - } - - emit_targeting_events(state, &assigned_targets, source_id, player, events); push_ability_entry( state, @@ -4636,6 +5284,7 @@ pub(super) fn push_activated_ability_to_stack( ability_index, resolved, pending_loyalty_activation_player, + activation_trigger_collection, events, ) } @@ -4682,6 +5331,7 @@ fn concretize_chosen_x_cost(cost: &AbilityCost, chosen_x: u32) -> AbilityCost { } /// Final step: create stack entry and record activation. +#[allow(clippy::too_many_arguments)] pub(super) fn push_ability_entry( state: &mut GameState, player: PlayerId, @@ -4689,6 +5339,7 @@ pub(super) fn push_ability_entry( ability_index: usize, mut resolved: ResolvedAbility, pending_loyalty_activation_player: Option, + activation_trigger_collection: Option>, events: &mut Vec, ) -> Result { let entry_id = ObjectId(state.next_object_id); @@ -4741,6 +5392,20 @@ pub(super) fn push_ability_entry( player, events, ); + if let Some(mut collection) = activation_trigger_collection { + collection.collect(state, events); + let mut deferred_contexts = std::mem::take(&mut state.deferred_triggers); + collection.commit_into(state, &mut deferred_contexts); + state.deferred_triggers = deferred_contexts; + state + .consumed_before_priority_trigger_events + .extend(events.iter().enumerate().map(|(index, event)| { + crate::game::triggers::ConsumedTriggerEventOccurrence { + event: event.clone(), + occurrence: crate::game::triggers::trigger_event_occurrence(events, index), + } + })); + } priority::clear_priority_passes(state); Ok(WaitingFor::Priority { player }) @@ -6885,6 +7550,7 @@ pub(crate) fn handle_reveal_for_cost( card_names: revealed_names, }); + pending.mark_activation_cost_committed(); finish_pending_cost_or_cast(state, player, pending, events) } @@ -11074,6 +11740,18 @@ pub fn enter_payment_step( }); } + if state + .pending_cast + .as_ref() + .is_some_and(|pending| pending.deferred_target_selection) + { + let pending = *state + .pending_cast + .take() + .expect("checked pending cast presence"); + return begin_deferred_target_selection(state, player, pending, events); + } + let targeted_counter_resume = pending.ability.chosen_x.and_then(|chosen_x| { pending .activation_cost @@ -11084,6 +11762,31 @@ pub fn enter_payment_step( }); if let Some((mut pending, cost, chosen_x)) = targeted_counter_resume { let concretized_cost = concretize_chosen_x_cost(&cost, chosen_x); + // CR 107.1b + CR 118.3: Choosing X=0 makes a targeted + // remove-X-counters component a zero cost. It neither requires an + // object choice nor requires that a matching counter exist. + if chosen_x == 0 { + pending.activation_cost = + remove_first_activation_cost_matching(concretized_cost, |cost| { + matches!( + cost, + AbilityCost::RemoveCounter { + count: 0, + target: Some(_), + .. + } + ) + }); + if let Some(waiting_for) = surface_next_unpaid_interactive_activation_cost( + state, + player, + &mut pending, + events, + )? { + return Ok(waiting_for); + } + return finish_pending_cost_or_cast(state, player, pending, events); + } let prompt_cost = targeted_remove_counter_choice_cost(&concretized_cost) .unwrap_or_else(|| concretized_cost.clone()); pending.activation_cost = Some(concretized_cost); @@ -11099,18 +11802,6 @@ pub fn enter_payment_step( } } - if state - .pending_cast - .as_ref() - .is_some_and(|pending| pending.deferred_target_selection) - { - let pending = *state - .pending_cast - .take() - .expect("checked pending cast presence"); - return begin_deferred_target_selection(state, player, pending, events); - } - if state.pending_cast.as_ref().is_some_and(|pending| { matches!( pending.additional_cost_flow, @@ -11813,6 +12504,7 @@ pub fn finalize_mana_payment_with_phyrexian_choices( pending.activation_residual, pending.activation_target_selection, pending.pending_loyalty_activation_player, + pending.activation_trigger_collection.clone(), events, ); } @@ -12895,7 +13587,9 @@ mod tests { assist_state: AssistState::NotOffered, activation_residual: ActivationResidual::None, activation_target_selection: ActivationTargetSelection::Pending, + activation_cost_committed: false, alt_cost_grant_source: None, + activation_trigger_collection: None, } } @@ -18170,7 +18864,9 @@ mod tests { assist_state: AssistState::NotOffered, activation_residual: ActivationResidual::None, activation_target_selection: ActivationTargetSelection::Pending, + activation_cost_committed: false, alt_cost_grant_source: None, + activation_trigger_collection: None, }; let result = pay_additional_cost( @@ -18305,7 +19001,9 @@ mod tests { assist_state: AssistState::NotOffered, activation_residual: ActivationResidual::None, activation_target_selection: ActivationTargetSelection::Pending, + activation_cost_committed: false, alt_cost_grant_source: None, + activation_trigger_collection: None, }; let mut events = Vec::new(); @@ -18409,7 +19107,9 @@ mod tests { assist_state: AssistState::NotOffered, activation_residual: ActivationResidual::None, activation_target_selection: ActivationTargetSelection::Pending, + activation_cost_committed: false, alt_cost_grant_source: None, + activation_trigger_collection: None, }; // Exactly one card is required. Selecting two must fail. @@ -18502,7 +19202,9 @@ mod tests { assist_state: AssistState::NotOffered, activation_residual: ActivationResidual::None, activation_target_selection: ActivationTargetSelection::Pending, + activation_cost_committed: false, alt_cost_grant_source: None, + activation_trigger_collection: None, }; // `red` is not in the legal-cards list, so the cost handler must reject @@ -18628,7 +19330,9 @@ mod tests { assist_state: AssistState::NotOffered, activation_residual: ActivationResidual::None, activation_target_selection: ActivationTargetSelection::Pending, + activation_cost_committed: false, alt_cost_grant_source: None, + activation_trigger_collection: None, }; let result = pay_additional_cost( @@ -21618,8 +22322,59 @@ its replicate cost was paid.)\nDraw a card."; ActivationResidual::XMana, ActivationTargetSelection::Pending, None, + None, + &mut events, + ); + } + + #[test] + fn direct_push_target_bearing_activation_selects_targets_before_paying_cost() { + let mut state = GameState::new_two_player(42); + let source = create_object( + &mut state, + CardId(7_710), + PlayerId(0), + "Target-First Direct Source".to_string(), + Zone::Battlefield, + ); + let resolved = ResolvedAbility::new( + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Any, + damage_source: None, + excess: None, + }, + Vec::new(), + source, + PlayerId(0), + ); + let cost = AbilityCost::Tap; + let mut events = Vec::new(); + + let waiting = push_activated_ability_to_stack( + &mut state, + PlayerId(0), + source, + 0, + resolved, + Some(&cost), + ActivationResidual::None, + ActivationTargetSelection::Pending, + None, + None, &mut events, + ) + .expect("direct activation root must enter target selection"); + + let WaitingFor::TargetSelection { pending_cast, .. } = waiting else { + panic!("expected target selection before the tap cost, got {waiting:?}"); + }; + assert_eq!(pending_cast.activation_cost, Some(AbilityCost::Tap)); + assert!( + !state.objects[&source].tapped, + "target declaration must precede the activation tap cost" ); + assert!(state.stack.is_empty()); } /// CR 119.4a + CR 810.9a: in 2HG the max X payable via a "pay X life" diff --git a/crates/engine/src/game/casting_targets.rs b/crates/engine/src/game/casting_targets.rs index 732109657b..24b217a2df 100644 --- a/crates/engine/src/game/casting_targets.rs +++ b/crates/engine/src/game/casting_targets.rs @@ -4,13 +4,12 @@ use crate::types::ability::{ }; use crate::types::events::GameEvent; use crate::types::game_state::{ - ActivationTargetSelection, CostResume, GameState, PayCostKind, PendingCast, WaitingFor, + ActivationTargetSelection, GameState, PendingCast, TargetSelectionSlot, WaitingFor, }; use crate::types::identifiers::ObjectId; use crate::types::keywords::Keyword; use crate::types::mana::ManaCost; use crate::types::player::PlayerId; -use crate::types::zones::ExileCostSourceZone; use super::ability_utils::{ ability_target_legality_needs_chosen_x, assign_selected_slots_in_chain, @@ -23,10 +22,46 @@ use super::ability_utils::{ use super::casting_costs::{ cost_has_x, drain_deferred_triggers_after_stack_object_announcement, enter_payment_step, finish_pending_cast_cost_or_pay, + target_first_activation_defers_interactive_costs_to_payment_boundary, + TargetFirstPaymentHandoff, }; use super::engine::EngineError; use super::restrictions; +/// Creates the sole interactive target-declaration boundary for an activated +/// ability after its announcement-only choices have settled. +/// +/// CR 602.2b + CR 601.2b-c: Modes and X are announced before targets, and +/// costs are paid only after the target declaration has completed. Keeping the +/// prompt construction here prevents cost-specific activation detours from +/// accidentally moving ahead of target selection. +pub(crate) fn begin_activated_target_selection( + state: &GameState, + player: PlayerId, + mut pending_cast: PendingCast, + target_slots: Vec, + mode_labels: Vec>, +) -> Result { + pending_cast.begin_activation_trigger_collection(); + let selection = begin_target_selection_for_ability( + state, + &pending_cast.ability, + &target_slots, + &pending_cast.target_constraints, + )?; + let initial_player = target_slots + .first() + .and_then(|slot| slot.chooser) + .unwrap_or(player); + Ok(WaitingFor::TargetSelection { + player: initial_player, + pending_cast: Box::new(pending_cast), + target_slots, + mode_labels, + selection, + }) +} + /// Handle mode selection for a modal spell. /// /// Combines chosen mode abilities into a single ResolvedAbility chain (sub_abilities), @@ -256,6 +291,7 @@ fn maybe_pause_for_cast_distribution( player: PlayerId, pending: &PendingCast, ability: &ResolvedAbility, + events: &[GameEvent], ) -> Result, EngineError> { let Some(unit) = &pending.distribute else { return Ok(None); @@ -270,6 +306,7 @@ fn maybe_pause_for_cast_distribution( } let mut pending_dist = pending.clone(); pending_dist.ability = Box::new(ability.clone()); + stage_activation_target_events_before_distribution(state, &mut pending_dist, events); state.pending_cast = Some(Box::new(pending_dist)); Ok(Some(WaitingFor::DistributeAmong { player, @@ -279,6 +316,19 @@ fn maybe_pause_for_cast_distribution( })) } +/// CR 602.2b + CR 603.3b: A target-bearing activation keeps target-declaration +/// triggers local while an intervening distribution choice is pending. The later +/// stack-commit boundary publishes this already-collected prefix with cost events. +fn stage_activation_target_events_before_distribution( + state: &GameState, + pending: &mut PendingCast, + events: &[GameEvent], +) { + if let Some(collection) = pending.activation_trigger_collection.as_mut() { + collection.collect(state, events); + } +} + /// Handle target selection for a pending cast. pub(crate) fn handle_select_targets( state: &mut GameState, @@ -322,7 +372,20 @@ pub(crate) fn handle_select_targets( let mut ability = pending.ability.clone(); assign_targets_in_chain(state, &mut ability, &targets)?; - if let Some(waiting_for) = maybe_pause_for_cast_distribution(state, player, &pending, &ability)? + if pending.activation_ability_index.is_some() { + // CR 602.2b + CR 601.2c: the target event occurs when targets are + // declared, before a distribution choice or any activation cost. + super::casting::emit_targeting_events( + state, + &super::ability_utils::flatten_targets_in_chain(&ability), + pending.object_id, + pending.ability.controller, + events, + ); + } + + if let Some(waiting_for) = + maybe_pause_for_cast_distribution(state, player, &pending, &ability, events)? { return Ok(waiting_for); } @@ -331,13 +394,20 @@ pub(crate) fn handle_select_targets( let mut pending = pending; pending.ability = ability; pending.activation_target_selection = ActivationTargetSelection::Settled; - if let Some(waiting_for) = pay_activation_costs_after_target_selection( - state, - player, + if !target_first_activation_defers_interactive_costs_to_payment_boundary( &pending, - (*pending.ability).clone(), - )? { - return Ok(waiting_for); + TargetFirstPaymentHandoff::BeforeManaPayment, + ) { + if let Some(waiting_for) = + super::casting_costs::surface_next_unpaid_interactive_activation_cost( + state, + player, + &mut pending, + events, + )? + { + return Ok(waiting_for); + } } return super::casting_costs::finish_target_selected_activated_ability_at_payment_boundary( @@ -417,8 +487,20 @@ pub(crate) fn handle_choose_target( // inbound per-slot `player` (the opponent) would pay and stack the spell. let controller = pending.ability.controller; + if pending.activation_ability_index.is_some() { + // CR 602.2b + CR 601.2c: complete the target-declaration event + // before any distribution choice or activation cost. + super::casting::emit_targeting_events( + state, + &super::ability_utils::flatten_targets_in_chain(&ability), + pending.object_id, + controller, + events, + ); + } + if let Some(waiting_for) = - maybe_pause_for_cast_distribution(state, controller, &pending, &ability)? + maybe_pause_for_cast_distribution(state, controller, &pending, &ability, events)? { return Ok(waiting_for); } @@ -427,13 +509,20 @@ pub(crate) fn handle_choose_target( let mut pending = pending; pending.ability = ability; pending.activation_target_selection = ActivationTargetSelection::Settled; - if let Some(waiting_for) = pay_activation_costs_after_target_selection( - state, - controller, + if !target_first_activation_defers_interactive_costs_to_payment_boundary( &pending, - (*pending.ability).clone(), - )? { - return Ok(waiting_for); + TargetFirstPaymentHandoff::BeforeManaPayment, + ) { + if let Some(waiting_for) = + super::casting_costs::surface_next_unpaid_interactive_activation_cost( + state, + controller, + &mut pending, + events, + )? + { + return Ok(waiting_for); + } } let waiting_for = @@ -453,86 +542,6 @@ pub(crate) fn handle_choose_target( } } -fn pay_activation_costs_after_target_selection( - state: &mut GameState, - player: PlayerId, - pending: &PendingCast, - assigned_ability: ResolvedAbility, -) -> Result, EngineError> { - if let Some(ref activation_cost) = pending.activation_cost { - if let Some((count, zone, filter)) = super::casting::find_non_self_exile(activation_cost) { - let narrow_zone = ExileCostSourceZone::try_from_zone(zone) - .expect("find_non_self_exile restricts zone to Hand or Graveyard"); - let eligible = super::casting::find_eligible_exile_for_cost_targets( - state, - player, - pending.object_id, - narrow_zone, - filter, - ); - if eligible.len() < count as usize { - return Err(EngineError::ActionNotAllowed( - "Not enough eligible cards to exile".into(), - )); - } - let mut pending = pending.clone(); - pending.ability = Box::new(assigned_ability); - return Ok(Some(WaitingFor::PayCost { - player, - kind: PayCostKind::ExileFromZone { zone: narrow_zone }, - choices: eligible, - count: count as usize, - min_count: 0, - resume: CostResume::Spell { - spell: Box::new(pending), - }, - })); - } - - // CR 701.3d + CR 601.2c/601.2h: a targeted "unattach a matching - // attachment from ~" ability chooses its targets first, then surfaces the - // Unattach cost interactively (Captain America's Throw). The chosen - // Equipment's mana value drives the divided damage (CR 202.3 + CR 608.2k), - // so eligibility narrows to attachments whose mana value is at least the - // announced target count (CR 601.2c). Modeled on the non-self exile detour. - if let Some((count, filter)) = super::casting::find_unattach_from_cost(activation_cost) { - let n = distribution_targets(&assigned_ability).len() as u32; - let eligible = super::casting::find_eligible_unattach_for_cost_targets( - state, - player, - pending.object_id, - filter, - n, - ); - if eligible.is_empty() { - // CR 733 / CR 601.2h: unpayable cost — nothing has been detached - // (pre-commit revert), so the activation is simply illegal. - return Err(EngineError::ActionNotAllowed( - "No eligible Equipment to unattach with mana value >= target count".into(), - )); - } - let mut pending = pending.clone(); - pending.ability = Box::new(assigned_ability); - // CR 601.2h: unattach-from is a required cost — no decline, exactly - // `count` selections. - return Ok(Some(WaitingFor::PayCost { - player, - kind: PayCostKind::UnattachFrom { - filter: filter.clone(), - }, - choices: eligible, - count: count as usize, - min_count: count as usize, - resume: CostResume::Spell { - spell: Box::new(pending), - }, - })); - } - } - - Ok(None) -} - /// CR 602.2b + CR 605.3b + CR 616.1: Resume an automatic activation mana leg /// through the same target-first cost suffix that owns chosen targets, /// distribution, and interactive non-mana costs. The mana payment is already @@ -540,7 +549,7 @@ fn pay_activation_costs_after_target_selection( pub(crate) fn finish_activation_after_automatic_mana_payment( state: &mut GameState, player: PlayerId, - pending: PendingCast, + mut pending: PendingCast, events: &mut Vec, ) -> Result { if pending.activation_ability_index.is_none() { @@ -551,10 +560,17 @@ pub(crate) fn finish_activation_after_automatic_mana_payment( if matches!( pending.activation_target_selection, ActivationTargetSelection::Settled + ) && !target_first_activation_defers_interactive_costs_to_payment_boundary( + &pending, + TargetFirstPaymentHandoff::AfterManaPayment, ) { - let ability = (*pending.ability).clone(); if let Some(waiting) = - pay_activation_costs_after_target_selection(state, player, &pending, ability)? + super::casting_costs::surface_next_unpaid_interactive_activation_cost( + state, + player, + &mut pending, + events, + )? { return Ok(waiting); } diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index 67c0806676..b7b7388495 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -5155,7 +5155,7 @@ fn composite_tap_self_exile_activation_moves_battlefield_source_to_exile() { } #[test] -fn activated_sacrifice_cost_resumes_to_effect_target_selection() { +fn activated_sacrifice_cost_selects_effect_target_before_payment() { let mut state = setup_game_at_main_phase(); let source = create_object( &mut state, @@ -5238,22 +5238,8 @@ fn activated_sacrifice_cost_resumes_to_effect_target_selection() { add_mana(&mut state, PlayerId(0), ManaType::Colorless, 2); let waiting = handle_activate_ability(&mut state, PlayerId(0), source, 0, &mut Vec::new()) - .expect("activation should ask for the token sacrifice cost first"); + .expect("activation should ask for its effect target before payment"); state.waiting_for = waiting; - let WaitingFor::PayCost { - kind: PayCostKind::Sacrifice, - choices: permanents, - count, - .. - } = &state.waiting_for - else { - panic!("expected PayCost Sacrifice, got {:?}", state.waiting_for); - }; - assert_eq!(*count, 1); - assert_eq!(permanents, &vec![token]); - - apply_as_current(&mut state, GameAction::SelectCards { cards: vec![token] }) - .expect("sacrificing the token should resume activation"); let WaitingFor::TargetSelection { target_slots, selection, @@ -5267,13 +5253,13 @@ fn activated_sacrifice_cost_resumes_to_effect_target_selection() { selection .current_legal_targets .contains(&TargetRef::Object(creature)), - "the post-cost target prompt must include the target creature" + "the target prompt must include the target creature before payment" ); assert!( selection .current_legal_targets .contains(&TargetRef::Object(other_creature)), - "the post-cost target prompt must include each legal target creature" + "the target prompt must include each legal target creature before payment" ); apply_as_current( @@ -5283,6 +5269,23 @@ fn activated_sacrifice_cost_resumes_to_effect_target_selection() { }, ) .expect("target creature should be selectable"); + + let WaitingFor::PayCost { + kind: PayCostKind::Sacrifice, + choices: permanents, + count, + .. + } = &state.waiting_for + else { + panic!( + "expected PayCost Sacrifice after target declaration, got {:?}", + state.waiting_for + ); + }; + assert_eq!(*count, 1); + assert_eq!(permanents, &vec![token]); + apply_as_current(&mut state, GameAction::SelectCards { cards: vec![token] }) + .expect("sacrificing the token should resume activation"); apply_as_current(&mut state, GameAction::PassPriority).unwrap(); apply_as_current(&mut state, GameAction::PassPriority).unwrap(); @@ -7232,12 +7235,10 @@ fn x_cost_activated_composite_tap_prompts_for_x_and_taps_on_resolution() { ); } -/// Regression test for issue #897: X-cost activated ability with Composite -/// {Tap, Mana{X}} cost must NOT attempt to pay the Tap sub-cost a second -/// time when interactive target selection is required. Before the fix, -/// `push_activated_ability_to_stack` paid the Tap cost and then stored it -/// in `pending_act.activation_cost`; the resumed target-selection path in -/// `casting_targets.rs` would try to pay it again, causing a softlock. +/// Regression test for issue #897: an X-cost activated ability with Composite +/// `{T}, {X}` chooses its target after X is announced but before either cost +/// component is paid. The pending root retains the tap cost through selection, +/// then pays it exactly once while completing the activation. #[test] fn x_cost_activated_composite_tap_no_double_payment_on_interactive_targets() { let mut state = setup_game_at_main_phase(); @@ -7299,33 +7300,268 @@ fn x_cost_activated_composite_tap_no_double_payment_on_interactive_targets() { state.waiting_for ); - // Commit X = 2. After mana payment + Tap, the ability needs interactive - // target selection (multiple legal targets: both players). + // Commit X = 2. X is announced, then the target is selected before the + // mana and tap costs are paid (CR 601.2c before CR 601.2h). apply_as_current(&mut state, GameAction::ChooseX { value: 2 }).unwrap(); - // Note: In strict CR 601.2, target selection (601.2c) occurs before cost - // payment (601.2h). The engine shortcuts this by paying non-mana costs - // first, so the source is already tapped before target selection begins. assert!( - state.objects[&source].tapped, - "source must be tapped after cost payment" + !state.objects[&source].tapped, + "source must remain untapped while choosing the target" ); - // The waiting_for should be TargetSelection, NOT a softlock/error. assert!( matches!(state.waiting_for, WaitingFor::TargetSelection { .. }), "expected TargetSelection for interactive target choice, got {:?}", state.waiting_for ); - // Verify the pending_cast does NOT carry activation_cost (no double-pay). + // The residual tap cost survives target selection and is paid once after + // the target is committed. if let WaitingFor::TargetSelection { ref pending_cast, .. } = state.waiting_for { assert!( - pending_cast.activation_cost.is_none(), - "activation_cost must be None after cost was already paid (issue #897)" + matches!(pending_cast.activation_cost, Some(AbilityCost::Tap)), + "target selection must retain the unpaid tap cost" ); } + + apply_as_current( + &mut state, + GameAction::SelectTargets { + targets: vec![TargetRef::Player(PlayerId(1))], + }, + ) + .expect("selecting the target must complete payment"); + assert!( + state.objects[&source].tapped, + "the tap cost is paid exactly once" + ); + assert_eq!(state.stack.len(), 1, "the activation reaches the stack"); +} + +/// CR 602.2b + CR 601.2b/c/f: An X-cost activation whose exact target count +/// is X cannot build target slots until X is announced. The pre-announcement +/// target-slot error must defer only this X-dependent declaration, not suppress +/// ordinary target-legality errors or start paying costs first. +#[test] +fn x_cost_activation_defers_exact_target_count_until_x_is_announced() { + let mut state = setup_game_at_main_phase(); + let source = create_object( + &mut state, + CardId(954), + PlayerId(0), + "X Target Relic".to_string(), + Zone::Battlefield, + ); + let target = create_object( + &mut state, + CardId(955), + PlayerId(1), + "Target Creature".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&target) + .unwrap() + .card_types + .core_types + .push(CoreType::Creature); + let other_target = create_object( + &mut state, + CardId(959), + PlayerId(1), + "Other Target Creature".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&other_target) + .unwrap() + .card_types + .core_types + .push(CoreType::Creature); + let mut ability = AbilityDefinition::new( + AbilityKind::Activated, + Effect::Destroy { + target: TargetFilter::Typed(TypedFilter::creature()), + cant_regenerate: false, + }, + ) + .cost(AbilityCost::Mana { + cost: ManaCost::Cost { + shards: vec![ManaCostShard::X], + generic: 0, + }, + }); + ability.multi_target = Some(MultiTargetSpec::exact(QuantityExpr::Ref { + qty: QuantityRef::Variable { + name: "X".to_string(), + }, + })); + Arc::make_mut(&mut state.objects.get_mut(&source).unwrap().abilities).push(ability); + add_mana(&mut state, PlayerId(0), ManaType::Colorless, 1); + + state.waiting_for = + handle_activate_ability(&mut state, PlayerId(0), source, 0, &mut Vec::new()) + .expect("the X activation must defer its target count"); + assert!(matches!(state.waiting_for, WaitingFor::ChooseXValue { .. })); + + apply_as_current(&mut state, GameAction::ChooseX { value: 1 }) + .expect("announcing X must allow target-slot construction"); + let WaitingFor::TargetSelection { target_slots, .. } = &state.waiting_for else { + panic!( + "expected target selection after X announcement, got {:?}", + state.waiting_for + ); + }; + assert_eq!(target_slots.len(), 1); + assert!(target_slots[0] + .legal_targets + .contains(&TargetRef::Object(target))); + assert!(target_slots[0] + .legal_targets + .contains(&TargetRef::Object(other_target))); +} + +/// CR 602.2b + CR 601.2b/c/f: Target legality that depends on X is evaluated +/// only after X is announced, even when the unannounced value would leave no +/// legal target. +#[test] +fn x_cost_activation_defers_x_dependent_target_filter_until_x_is_announced() { + let mut state = setup_game_at_main_phase(); + let source = create_object( + &mut state, + CardId(957), + PlayerId(0), + "X Filter Relic".to_string(), + Zone::Battlefield, + ); + let target = create_object( + &mut state, + CardId(958), + PlayerId(1), + "Mana Value Two Creature".to_string(), + Zone::Battlefield, + ); + { + let target_obj = state.objects.get_mut(&target).unwrap(); + target_obj.card_types.core_types.push(CoreType::Creature); + target_obj.mana_cost = ManaCost::generic(2); + } + let other_target = create_object( + &mut state, + CardId(960), + PlayerId(1), + "Other Mana Value Two Creature".to_string(), + Zone::Battlefield, + ); + { + let target_obj = state.objects.get_mut(&other_target).unwrap(); + target_obj.card_types.core_types.push(CoreType::Creature); + target_obj.mana_cost = ManaCost::generic(2); + } + let ability = AbilityDefinition::new( + AbilityKind::Activated, + Effect::Destroy { + target: TargetFilter::Typed(TypedFilter::creature().properties(vec![ + FilterProp::Cmc { + comparator: Comparator::LE, + value: QuantityExpr::Ref { + qty: QuantityRef::Variable { + name: "X".to_string(), + }, + }, + }, + ])), + cant_regenerate: false, + }, + ) + .cost(AbilityCost::Mana { + cost: ManaCost::Cost { + shards: vec![ManaCostShard::X], + generic: 0, + }, + }); + Arc::make_mut(&mut state.objects.get_mut(&source).unwrap().abilities).push(ability); + add_mana(&mut state, PlayerId(0), ManaType::Colorless, 2); + + state.waiting_for = + handle_activate_ability(&mut state, PlayerId(0), source, 0, &mut Vec::new()) + .expect("the X-dependent filter must defer target legality"); + assert!(matches!(state.waiting_for, WaitingFor::ChooseXValue { .. })); + + apply_as_current(&mut state, GameAction::ChooseX { value: 2 }) + .expect("announcing X must make the mana-value-two target legal"); + let WaitingFor::TargetSelection { target_slots, .. } = &state.waiting_for else { + panic!( + "expected target selection after X announcement, got {:?}", + state.waiting_for + ); + }; + assert_eq!(target_slots.len(), 1); + assert!(target_slots[0] + .legal_targets + .contains(&TargetRef::Object(target))); + assert!(target_slots[0] + .legal_targets + .contains(&TargetRef::Object(other_target))); +} + +/// CR 602.2b + CR 601.2b/c/f: An unresolved X target count may defer target +/// declaration, but it cannot conceal a separate mandatory target's absence. +#[test] +fn x_target_count_does_not_mask_missing_fixed_target() { + let mut state = setup_game_at_main_phase(); + let source = create_object( + &mut state, + CardId(956), + PlayerId(0), + "Mixed Target Relic".to_string(), + Zone::Battlefield, + ); + let mut x_target = AbilityDefinition::new( + AbilityKind::Activated, + Effect::Destroy { + target: TargetFilter::Typed(TypedFilter::creature()), + cant_regenerate: false, + }, + ); + x_target.multi_target = Some(MultiTargetSpec::exact(QuantityExpr::Ref { + qty: QuantityRef::Variable { + name: "X".to_string(), + }, + })); + let ability = AbilityDefinition::new( + AbilityKind::Activated, + Effect::Destroy { + target: TargetFilter::Typed(TypedFilter::creature()), + cant_regenerate: false, + }, + ) + .cost(AbilityCost::Mana { + cost: ManaCost::Cost { + shards: vec![ManaCostShard::X], + generic: 0, + }, + }) + .sub_ability(x_target); + Arc::make_mut(&mut state.objects.get_mut(&source).unwrap().abilities).push(ability); + add_mana(&mut state, PlayerId(0), ManaType::Colorless, 1); + + assert!( + !can_activate_ability_now(&state, PlayerId(0), source, 0), + "legal-action generation must not mask the missing fixed target with the later X target" + ); + + let error = handle_activate_ability(&mut state, PlayerId(0), source, 0, &mut Vec::new()) + .expect_err("the missing fixed target must reject before choosing X"); + assert!(matches!( + error, + EngineError::ActionNotAllowed(message) if message == "No legal targets available" + )); + assert!(state.pending_cast.is_none()); + assert!(matches!(state.waiting_for, WaitingFor::Priority { .. })); } #[test] @@ -34637,21 +34873,6 @@ mod remove_counter_cost { assert!(matches!(state.waiting_for, WaitingFor::ChooseXValue { .. })); apply_as_current(&mut state, GameAction::ChooseX { value: 2 }).unwrap(); - assert!(matches!( - state.waiting_for, - WaitingFor::PayCost { - kind: PayCostKind::RemoveCounter { .. }, - .. - } - )); - - apply_as_current( - &mut state, - GameAction::SelectCards { - cards: vec![counter_source], - }, - ) - .unwrap(); match &state.waiting_for { WaitingFor::TargetSelection { target_slots, @@ -34668,6 +34889,38 @@ mod remove_counter_cost { } other => panic!("expected deferred modal target selection, got {other:?}"), } + + let target_slots = match &state.waiting_for { + WaitingFor::TargetSelection { target_slots, .. } => target_slots, + _ => unreachable!("matched TargetSelection above"), + }; + let selected_target = target_slots[0] + .legal_targets + .first() + .cloned() + .expect("modal target slot must have a legal creature"); + apply_as_current( + &mut state, + GameAction::SelectTargets { + targets: vec![selected_target], + }, + ) + .unwrap(); + assert!(matches!( + state.waiting_for, + WaitingFor::PayCost { + kind: PayCostKind::RemoveCounter { .. }, + .. + } + )); + + apply_as_current( + &mut state, + GameAction::SelectCards { + cards: vec![counter_source], + }, + ) + .unwrap(); } } diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index cf02915aff..b7adf0573f 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -90,11 +90,17 @@ pub enum PublicFinalizeMode { /// selected contribution. Once helper payment has started or completed, its /// resources may have changed and cancellation cannot roll that prefix back. fn ensure_assist_cancellation_is_allowed(state: &GameState) -> Result<(), EngineError> { + let pending = state + .pending_cast + .as_deref() + .or_else(|| state.waiting_for.pending_cast_ref()); + if pending.is_some_and(|pending| pending.activation_cost_committed) { + return Err(EngineError::ActionNotAllowed( + "Cannot cancel an activation after a cost is paid".to_string(), + )); + } if matches!( - state - .pending_cast - .as_deref() - .map(|pending| pending.assist_state), + pending.map(|pending| pending.assist_state), Some(AssistState::PaymentStarted { .. } | AssistState::Paid { .. }) ) { return Err(EngineError::ActionNotAllowed( @@ -3165,6 +3171,7 @@ pub(crate) fn drain_pending_cost_move_resume( | PendingCostMoveResume::UnlessBouncePayment { .. } | PendingCostMoveResume::DelveManaPayment { .. } | PendingCostMoveResume::ManaAbilityPayment { .. } + | PendingCostMoveResume::ActivationMillPayment { .. } | PendingCostMoveResume::LoyaltyActivation { .. } ) ), @@ -3184,6 +3191,7 @@ pub(crate) fn drain_pending_cost_move_resume( | PendingCostMoveResume::UnlessBouncePayment { .. } | PendingCostMoveResume::DelveManaPayment { .. } | PendingCostMoveResume::ManaAbilityPayment { .. } + | PendingCostMoveResume::ActivationMillPayment { .. } | PendingCostMoveResume::LoyaltyActivation { .. } ) ), @@ -3246,6 +3254,11 @@ pub(crate) fn drain_pending_cost_move_resume( Some(PendingCostMoveResume::ManaAbilityPayment { .. }) ) { mana_abilities::resume_mana_ability_cost_move(state, events)? + } else if matches!( + state.pending_cost_move_resume, + Some(PendingCostMoveResume::ActivationMillPayment { .. }) + ) { + casting_costs::resume_activation_mill_cost_payment(state, events)? } else if matches!( state.pending_cost_move_resume, Some(PendingCostMoveResume::LoyaltyActivation { .. }) @@ -5036,7 +5049,7 @@ fn apply_action( .. }, GameAction::CancelCast, - ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events), + ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events)?, (WaitingFor::TargetSelection { player, .. }, GameAction::SelectTargets { targets }) => { engine_casting::handle_target_selection_select_targets( state, @@ -5060,7 +5073,7 @@ fn apply_action( .. }, GameAction::CancelCast, - ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events), + ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events)?, ( WaitingFor::OptionalCostChoice { player, @@ -5084,7 +5097,7 @@ fn apply_action( .. }, GameAction::CancelCast, - ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events), + ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events)?, ( WaitingFor::ChooseGiftRecipient { player, @@ -5092,7 +5105,7 @@ fn apply_action( .. }, GameAction::CancelCast, - ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events), + ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events)?, // CR 702.47a–e: Splice — caster reveals a card to splice onto the spell // (re-offering for the rest), or declines to finish and proceed to targets. ( @@ -5117,7 +5130,7 @@ fn apply_action( .. }, GameAction::CancelCast, - ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events), + ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events)?, // CR 601.2b: Defiler cycle — player decides whether to pay life for mana reduction. ( WaitingFor::DefilerPayment { @@ -5143,7 +5156,7 @@ fn apply_action( .. }, GameAction::CancelCast, - ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events), + ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events)?, // CR 118.3 + CR 601.2b + CR 605.3b: Player selected objects to pay a // cost. The single `PayCost` state dispatches on `kind` (which action) // and `resume` (spell-cast vs mana-ability pipeline) to the @@ -5555,7 +5568,7 @@ fn apply_action( .. }, GameAction::CancelCast, - ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events), + ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events)?, // CR 118.3: Player selected permanents to sacrifice as cost. ( WaitingFor::ActivationCostOneOfChoice { @@ -5579,7 +5592,7 @@ fn apply_action( .. }, GameAction::CancelCast, - ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events), + ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events)?, // CR 601.2b + CR 701.4a: player chose the creature type for a pre-choice // behold cost; record it and resume behold payment. ( @@ -5605,7 +5618,7 @@ fn apply_action( .. }, GameAction::CancelCast, - ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events), + ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events)?, // Blight: player selected creature(s) to put -1/-1 counters on as cost. ( WaitingFor::BlightChoice { @@ -5631,7 +5644,7 @@ fn apply_action( .. }, GameAction::CancelCast, - ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events), + ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events)?, ( WaitingFor::ChooseManaColor { choice, context, .. @@ -5807,7 +5820,7 @@ fn apply_action( .. }, GameAction::CancelCast, - ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events), + ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events)?, // CR 608.2d: Player decided whether to perform an optional effect ("You may X"). (WaitingFor::OptionalEffectChoice { .. }, GameAction::DecideOptionalEffect { accept }) => { engine_payment_choices::handle_optional_effect_choice(state, accept, &mut events)? @@ -6037,7 +6050,7 @@ fn apply_action( let player = *player; match state.pending_cast.take() { Some(pending) => { - engine_casting::cancel_pending_cast(state, player, &pending, &mut events) + engine_casting::cancel_pending_cast(state, player, &pending, &mut events)? } None => WaitingFor::Priority { player }, } @@ -6045,10 +6058,11 @@ fn apply_action( (WaitingFor::ChooseXValue { player, .. }, GameAction::CancelCast) => { // CR 601.2f + CR 601.2i: Caster may back out before committing to an // X value. Pop the stack entry placed at announcement and restore. + ensure_assist_cancellation_is_allowed(state)?; let player = *player; match state.pending_cast.take() { Some(pending) => { - engine_casting::cancel_pending_cast(state, player, &pending, &mut events) + engine_casting::cancel_pending_cast(state, player, &pending, &mut events)? } None => WaitingFor::Priority { player }, } @@ -6227,10 +6241,11 @@ fn apply_action( } } (WaitingFor::AssistChoosePlayer { player, .. }, GameAction::CancelCast) => { + ensure_assist_cancellation_is_allowed(state)?; let player = *player; match state.pending_cast.take() { Some(pending) => { - engine_casting::cancel_pending_cast(state, player, &pending, &mut events) + engine_casting::cancel_pending_cast(state, player, &pending, &mut events)? } None => WaitingFor::Priority { player }, } @@ -6431,7 +6446,7 @@ fn apply_action( let player = *player; match state.pending_cast.take() { Some(pending) => { - engine_casting::cancel_pending_cast(state, player, &pending, &mut events) + engine_casting::cancel_pending_cast(state, player, &pending, &mut events)? } None => WaitingFor::Priority { player }, } @@ -8295,10 +8310,11 @@ fn apply_action( } // CR 601.2d: Distribute among targets (casting-time distribution). (WaitingFor::DistributeAmong { player, .. }, GameAction::CancelCast) => { + ensure_assist_cancellation_is_allowed(state)?; let player = *player; match state.pending_cast.take() { Some(pending) => { - engine_casting::cancel_pending_cast(state, player, &pending, &mut events) + engine_casting::cancel_pending_cast(state, player, &pending, &mut events)? } None => { return Err(EngineError::InvalidAction( diff --git a/crates/engine/src/game/engine_casting.rs b/crates/engine/src/game/engine_casting.rs index 13fb4a1e77..15e3022d31 100644 --- a/crates/engine/src/game/engine_casting.rs +++ b/crates/engine/src/game/engine_casting.rs @@ -19,9 +19,14 @@ pub(super) fn cancel_pending_cast( player: PlayerId, pending_cast: &PendingCast, events: &mut Vec, -) -> WaitingFor { +) -> Result { + if pending_cast.activation_cost_committed { + return Err(EngineError::ActionNotAllowed( + "Cannot cancel an activation after a cost is paid".to_string(), + )); + } casting::handle_cancel_cast(state, pending_cast, events); - WaitingFor::Priority { player } + Ok(WaitingFor::Priority { player }) } pub(super) fn handle_target_selection_select_targets( diff --git a/crates/engine/src/game/engine_modes.rs b/crates/engine/src/game/engine_modes.rs index fa24178b36..a95611c410 100644 --- a/crates/engine/src/game/engine_modes.rs +++ b/crates/engine/src/game/engine_modes.rs @@ -1,5 +1,5 @@ use crate::types::events::GameEvent; -use crate::types::game_state::{CostResume, GameState, PayCostKind, PendingCast, WaitingFor}; +use crate::types::game_state::{GameState, PendingCast, WaitingFor}; use crate::types::identifiers::{CardId, ObjectId}; use crate::types::mana::ManaCost; @@ -182,41 +182,6 @@ fn handle_activated_mode_choice( state.pending_cast = Some(Box::new(pending_x)); return casting_costs::enter_payment_step(state, player, None, events); } - - // CR 118.3 + CR 602.2b: Modal activated abilities detour to the - // interactive sacrifice prompt before targets or direct cost payment. - // Non-modal activations take this path in `handle_activate_ability`; - // without it, `pay_ability_cost` no-ops non-self `Sacrifice` sub-costs. - if let Some((count, sac_filter)) = casting::find_non_self_sacrifice_cost(cost) { - let eligible = - casting::find_eligible_sacrifice_targets(state, player, source_id, sac_filter); - let (min_count, max_count) = casting::sacrifice_cost_bounds(count, eligible.len()); - if eligible.len() < min_count { - return Err(EngineError::ActionNotAllowed( - "Not enough eligible permanents to sacrifice".into(), - )); - } - let mut pending_sac = - PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); - pending_sac.activation_cost = Some(cost.clone()); - pending_sac.activation_ability_index = ability_index; - pending_sac.target_constraints = target_constraints_from_modal(&modal); - pending_sac.distribute = mode_distribute.clone(); - pending_sac.deferred_target_selection = true; - let mut chosen_modes = indices.clone(); - chosen_modes.sort_unstable(); - pending_sac.chosen_modes = chosen_modes; - return Ok(WaitingFor::PayCost { - player, - kind: PayCostKind::Sacrifice, - choices: eligible, - count: max_count, - min_count, - resume: CostResume::Spell { - spell: Box::new(pending_sac), - }, - }); - } } super::layers::flush_layers(state); @@ -262,39 +227,37 @@ fn handle_activated_mode_choice( if let Some(targets) = resolved_targets { let mut resolved = resolved; assign_targets_in_chain(state, &mut resolved, &targets)?; + // CR 602.2b + CR 601.2c: automatic target assignment is still a + // declaration before activation costs are paid. + casting::emit_targeting_events( + state, + &super::ability_utils::flatten_targets_in_chain(&resolved), + source_id, + player, + events, + ); let mut pending = PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); pending.activation_cost = ability_cost.clone(); pending.activation_ability_index = ability_index; pending.target_constraints = target_constraints; pending.distribute = mode_distribute; + pending.begin_activation_trigger_collection(); casting_costs::finish_target_selected_activated_ability_at_payment_boundary( state, player, pending, events, ) } else { - let selection = begin_target_selection_for_ability( - state, - &resolved, - &target_slots, - &target_constraints, - )?; let mut pending = PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); pending.activation_cost = ability_cost; pending.activation_ability_index = ability_index; pending.target_constraints = target_constraints; pending.distribute = mode_distribute; - // CR 601.2c + CR 602.2b: first slot's announcer (controller unless the - // slot is "of an opponent's choice"). - let initial_player = target_slots - .first() - .and_then(|slot| slot.chooser) - .unwrap_or(player); - Ok(WaitingFor::TargetSelection { - player: initial_player, - pending_cast: Box::new(pending), + super::casting_targets::begin_activated_target_selection( + state, + player, + pending, target_slots, mode_labels, - selection, - }) + ) } } else { let mut pending = PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); diff --git a/crates/engine/src/game/engine_priority.rs b/crates/engine/src/game/engine_priority.rs index 0aaff11622..e7642ae63c 100644 --- a/crates/engine/src/game/engine_priority.rs +++ b/crates/engine/src/game/engine_priority.rs @@ -36,6 +36,8 @@ pub(crate) fn run_post_action_pipeline_from( skip_trigger_scan: bool, skip_deferred_trigger_drain: bool, ) -> Result { + stage_pending_activation_trigger_events(state, events, event_start); + // Capture stack depth before any trigger/SBA processing so we can detect // whether new triggered abilities were added during this pipeline pass. let stack_before = state.stack.len(); @@ -336,6 +338,39 @@ pub(crate) fn run_post_action_pipeline_from( )) } +/// Route events emitted while a target-bearing activation remains pending into +/// its private trigger transaction before the ordinary post-action collector +/// can observe them. +/// +/// CR 602.2a-b + CR 603.2: target declaration and cost payment can create +/// trigger events before the activated ability reaches the stack. These events +/// remain pending until the activation commits; recording their occurrences in +/// the consumed buffer preserves the generic collector's exactly-once contract +/// for this action without making the transaction public state. +fn stage_pending_activation_trigger_events( + state: &mut GameState, + events: &[GameEvent], + event_start: usize, +) { + let new_events = &events[event_start..]; + if new_events.is_empty() { + return; + } + let Some(mut collection) = state.take_pending_activation_trigger_collection() else { + return; + }; + collection.collect(state, new_events); + state.restore_pending_activation_trigger_collection(collection); + state + .consumed_before_priority_trigger_events + .extend(new_events.iter().enumerate().map(|(offset, event)| { + triggers::ConsumedTriggerEventOccurrence { + event: event.clone(), + occurrence: triggers::trigger_event_occurrence(events, event_start + offset), + } + })); +} + /// CR 603.3b + CR 608.2g: settles a terminal resolution marker only after its /// final free cast has actually been announced. The drain uses the /// stack-announcement boundary so B/C's triggers may be ordered above their diff --git a/crates/engine/src/game/engine_tests.rs b/crates/engine/src/game/engine_tests.rs index cd506cdbe7..f8d61d320c 100644 --- a/crates/engine/src/game/engine_tests.rs +++ b/crates/engine/src/game/engine_tests.rs @@ -15,7 +15,9 @@ use crate::types::card_type::CardType; use crate::types::card_type::{CoreType, Supertype}; use crate::types::counter::CounterType; use crate::types::format::FormatConfig; -use crate::types::game_state::{CastPaymentMode, CastingVariant, ProductionOverride}; +use crate::types::game_state::{ + CastPaymentMode, CastingVariant, PendingCast, ProductionOverride, TargetSelectionProgress, +}; use crate::types::identifiers::{CardId, ObjectId}; use crate::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType, ManaUnit}; use crate::types::statics::{CastFrequency, StaticMode}; @@ -110,6 +112,34 @@ fn cards_revealed_events_are_remembered_publicly() { assert_eq!(replay.public_revealed_cards, state.public_revealed_cards); } +#[test] +fn assist_cancellation_rejects_committed_activation_held_by_waiting_for() { + let mut state = setup_game_at_main_phase(); + let mut pending = PendingCast::new( + ObjectId(1), + CardId(1), + ResolvedAbility::new(Effect::NoOp, vec![], ObjectId(1), PlayerId(0)), + ManaCost::NoCost, + ); + pending.activation_cost_committed = true; + state.waiting_for = WaitingFor::TargetSelection { + player: PlayerId(0), + pending_cast: Box::new(pending), + target_slots: vec![], + mode_labels: vec![], + selection: TargetSelectionProgress { + current_slot: 0, + selected_slots: vec![], + current_legal_targets: vec![], + }, + }; + + assert!(matches!( + ensure_assist_cancellation_is_allowed(&state), + Err(EngineError::ActionNotAllowed(message)) if message == "Cannot cancel an activation after a cost is paid" + )); +} + /// CR 603.3d regression — reported turn-34 Commander freeze (All Will Be /// One + Red Hulk + Schema Thief board). A targeted trigger whose only legal /// target vanished between "push first" dispatch and "choose second" @@ -7611,7 +7641,9 @@ fn test_mana_ability_during_mana_payment_stays_in_mana_payment() { assist_state: AssistState::NotOffered, activation_residual: crate::types::game_state::ActivationResidual::None, activation_target_selection: crate::types::game_state::ActivationTargetSelection::Pending, + activation_cost_committed: false, alt_cost_grant_source: None, + activation_trigger_collection: None, })); state.waiting_for = WaitingFor::ManaPayment { player: PlayerId(0), @@ -8006,7 +8038,9 @@ fn taps_for_mana_multiplier_fires_once_on_color_choice_mana_payment_resume() { assist_state: AssistState::NotOffered, activation_residual: crate::types::game_state::ActivationResidual::None, activation_target_selection: crate::types::game_state::ActivationTargetSelection::Pending, + activation_cost_committed: false, alt_cost_grant_source: None, + activation_trigger_collection: None, })); state.waiting_for = WaitingFor::ManaPayment { player: PlayerId(0), diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index 716dbab427..a494f7d9df 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -2632,13 +2632,20 @@ fn resolve_ref( // // CR 107.3e + CR 107.3m + CR 603.7c: When the trigger source itself has // no `chosen_x` (SpellCast triggers and similar event triggers do not - // have their own cost), fall back to the triggering spell's + // have their own cost), first fall back to the triggering spell's // `cost_x_paid`. This covers "whenever you cast your first spell with // {X} in its mana cost each turn, put X +1/+1 counters on ~" — the X // there is the triggering spell's X, not this trigger's X (which // doesn't exist). CR 107.3e explicitly permits an ability to refer to // X of another object's cost. // + // CR 107.3m + CR 603.3b: Trigger target selection happens before the + // trigger enters resolution, so `current_trigger_event` is not yet + // installed. A self-ETB trigger's bare X in a target-filter threshold + // must therefore fall back to its exact source context's cast X; the + // context preserves that value across zone changes without rebinding a + // later incarnation of the same storage id. + // // Other named variables (set by `NamedChoice` handlers for things like // "chosen number") keep their single-responsibility path through // `last_named_choice`. @@ -2653,6 +2660,12 @@ fn resolve_ref( .and_then(|obj| obj.cost_x_paid) .map(u32_to_i32_saturating) }) + .or_else(|| { + trigger_source + .as_ref() + .and_then(|source| source.source_read(state).cost_x_paid()) + .map(u32_to_i32_saturating) + }) .unwrap_or(0), QuantityRef::Variable { .. } => state .last_named_choice diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index cb2fa0cb32..44d1f58fd9 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -7,8 +7,8 @@ use crate::types::ability::{ ControllerRef, CopyRetargetPermission, DelayedTriggerCondition, Effect, ModalChoice, ObjectScope, OriginConstraint, PlayerFilter, PtValue, QuantityExpr, QuantityRef, RenownSubject, ResolvedAbility, SacrificeCost, TargetFilter, TargetRef, TributeOutcome, TriggerCondition, - TriggerDefinition, TriggerDefinitionOccurrenceRef, TriggerDefinitionRef, TriggerEntry, - TriggerGrantProducerKey, TypeFilter, TypedFilter, + TriggerConstraint, TriggerDefinition, TriggerDefinitionOccurrenceRef, TriggerDefinitionRef, + TriggerEntry, TriggerGrantProducerKey, TypeFilter, TypedFilter, }; #[cfg(test)] use crate::types::ability::{EffectScope, TapStateChange}; @@ -309,6 +309,444 @@ impl PendingTriggerContext { } } +/// Read-only authority supplied while collecting triggers for an action that has +/// been prepared but has not yet produced its physical stack entry. +/// +/// CR 602.2a + CR 602.2b: an activated ability is announced before its targets +/// are chosen. Target-trigger collection therefore needs the announced +/// ability's controller even while the activation is represented only by a +/// pending transaction. The overlay is deliberately narrow: it can answer the +/// controller of that one targeting source, but cannot alter object lookup, +/// zones, or the stack. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub(crate) struct TriggerCollectionOverlay { + virtual_targeting_source: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +struct VirtualTargetingSource { + source_id: ObjectId, + source_occurrence: Option, + controller: PlayerId, + ability_index: Option, + ability: Option>, +} + +impl TriggerCollectionOverlay { + /// Prepares the virtual source authority for one in-flight activation. + #[cfg_attr(not(test), allow(dead_code))] // Reached through the pending activation session. + pub(crate) fn for_activated_ability(source_id: ObjectId, controller: PlayerId) -> Self { + Self { + virtual_targeting_source: Some(VirtualTargetingSource { + source_id, + source_occurrence: None, + controller, + ability_index: None, + ability: None, + }), + } + } + + /// Captures the complete announced activated ability for source-filtered + /// becomes-target triggers while the real stack entry does not yet exist. + /// + /// CR 602.2b + CR 603.2: This is a read-only, transaction-local stack + /// projection. It preserves the source occurrence, controller, printed + /// ability index, and resolved tag/kind/target facts used by `StackAbility` + /// filters without publishing an actual stack object before costs are paid. + pub(crate) fn for_prepared_activated_ability( + source_id: ObjectId, + controller: PlayerId, + ability_index: usize, + ability: &ResolvedAbility, + ) -> Self { + Self { + virtual_targeting_source: Some(VirtualTargetingSource { + source_id, + source_occurrence: ability.source_incarnation, + controller, + ability_index: Some(ability_index), + ability: Some(Box::new(ability.clone())), + }), + } + } + + fn targeting_source_controller(&self, source_id: ObjectId) -> Option { + self.virtual_targeting_source + .as_ref() + .filter(|source| source.source_id == source_id) + .map(|source| source.controller) + } + + /// Adds an ephemeral activated-ability stack projection to a staged + /// collection state. Inserting at the front makes the in-flight occurrence + /// authoritative even if an earlier activation from the same permanent is + /// already physically on the stack. + fn install_virtual_targeting_source(&self, state: &mut GameState) { + let Some(source) = self.virtual_targeting_source.as_ref() else { + return; + }; + let (Some(ability_index), Some(ability)) = (source.ability_index, source.ability.as_ref()) + else { + return; + }; + let mut ability = (**ability).clone(); + debug_assert_eq!(ability.source_incarnation, source.source_occurrence); + ability.ability_index = Some(ability_index); + state.stack.insert( + 0, + StackEntry { + // The becomes-target event identifies an activated source by + // source id before the physical stack object exists. + id: source.source_id, + source_id: source.source_id, + controller: source.controller, + kind: StackEntryKind::ActivatedAbility { + source_id: source.source_id, + ability: Box::new(ability), + }, + }, + ); + } +} + +/// One stateful consequence of collecting triggered abilities. +/// +/// Trigger matching is mostly observational, but CR 603 collection also updates +/// per-turn trigger ledgers and event-observation facts. Keeping those writes as +/// owned operations makes their ordering explicit and gives a future +/// activation-local collector a single seam at which to retain or discard them. +/// The ordinary collector applies every operation immediately. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +enum TriggerCollectionOperation { + PrepareEventBatch { + events: Vec, + }, + RecordTriggerFired { + constraint: Option, + source_context: Box>, + definition_ref: Option, + event: GameEvent, + }, + RecordBatchedZoneChanges { + definition_ref: TriggerDefinitionRef, + turn_zone_change_indices: Vec, + }, + AllocateTimestamp, + RecordCombatDamageCastingPermission { + controller: PlayerId, + grants_freerunning: bool, + creature_types: Vec, + }, + MarkSpeedTriggerUsed { + player: PlayerId, + }, +} + +/// Applies trigger-collection mutations in their collection order. +/// +/// This session intentionally owns no game-state borrow. Ordinary collection +/// applies each operation to live state immediately; activation-local collection +/// applies it to a staged clone and retains the same ordered operation journal +/// for its consuming commit path. +struct TriggerCollectionSession { + overlay: TriggerCollectionOverlay, + operation_journal: Option>, +} + +impl TriggerCollectionSession { + fn new(overlay: TriggerCollectionOverlay) -> Self { + Self { + overlay, + operation_journal: None, + } + } + + #[cfg_attr(not(test), allow(dead_code))] // Wired into casting in the next activation step. + fn transactional( + overlay: TriggerCollectionOverlay, + operation_journal: Vec, + ) -> Self { + Self { + overlay, + operation_journal: Some(operation_journal), + } + } + + fn prepare(&mut self, state: &mut GameState, events: &[GameEvent]) { + let _ = self.apply( + state, + TriggerCollectionOperation::PrepareEventBatch { + events: events.to_vec(), + }, + ); + } + + fn record_match(&mut self, state: &mut GameState, matched: &MatchedTrigger, event: &GameEvent) { + let _ = self.apply( + state, + TriggerCollectionOperation::RecordTriggerFired { + constraint: matched.constraint.clone(), + source_context: Box::new(matched.pending.ability.trigger_source.clone()), + definition_ref: matched.definition_ref.clone(), + event: event.clone(), + }, + ); + + if !matched.batched || !batched_zone_change_batch(&matched.trigger_events) { + return; + } + let Some(definition_ref) = matched.definition_ref.clone() else { + return; + }; + let turn_zone_change_indices = matched + .trigger_events + .iter() + .filter_map(|event| match event { + GameEvent::ZoneChanged { record, .. } => Some(record.turn_zone_change_index), + _ => None, + }) + .collect(); + let _ = self.apply( + state, + TriggerCollectionOperation::RecordBatchedZoneChanges { + definition_ref, + turn_zone_change_indices, + }, + ); + } + + fn mark_speed_trigger_used(&mut self, state: &mut GameState, player: PlayerId) { + let _ = self.apply( + state, + TriggerCollectionOperation::MarkSpeedTriggerUsed { player }, + ); + } + + fn next_timestamp(&mut self, state: &mut GameState) -> u64 { + self.apply(state, TriggerCollectionOperation::AllocateTimestamp) + .expect("timestamp allocation must return the allocated timestamp") + } + + fn record_combat_damage_casting_permission( + &mut self, + state: &mut GameState, + controller: PlayerId, + grants_freerunning: bool, + creature_types: Vec, + ) { + let _ = self.apply( + state, + TriggerCollectionOperation::RecordCombatDamageCastingPermission { + controller, + grants_freerunning, + creature_types, + }, + ); + } + + fn targeting_source_controller( + &self, + state: &GameState, + source_id: ObjectId, + ) -> Option { + self.overlay + .targeting_source_controller(source_id) + .or_else(|| { + state.stack.iter().rev().find_map(|entry| { + (entry.id == source_id || entry.source_id == source_id) + .then_some(entry.controller) + }) + }) + .or_else(|| { + state + .objects + .get(&source_id) + .map(|source| source.controller) + }) + } + + fn apply( + &mut self, + state: &mut GameState, + operation: TriggerCollectionOperation, + ) -> Option { + if let Some(operation_journal) = &mut self.operation_journal { + operation_journal.push(operation.clone()); + } + Self::apply_operation(state, operation) + } + + #[cfg_attr(not(test), allow(dead_code))] // Returned to the durable pending carrier. + fn into_operation_journal(mut self) -> Vec { + self.operation_journal.take().unwrap_or_default() + } + + fn apply_operation( + state: &mut GameState, + operation: TriggerCollectionOperation, + ) -> Option { + match operation { + TriggerCollectionOperation::PrepareEventBatch { events } => { + super::layers::flush_layers(state); + reconcile_off_zone_keyword_triggers(state); + observe_object_taps(state, &events); + observe_object_counter_placements(state, &events); + None + } + TriggerCollectionOperation::RecordTriggerFired { + constraint, + source_context, + definition_ref, + event, + } => { + record_trigger_fired_with_ref( + state, + constraint.as_ref(), + source_context.as_ref().as_ref(), + definition_ref.as_ref(), + &event, + ); + None + } + TriggerCollectionOperation::RecordBatchedZoneChanges { + definition_ref, + turn_zone_change_indices, + } => { + for turn_zone_change_index in turn_zone_change_indices { + state + .batched_zone_change_trigger_fired + .insert((definition_ref.clone(), turn_zone_change_index)); + } + None + } + TriggerCollectionOperation::AllocateTimestamp => Some(state.next_timestamp()), + TriggerCollectionOperation::RecordCombatDamageCastingPermission { + controller, + grants_freerunning, + creature_types, + } => { + if grants_freerunning { + state + .assassin_or_commander_dealt_combat_damage_this_turn + .insert(controller); + } + state.creature_types_dealt_combat_damage_this_turn.extend( + creature_types + .into_iter() + .map(|creature_type| (controller, creature_type)), + ); + None + } + TriggerCollectionOperation::MarkSpeedTriggerUsed { player } => { + mark_speed_trigger_used(state, player); + None + } + } + } +} + +/// Trigger collection held by an announced activated ability until its +/// activation transaction commits or is cancelled. +/// +/// CR 602.2a + CR 602.2b: An activation may cross several payment prompts after targets +/// have produced trigger events, but none of those events become globally +/// committed until the full activation is complete. This owner retains only the +/// virtual source authority, ordered semantic operations, and collected +/// contexts. Each continuation reconstructs an ephemeral staged clone from the +/// current live state and replays the operations before collecting its next +/// event slice. Dropping it is cancellation: neither operations nor contexts +/// escape to the live game. +#[cfg_attr(not(test), allow(dead_code))] // Casting owns this session in the next activation step. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub(crate) struct PendingActivationTriggerCollection { + overlay: TriggerCollectionOverlay, + operation_journal: Vec, + pending_contexts: Vec, +} + +#[cfg_attr(not(test), allow(dead_code))] // Exercised by unit tests until casting adopts it. +impl PendingActivationTriggerCollection { + #[cfg_attr(not(test), allow(dead_code))] // Construction moves into the activation announcement path next. + pub(crate) fn for_activated_ability(source_id: ObjectId, controller: PlayerId) -> Self { + Self { + overlay: TriggerCollectionOverlay::for_activated_ability(source_id, controller), + operation_journal: Vec::new(), + pending_contexts: Vec::new(), + } + } + + /// Creates an activation-local collector with the complete in-flight + /// ability available to source-filtered trigger predicates. + pub(crate) fn for_prepared_activated_ability( + source_id: ObjectId, + controller: PlayerId, + ability_index: usize, + ability: &ResolvedAbility, + ) -> Self { + Self { + overlay: TriggerCollectionOverlay::for_prepared_activated_ability( + source_id, + controller, + ability_index, + ability, + ), + operation_journal: Vec::new(), + pending_contexts: Vec::new(), + } + } + + /// Collect one event slice emitted while the activation is still pending. + /// + /// Later slices replay the earlier collector operations into a fresh staged + /// clone, so per-turn trigger constraints and event-observation ledgers + /// behave as one uninterrupted transaction without touching live state. + #[cfg_attr(not(test), allow(dead_code))] // Called by target/cost continuation roots next. + pub(crate) fn collect(&mut self, state: &GameState, events: &[GameEvent]) { + let mut staged_state = self.rebuild_staged_state(state); + self.overlay + .install_virtual_targeting_source(&mut staged_state); + let operation_journal = std::mem::take(&mut self.operation_journal); + let mut session = + TriggerCollectionSession::transactional(self.overlay.clone(), operation_journal); + let contexts = collect_pending_triggers_with_collection( + &mut staged_state, + events, + LogicalZoneTriggerCollection::Ordinary, + &mut session, + ); + self.operation_journal = session.into_operation_journal(); + self.pending_contexts.extend(contexts); + } + + /// Commit collector consequences exactly once and append the retained + /// contexts to the caller's deferred delivery list. + #[cfg_attr(not(test), allow(dead_code))] // Called by the activated stack-push boundary next. + pub(crate) fn commit_into( + self, + state: &mut GameState, + deferred_contexts: &mut Vec, + ) { + for operation in self.operation_journal { + let _ = TriggerCollectionSession::apply_operation(state, operation); + } + deferred_contexts.extend(self.pending_contexts); + } + + fn rebuild_staged_state(&self, state: &GameState) -> GameState { + let mut staged_state = state.clone(); + for operation in &self.operation_journal { + let _ = TriggerCollectionSession::apply_operation(&mut staged_state, operation.clone()); + } + staged_state + } + + #[cfg(test)] + fn staged_state_for_test(&self, state: &GameState) -> GameState { + self.rebuild_staged_state(state) + } +} + /// Installs one CR 603.7 delayed triggered ability and journals it. /// /// SINGLE AUTHORITY for adding to `GameState::delayed_triggers`. Every rules @@ -2278,7 +2716,27 @@ fn collect_pending_triggers( state: &mut GameState, events: &[GameEvent], ) -> Vec { - collect_pending_triggers_with_collection(state, events, LogicalZoneTriggerCollection::Ordinary) + collect_pending_triggers_with_overlay(state, events, TriggerCollectionOverlay::default()) +} + +/// Collect trigger contexts using a read-only authority for an announced +/// activation that has not yet been committed to the physical stack. +/// +/// This is collection only; callers remain responsible for placing the returned +/// contexts in the appropriate deferred queue exactly once at their transaction +/// commit boundary. +pub(crate) fn collect_pending_triggers_with_overlay( + state: &mut GameState, + events: &[GameEvent], + overlay: TriggerCollectionOverlay, +) -> Vec { + let mut session = TriggerCollectionSession::new(overlay); + collect_pending_triggers_with_collection( + state, + events, + LogicalZoneTriggerCollection::Ordinary, + &mut session, + ) } /// Collect one explicit delivery segment of a logical zone-change owner. @@ -2291,10 +2749,12 @@ pub(crate) fn collect_logical_zone_trigger_segment( group: &LogicalZoneChangeGroup, events: &[GameEvent], ) -> Vec { + let mut session = TriggerCollectionSession::new(TriggerCollectionOverlay::default()); collect_pending_triggers_with_collection( state, events, LogicalZoneTriggerCollection::Segment(group), + &mut session, ) } @@ -2712,6 +3172,7 @@ fn collect_pending_triggers_with_collection( state: &mut GameState, events: &[GameEvent], collection: LogicalZoneTriggerCollection<'_>, + session: &mut TriggerCollectionSession, ) -> Vec { // CR 603.6a + CR 611.2e: Continuous effects (including statics that grant // triggered abilities to a class — sliver-lord pattern) apply the moment @@ -2721,8 +3182,6 @@ fn collect_pending_triggers_with_collection( // battlefield. Flushing pending layer evaluation here guarantees // `obj.trigger_definitions` and `obj.keywords` reflect all active // continuous effects before this pass scans for matching triggers. - super::layers::flush_layers(state); - reconcile_off_zone_keyword_triggers(state); // CR 701.26 + CR 603.4: Observe every `PermanentTapped` event in this batch and // bump the per-object tap-count ledger BEFORE any trigger condition is checked, // so a "first time it became tapped this turn" intervening-if @@ -2730,14 +3189,13 @@ fn collect_pending_triggers_with_collection( // `collect_pending_triggers` is the single chokepoint all trigger collection // funnels through (combat declaration, mana taps, cost taps, crew), so combat + // effect + crew taps all count here regardless of which producer emitted them. - observe_object_taps(state, events); // CR 122.1 + CR 603.4: Bump the per-object counter-placement OCCURRENCE ledger // for this batch (deduped across a multi-KIND placement) BEFORE any trigger // condition is checked, so a "first time counters have been put on it this // turn" intervening-if (`FirstTimeObjectCountersAddedThisTurn`) reads // occurrence-count == 1 for the placement that just fired. Same chokepoint as // the tap ledger, so effect, ETB, and ability counter placements all count. - observe_object_counter_placements(state, events); + session.prepare(state, events); let mut pending: Vec = Vec::new(); // CR 603.2c: Track which batched triggers (source_id, trig_idx) have already // fired in this pass so "one or more" triggers fire at most once per batch. @@ -2896,14 +3354,7 @@ fn collect_pending_triggers_with_collection( if audit_trigger_index { production_matched.insert((obj_id, matched.trig_idx)); } - record_trigger_fired_with_ref( - state, - matched.constraint.as_ref(), - matched.pending.ability.trigger_source.as_ref(), - matched.definition_ref.as_ref(), - event, - ); - record_matched_batched_zone_change_replay(state, &matched); + session.record_match(state, &matched, event); if matched.batched { batched_this_pass.insert((obj_id, matched.trig_idx)); } @@ -3154,19 +3605,11 @@ fn collect_pending_triggers_with_collection( } = event { if *targeted_id == obj_id { - // Look up source controller. For spells, StackEntry.id matches source_id. - // For activated abilities, StackEntry.source_id matches (the permanent), - // and the fallback via state.objects finds the permanent's controller. - let source_controller = state - .stack - .iter() - .find(|e| { - e.id == *targeting_source_id || e.source_id == *targeting_source_id - }) - .map(|e| e.controller) - .or_else(|| { - state.objects.get(targeting_source_id).map(|o| o.controller) - }); + // Prefer the prepared activation's virtual authority. + // A committed spell/ability still resolves through its + // physical stack entry, then the live source fallback. + let source_controller = + session.targeting_source_controller(state, *targeting_source_id); // CR 702.21a + CR 611.3 + CR 613.11: An active // `SuppressTriggers { events ∋ BecomesTargeted }` static @@ -3318,14 +3761,7 @@ fn collect_pending_triggers_with_collection( }; if !matched_triggers.is_empty() { for matched in matched_triggers { - record_trigger_fired_with_ref( - state, - matched.constraint.as_ref(), - matched.pending.ability.trigger_source.as_ref(), - matched.definition_ref.as_ref(), - event, - ); - record_matched_batched_zone_change_replay(state, &matched); + session.record_match(state, &matched, event); if matched.batched { batched_this_pass.insert((*moved_id, matched.trig_idx)); } @@ -3370,14 +3806,7 @@ fn collect_pending_triggers_with_collection( ) }; for matched in matched_triggers { - record_trigger_fired_with_ref( - state, - matched.constraint.as_ref(), - matched.pending.ability.trigger_source.as_ref(), - matched.definition_ref.as_ref(), - event, - ); - record_matched_batched_zone_change_replay(state, &matched); + session.record_match(state, &matched, event); if matched.batched { batched_this_pass.insert((*exploiter, matched.trig_idx)); } @@ -3477,14 +3906,7 @@ fn collect_pending_triggers_with_collection( } } for matched in matched_triggers { - record_trigger_fired_with_ref( - state, - matched.constraint.as_ref(), - matched.pending.ability.trigger_source.as_ref(), - matched.definition_ref.as_ref(), - event, - ); - record_matched_batched_zone_change_replay(state, &matched); + session.record_match(state, &matched, event); if matched.batched { batched_this_pass.insert((observer_id, matched.trig_idx)); } @@ -3541,14 +3963,7 @@ fn collect_pending_triggers_with_collection( }; for matched in matched_triggers { - record_trigger_fired_with_ref( - state, - matched.constraint.as_ref(), - matched.pending.ability.trigger_source.as_ref(), - matched.definition_ref.as_ref(), - event, - ); - record_matched_batched_zone_change_replay(state, &matched); + session.record_match(state, &matched, event); if matched.batched { batched_this_pass.insert((obj_id, matched.trig_idx)); } @@ -3625,7 +4040,7 @@ fn collect_pending_triggers_with_collection( // synthesized trigger is only collected from SpellCast, // which is only emitted for an actual cast, so cast-ness is // already implied by the trigger event itself. - let timestamp = state.next_timestamp() as u32; + let timestamp = session.next_timestamp(state) as u32; pending.push(PendingTriggerContext::single(PendingTrigger { source_id: *cast_obj_id, controller: *caster, @@ -3687,7 +4102,7 @@ fn collect_pending_triggers_with_collection( if let Some(source_context) = &cast_source_context { cascade_ability.set_trigger_source_recursive(source_context.clone()); } - let timestamp = state.next_timestamp() as u32; + let timestamp = session.next_timestamp(state) as u32; pending.push(PendingTriggerContext::single(PendingTrigger { source_id: *cast_obj_id, controller, @@ -3747,7 +4162,7 @@ fn collect_pending_triggers_with_collection( if let Some(source_context) = &cast_source_context { ripple_ability.set_trigger_source_recursive(source_context.clone()); } - let timestamp = state.next_timestamp() as u32; + let timestamp = session.next_timestamp(state) as u32; pending.push(PendingTriggerContext::single(PendingTrigger { source_id: *cast_obj_id, controller: ripple_controller, @@ -3862,7 +4277,7 @@ fn collect_pending_triggers_with_collection( if let Some(source_context) = &cast_source_context { casualty_ability.set_trigger_source_recursive(source_context.clone()); } - let timestamp = state.next_timestamp() as u32; + let timestamp = session.next_timestamp(state) as u32; pending.push(PendingTriggerContext::single(PendingTrigger { source_id: *cast_obj_id, controller: dynamically_granted_casualty_instances.1, @@ -3939,7 +4354,7 @@ fn collect_pending_triggers_with_collection( if let Some(source_context) = &cast_source_context { conspire_ability.set_trigger_source_recursive(source_context.clone()); } - let timestamp = state.next_timestamp() as u32; + let timestamp = session.next_timestamp(state) as u32; pending.push(PendingTriggerContext::single(PendingTrigger { source_id: *cast_obj_id, controller: dynamically_granted_conspire_instances.1, @@ -4011,7 +4426,7 @@ fn collect_pending_triggers_with_collection( if let Some(source_context) = &cast_source_context { replicate_ability.set_trigger_source_recursive(source_context.clone()); } - let timestamp = state.next_timestamp() as u32; + let timestamp = session.next_timestamp(state) as u32; pending.push(PendingTriggerContext::single(PendingTrigger { source_id: *cast_obj_id, controller, @@ -4069,7 +4484,7 @@ fn collect_pending_triggers_with_collection( if let Some(source_context) = &cast_source_context { demonstrate_ability.set_trigger_source_recursive(source_context.clone()); } - let timestamp = state.next_timestamp() as u32; + let timestamp = session.next_timestamp(state) as u32; pending.push(PendingTriggerContext::single(PendingTrigger { source_id: *cast_obj_id, controller: dynamically_granted_demonstrate_instances.1, @@ -4189,21 +4604,15 @@ fn collect_pending_triggers_with_collection( // creature types AT DAMAGE TIME (LKI) before any later mutation. let controller = source_obj.controller; let source_subtypes = source_obj.card_types.subtypes.clone(); - if is_assassin_creature || is_commander { - state - .assassin_or_commander_dealt_combat_damage_this_turn - .insert(controller); - } // CR 702.76a: record this source's creature types under its // controller so Prowl can later check "had any of this spell's // creature types". A source with no subtypes contributes nothing. - if !source_subtypes.is_empty() { - state.creature_types_dealt_combat_damage_this_turn.extend( - source_subtypes - .into_iter() - .map(|creature_type| (controller, creature_type)), - ); - } + session.record_combat_damage_casting_permission( + state, + controller, + is_assassin_creature || is_commander, + source_subtypes, + ); } } @@ -4337,7 +4746,7 @@ fn collect_pending_triggers_with_collection( subject_match_count: None, die_result: None, })); - mark_speed_trigger_used(state, trigger_controller); + session.mark_speed_trigger_used(state, trigger_controller); } } @@ -7330,7 +7739,7 @@ pub fn check_delayed_triggers(state: &mut GameState, events: &[GameEvent]) -> Ve new_events } -fn trigger_event_occurrence(events: &[GameEvent], event_index: usize) -> usize { +pub(crate) fn trigger_event_occurrence(events: &[GameEvent], event_index: usize) -> usize { let event = &events[event_index]; events[..event_index] .iter() @@ -18913,6 +19322,239 @@ pub mod tests { ); } + #[test] + fn activation_overlay_supplies_controller_before_stack_commit() { + let make_state = || { + let mut state = setup(); + state.active_player = PlayerId(0); + let source = create_object( + &mut state, + CardId(9), + PlayerId(0), + "Activation Source".to_string(), + Zone::Battlefield, + ); + let creature = create_object( + &mut state, + CardId(10), + PlayerId(0), + "Ward Creature".to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&creature).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + obj.entered_battlefield_turn = Some(1); + obj.keywords + .push(Keyword::Ward(WardCost::Mana(ManaCost::generic(2)))); + (state, source, creature) + }; + + // The source has no StackEntry, and its live controller deliberately + // differs from the prepared authority. This proves collection uses the + // announce-time controller rather than re-reading mutable battlefield + // state while the activation awaits its transaction commit. + let (mut without_overlay, prepared_source, ward_target) = make_state(); + let event = GameEvent::BecomesTarget { + target: TargetRef::Object(ward_target), + source_id: prepared_source, + }; + assert!( + collect_pending_triggers(&mut without_overlay, std::slice::from_ref(&event)).is_empty(), + "the ordinary collector must use the live source controller" + ); + + let (mut with_overlay, prepared_source, ward_target) = make_state(); + let event = GameEvent::BecomesTarget { + target: TargetRef::Object(ward_target), + source_id: prepared_source, + }; + let pending = collect_pending_triggers_with_overlay( + &mut with_overlay, + &[event], + TriggerCollectionOverlay::for_activated_ability(prepared_source, PlayerId(1)), + ); + + assert_eq!( + pending.len(), + 1, + "opponent-controlled prepared activation must trigger ward" + ); + assert_eq!(pending[0].pending.source_id, ward_target); + assert_eq!(pending[0].pending.controller, PlayerId(0)); + assert!(pending[0].pending.ability.unless_pay.is_some()); + assert!( + with_overlay.stack.is_empty(), + "collection remains non-dispatching; the future activation transaction owns commit" + ); + } + + #[test] + fn prepared_activation_overlay_matches_source_filtered_stack_ability_before_commit() { + let mut state = setup(); + let source = create_object( + &mut state, + CardId(9), + PlayerId(1), + "Prepared Ability Source".to_string(), + Zone::Battlefield, + ); + let observer = create_object( + &mut state, + CardId(10), + PlayerId(0), + "Ability Observer".to_string(), + Zone::Battlefield, + ); + let mut ability = ResolvedAbility::new( + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Any, + damage_source: None, + excess: None, + }, + vec![TargetRef::Object(observer)], + source, + PlayerId(1), + ); + ability.source_incarnation = Some(7); + ability.context.ability_tag = Some(crate::types::ability::AbilityTag::Backup); + + let overlay = TriggerCollectionOverlay::for_prepared_activated_ability( + source, + PlayerId(1), + 4, + &ability, + ); + assert_eq!( + overlay + .virtual_targeting_source + .as_ref() + .and_then(|source| source.source_occurrence), + Some(7), + "the pending session must retain the source occurrence" + ); + let mut staged = state.clone(); + overlay.install_virtual_targeting_source(&mut staged); + let projected = staged.stack.front().expect("virtual ability is projected"); + assert_eq!(projected.controller, PlayerId(1)); + assert_eq!( + projected + .ability() + .and_then(|ability| ability.ability_index), + Some(4), + "the projection must preserve the printed activated-ability index" + ); + + let mut trigger = TriggerDefinition::new(TriggerMode::BecomesTarget); + trigger.valid_source = Some(TargetFilter::StackAbility { + controller: Some(ControllerRef::Opponent), + tag: Some(crate::types::ability::AbilityTag::Backup), + kind: Some(crate::types::ability::StackAbilityKind::Activated), + }); + let event = GameEvent::BecomesTarget { + target: TargetRef::Object(observer), + source_id: source, + }; + assert!(crate::game::trigger_matchers::match_becomes_target( + &event, + &trigger, + &crate::game::trigger_matchers::test_trigger_source_context(&staged, observer), + &staged, + )); + } + + #[test] + fn pending_activation_collection_commits_once_and_drop_discards_everything() { + let mut state = setup(); + state.active_player = PlayerId(0); + let source = create_object( + &mut state, + CardId(9), + PlayerId(0), + "Activation Source".to_string(), + Zone::Battlefield, + ); + let ward_target = create_object( + &mut state, + CardId(10), + PlayerId(0), + "Ward Creature".to_string(), + Zone::Battlefield, + ); + let ward = state.objects.get_mut(&ward_target).unwrap(); + ward.card_types.core_types.push(CoreType::Creature); + ward.entered_battlefield_turn = Some(1); + ward.keywords + .push(Keyword::Ward(WardCost::Mana(ManaCost::generic(2)))); + + let tapped = ObjectId(71); + let mut pending = + PendingActivationTriggerCollection::for_activated_ability(source, PlayerId(1)); + pending.collect( + &state, + &[GameEvent::BecomesTarget { + target: TargetRef::Object(ward_target), + source_id: source, + }], + ); + let serialized = serde_json::to_string(&pending) + .expect("pending activation collector must serialize for a resumed prompt"); + let mut pending: PendingActivationTriggerCollection = serde_json::from_str(&serialized) + .expect("serialized pending activation collector must restore"); + pending.collect( + &state, + &[GameEvent::PermanentTapped { + object_id: tapped, + caused_by: None, + }], + ); + pending.collect( + &state, + &[GameEvent::PermanentTapped { + object_id: tapped, + caused_by: None, + }], + ); + + assert_eq!( + pending + .staged_state_for_test(&state) + .object_tap_count_this_turn + .get(&tapped), + Some(&2), + "the later pending-payment event must see the earlier staged collector write" + ); + assert_eq!(pending.pending_contexts.len(), 1); + assert!( + state.object_tap_count_this_turn.is_empty() && state.stack.is_empty(), + "pending collection must not mutate or dispatch through live state" + ); + + let mut deferred = Vec::new(); + pending.commit_into(&mut state, &mut deferred); + assert_eq!(state.object_tap_count_this_turn.get(&tapped), Some(&2)); + assert_eq!(deferred.len(), 1); + assert_eq!(deferred[0].pending.source_id, ward_target); + + let cancelled_tap = ObjectId(72); + let mut cancelled = + PendingActivationTriggerCollection::for_activated_ability(source, PlayerId(1)); + cancelled.collect( + &state, + &[GameEvent::PermanentTapped { + object_id: cancelled_tap, + caused_by: None, + }], + ); + drop(cancelled); + assert!( + !state + .object_tap_count_this_turn + .contains_key(&cancelled_tap), + "cancelling the activation by dropping its session must discard collector writes" + ); + } + #[test] fn ward_trigger_does_not_fire_without_ward() { let mut state = setup(); @@ -19426,6 +20068,48 @@ pub mod tests { assert_eq!(state.object_tap_count_this_turn.get(&b).copied(), Some(1)); } + /// CR 701.26 + CR 122.1 + CR 603.4: the collector session owns the + /// pre-match event-observation write. It preserves the tap ledger's + /// per-event count while deduplicating counter placements per object in one + /// emitted event batch. + #[test] + fn trigger_collection_session_applies_event_observation_operation() { + let mut state = setup(); + let tapped = ObjectId(71); + let countered = ObjectId(72); + let events = vec![ + GameEvent::PermanentTapped { + object_id: tapped, + caused_by: None, + }, + GameEvent::PermanentTapped { + object_id: tapped, + caused_by: None, + }, + GameEvent::CounterAdded { + object_id: countered, + counter_type: CounterType::Lore, + count: 1, + }, + GameEvent::CounterAdded { + object_id: countered, + counter_type: CounterType::Plus1Plus1, + count: 1, + }, + ]; + + TriggerCollectionSession::new(TriggerCollectionOverlay::default()) + .prepare(&mut state, &events); + + assert_eq!(state.object_tap_count_this_turn.get(&tapped), Some(&2)); + assert_eq!( + state + .object_counter_placement_count_this_turn + .get(&countered), + Some(&1), + ); + } + /// CR 120.1 + CR 108.3: `DamagedPlayerIsEventSourceOwner` holds only when the /// damaged player is the OWNER of the damaging object (The Beast). A creature /// owned by P0 dealing combat damage to P0 satisfies it; to P1 does not; an diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index 62d1b46bb5..ee14d8c442 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -79,6 +79,16 @@ pub(crate) fn capture_library_search_card_view( /// viewer is explicitly allowed to see them. pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState { let mut filtered = state.clone(); + // Pending activation trigger collection retains source contexts and the + // uncommitted event journal solely for rules execution. The pending ability + // itself remains public, but this implementation carrier is never part of a + // viewer projection — including the activating player's projection. + if let Some(pending) = filtered.pending_cast.as_mut() { + pending.activation_trigger_collection = None; + } + if let Some(pending) = filtered.waiting_for.pending_cast_mut() { + pending.activation_trigger_collection = None; + } // Interaction capability authority is trusted persistence state. Viewer // projections expose only the actor-scoped opaque opportunity IDs produced // by `game::interaction`, never the session/serial/slot minting ledger. @@ -1905,7 +1915,9 @@ mod tests { activation_residual: crate::types::game_state::ActivationResidual::None, activation_target_selection: crate::types::game_state::ActivationTargetSelection::Pending, + activation_cost_committed: false, alt_cost_grant_source: None, + activation_trigger_collection: None, }) } diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 5b76725066..6c7a54bfbf 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -5583,6 +5583,11 @@ pub struct PendingCast { /// through a cost-payment continuation without conflating it with cost legs. #[serde(default, skip_serializing_if = "ActivationTargetSelection::is_pending")] pub activation_target_selection: ActivationTargetSelection, + /// CR 601.2h + CR 602.2b: An activation cannot be cancelled once any + /// component of its cost has been paid, because that payment is not a + /// rollback-able announcement choice. + #[serde(default)] + pub activation_cost_committed: bool, /// CR 118.9 + CR 601.2b: When this cast is offered a once-per-turn /// `CastWithAlternativeCost` grant (As Foretold), the granting permanent's id. /// Carried across the `OptionalCostChoice` round-trip so the accept handler can @@ -5590,6 +5595,16 @@ pub struct PendingCast { /// `Unlimited` grants. #[serde(default, skip_serializing_if = "Option::is_none")] pub alt_cost_grant_source: Option, + /// CR 602.2a-b + CR 603.2: Trigger consequences from a target-bearing + /// activation are collected while its targets and costs are announced, but + /// become globally visible only when the ability reaches the stack. + /// + /// The carrier is durable across interactive cost prompts and is redacted + /// from every per-viewer state projection; its journal is engine-private + /// implementation state, not public pending-cast information. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) activation_trigger_collection: + Option>, } fn default_origin_zone() -> Zone { @@ -5914,6 +5929,14 @@ pub enum PendingCostMoveResume { pending: Box, cursor: ManaAbilityCostCursor, }, + /// CR 602.2b + CR 701.17a + CR 616.1: An activation's deterministic mill + /// cost paused while a replacement choice settles. Resume the serialized + /// activation suffix only after the mill event has reached a terminal + /// replacement outcome. + ActivationMillPayment { + player: PlayerId, + pending: Box, + }, /// CR 606.4 + CR 614.1a + CR 616.1: A loyalty activation paused on a /// counter-cost replacement-ordering choice (e.g. Doubling Season vs an /// opponent's Vorinclex halving the loyalty counters). The counter is @@ -5983,7 +6006,9 @@ impl PendingCast { assist_state: AssistState::NotOffered, activation_residual: ActivationResidual::None, activation_target_selection: ActivationTargetSelection::Pending, + activation_cost_committed: false, alt_cost_grant_source: None, + activation_trigger_collection: None, } } @@ -5991,6 +6016,73 @@ impl PendingCast { self.payment_mode = payment_mode; self } + + /// Starts the trigger transaction for an announced, target-bearing + /// activated ability. + /// + /// CR 602.2a-b + CR 603.2: Target declaration can make triggers fire + /// before costs are paid, while the activated ability does not exist on + /// the physical stack until the announcement completes. + pub(crate) fn begin_activation_trigger_collection(&mut self) { + if self.activation_trigger_collection.is_none() { + let ability_index = self + .activation_ability_index + .expect("target-bearing activation session requires an ability index"); + self.activation_trigger_collection = Some(Box::new( + crate::game::triggers::PendingActivationTriggerCollection::for_prepared_activated_ability( + self.object_id, + self.ability.controller, + ability_index, + &self.ability, + ), + )); + } + } + + /// CR 601.2h + CR 602.2b: Marks an announced activation as having paid an + /// irreversible cost component, so it can no longer be cancelled. + pub(crate) fn mark_activation_cost_committed(&mut self) { + if self.activation_ability_index.is_some() { + self.activation_cost_committed = true; + } + } +} + +impl GameState { + /// Temporarily removes the activation-local trigger transaction from the + /// pending cast that currently owns it. + /// + /// `ManaPayment` stores its `PendingCast` in `GameState::pending_cast`; + /// every other interactive casting prompt embeds it in `WaitingFor`. + /// Keeping that routing distinction here prevents individual cost handlers + /// from choosing the wrong continuation carrier. + pub(crate) fn take_pending_activation_trigger_collection( + &mut self, + ) -> Option> { + if let Some(pending) = self.pending_cast.as_mut() { + return pending.activation_trigger_collection.take(); + } + self.waiting_for + .pending_cast_mut()? + .activation_trigger_collection + .take() + } + + /// Restores the transaction removed by + /// [`Self::take_pending_activation_trigger_collection`] to the same active + /// casting continuation. + pub(crate) fn restore_pending_activation_trigger_collection( + &mut self, + collection: Box, + ) { + if let Some(pending) = self.pending_cast.as_mut() { + pending.activation_trigger_collection = Some(collection); + return; + } + if let Some(pending) = self.waiting_for.pending_cast_mut() { + pending.activation_trigger_collection = Some(collection); + } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -21238,7 +21330,9 @@ mod tests { assist_state: AssistState::NotOffered, activation_residual: ActivationResidual::None, activation_target_selection: ActivationTargetSelection::Pending, + activation_cost_committed: false, alt_cost_grant_source: None, + activation_trigger_collection: None, }) } @@ -21647,7 +21741,9 @@ mod tests { assist_state: AssistState::NotOffered, activation_residual: ActivationResidual::None, activation_target_selection: ActivationTargetSelection::Pending, + activation_cost_committed: false, alt_cost_grant_source: None, + activation_trigger_collection: None, }); let choose_x = WaitingFor::ChooseXValue { player: PlayerId(0), diff --git a/crates/engine/tests/integration/bound_by_moonsilver_sacrifice_source_relative_6017.rs b/crates/engine/tests/integration/bound_by_moonsilver_sacrifice_source_relative_6017.rs index a9debd6201..6903ac26e6 100644 --- a/crates/engine/tests/integration/bound_by_moonsilver_sacrifice_source_relative_6017.rs +++ b/crates/engine/tests/integration/bound_by_moonsilver_sacrifice_source_relative_6017.rs @@ -26,6 +26,7 @@ //! CR 613.4c: outside per-recipient layer contexts, "other" is source-relative. use engine::game::scenario::{GameScenario, P0}; +use engine::types::ability::TargetRef; use engine::types::actions::GameAction; use engine::types::game_state::{PayCostKind, WaitingFor}; use engine::types::phase::Phase; @@ -65,9 +66,8 @@ fn bound_by_moonsilver_sacrifice_another_is_source_relative() { state.layers_dirty.mark_full(); } - // Activate the Aura's "Sacrifice another permanent: Attach ..." ability. - // Sacrifice-cost payment is prompted BEFORE target selection, so the - // eligible sacrifice set is surfaced immediately. + // CR 602.2b + CR 601.2c: declare the attachment target before paying the + // "Sacrifice another permanent" cost. runner .act(GameAction::ActivateAbility { source_id: aura, @@ -75,6 +75,21 @@ fn bound_by_moonsilver_sacrifice_another_is_source_relative() { }) .expect("activating Bound by Moonsilver's sacrifice ability must succeed"); + let WaitingFor::TargetSelection { target_slots, .. } = &runner.state().waiting_for else { + panic!( + "expected attachment target selection before the sacrifice cost, got {:?}", + runner.state().waiting_for + ); + }; + assert!(target_slots[0] + .legal_targets + .contains(&TargetRef::Object(host))); + runner + .act(GameAction::SelectTargets { + targets: vec![TargetRef::Object(host)], + }) + .expect("selecting the Aura's attachment target must succeed"); + let choices = match &runner.state().waiting_for { WaitingFor::PayCost { kind: PayCostKind::Sacrifice, diff --git a/crates/engine/tests/integration/cost_x_carrier_runtime.rs b/crates/engine/tests/integration/cost_x_carrier_runtime.rs index f5a6a3a715..23fb1530c8 100644 --- a/crates/engine/tests/integration/cost_x_carrier_runtime.rs +++ b/crates/engine/tests/integration/cost_x_carrier_runtime.rs @@ -673,7 +673,7 @@ fn filter_mana_value_bound_binds_x_and_destroys_within_bound() { let mut scenario = GameScenario::new(); scenario.at_phase(Phase::PreCombatMain); let relic = scenario - .add_creature(P1, "Worn Powerstone", 0, 0) + .add_creature(P1, "Worn Powerstone", 1, 1) .as_artifact() .id(); let ooze = { @@ -737,7 +737,7 @@ fn filter_mana_value_bound_excludes_targets_above_x() { let mut scenario = GameScenario::new(); scenario.at_phase(Phase::PreCombatMain); let relic = scenario - .add_creature(P1, "Worn Powerstone", 0, 0) + .add_creature(P1, "Worn Powerstone", 1, 1) .as_artifact() .id(); let ooze = { diff --git a/crates/engine/tests/integration/cost_zone_pipeline.rs b/crates/engine/tests/integration/cost_zone_pipeline.rs index 3d1449b728..6fce1fffa3 100644 --- a/crates/engine/tests/integration/cost_zone_pipeline.rs +++ b/crates/engine/tests/integration/cost_zone_pipeline.rs @@ -1810,6 +1810,102 @@ fn paused_sacrifice_cost_stamps_cross_action_departures_and_collects_dies_once() ); } +#[test] +fn target_activation_replacement_paused_sacrifice_stages_cost_triggers_until_commit() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let source = scenario + .add_creature(P0, "Targeted Sacrifice Replacement Witness", 1, 1) + .with_ability_definition( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Any, + damage_source: None, + excess: None, + }, + ) + .cost(AbilityCost::Sacrifice(SacrificeCost::count( + TargetFilter::Typed(TypedFilter::new(TypeFilter::Creature)), + 2, + ))), + ) + .id(); + let first = scenario + .add_creature(P0, "First Targeted Sacrifice Witness", 1, 1) + .id(); + let second = scenario + .add_creature(P0, "Second Targeted Sacrifice Witness", 1, 1) + .with_replacement_definition(redirect_self_moved_to(Zone::Graveyard, Zone::Exile)) + .with_replacement_definition(redirect_self_moved_to(Zone::Graveyard, Zone::Hand)) + .id(); + let target = scenario.add_creature(P1, "Target", 2, 2).id(); + let mut runner = scenario.build(); + runner + .state_mut() + .objects + .get_mut(&first) + .unwrap() + .trigger_definitions + .push( + TriggerDefinition::new(TriggerMode::ChangesZone) + .valid_card(TargetFilter::SelfRef) + .origin(Zone::Battlefield) + .destination(Zone::Graveyard) + .trigger_zones(vec![Zone::Battlefield]) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 1 }, + player: TargetFilter::Controller, + }, + )), + ); + + runner + .act(GameAction::ActivateAbility { + source_id: source, + ability_index: 0, + }) + .expect("targeted activation starts with target selection"); + runner + .act(GameAction::SelectTargets { + targets: vec![TargetRef::Object(target)], + }) + .expect("target is declared before the sacrifice cost"); + runner + .act(GameAction::SelectCards { + cards: vec![first, second], + }) + .expect("second sacrifice reaches the replacement pipeline"); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::ReplacementChoice { .. } + )); + assert!( + runner.state().deferred_triggers.is_empty(), + "the first sacrifice event stays inside the pending activation session" + ); + + runner + .act(GameAction::ChooseReplacement { index: 0 }) + .expect("replacement completion commits the activation"); + assert_eq!( + runner + .state() + .stack + .iter() + .filter(|entry| matches!( + entry.kind, + StackEntryKind::TriggeredAbility { source_id, .. } if source_id == first + )) + .count(), + 1, + "the replacement-paused sacrifice trigger is collected once at activation commit" + ); +} + #[test] fn self_sacrifice_mana_cost_waits_for_replacement_before_producing_mana() { let mut scenario = GameScenario::new(); diff --git a/crates/engine/tests/integration/greater_good_activation.rs b/crates/engine/tests/integration/greater_good_activation.rs index 4f51d1be73..2f99452097 100644 --- a/crates/engine/tests/integration/greater_good_activation.rs +++ b/crates/engine/tests/integration/greater_good_activation.rs @@ -607,13 +607,12 @@ fn multi_sacrifice_cost_resolves_through_pipeline() { } } -/// CR 601.2c + CR 701.21a — non-modal activated abilities with sacrifice -/// costs must pay that cost before choosing targets when target legality or -/// effect quantities depend on the sacrificed object. This mirrors Dina-style -/// abilities that sacrifice one creature, then target another creature with an -/// effect sized by `CostPaidObject`. +/// CR 602.2b + CR 601.2c/h — target declaration precedes paying a sacrifice +/// cost even when the effect's later quantity reads the sacrificed object's +/// last-known information. This mirrors Dina-style abilities that sacrifice +/// one creature, then put counters on a target creature based on its power. #[test] -fn sacrifice_cost_defers_target_selection_until_cost_paid_object_exists() { +fn sacrifice_cost_selects_target_before_cost_paid_object_exists() { let ability = AbilityDefinition::new( AbilityKind::Activated, Effect::PutCounter { @@ -640,8 +639,7 @@ fn sacrifice_cost_defers_target_selection_until_cost_paid_object_exists() { .id(); let victim_id = scenario.add_creature(P0, "Victim", 3, 3).id(); let target_id = scenario.add_creature(P0, "Counter Target", 1, 1).id(); - // A second legal creature target so auto-select does not skip the post-sacrifice - // target prompt when only one creature remains after the victim leaves play. + // A second legal creature target keeps target selection interactive. scenario.add_creature(P0, "Alternate Target", 2, 2); let mut runner = scenario.build(); @@ -654,35 +652,25 @@ fn sacrifice_cost_defers_target_selection_until_cost_paid_object_exists() { assert!( matches!( &runner.state().waiting_for, - engine::types::WaitingFor::PayCost { - kind: engine::types::PayCostKind::Sacrifice, - .. - } + engine::types::WaitingFor::TargetSelection { .. } ), - "activating must prompt for sacrifice before target selection, got {:?}", + "activating must prompt for a target before the sacrifice cost, got {:?}", runner.state().waiting_for, ); - - runner - .act(GameAction::SelectCards { - cards: vec![victim_id], - }) - .expect("selecting the sacrifice victim must succeed"); - match &runner.state().waiting_for { engine::types::WaitingFor::TargetSelection { target_slots, .. } => { assert_eq!(target_slots.len(), 1, "the effect has one target slot"); let legal_targets = &target_slots[0].legal_targets; assert!( legal_targets.contains(&TargetRef::Object(target_id)), - "the post-sacrifice target creature must be legal", + "the declared target creature must be legal before payment", ); assert!( - !legal_targets.contains(&TargetRef::Object(victim_id)), - "the sacrificed creature must not remain targetable after cost payment", + legal_targets.contains(&TargetRef::Object(victim_id)), + "the sacrifice candidate is legal while targets are declared", ); } - other => panic!("expected target selection after sacrifice cost, got {other:?}"), + other => panic!("expected target selection before sacrifice cost, got {other:?}"), } runner @@ -690,12 +678,17 @@ fn sacrifice_cost_defers_target_selection_until_cost_paid_object_exists() { targets: vec![TargetRef::Object(target_id)], }) .expect("selecting the remaining creature target must succeed"); + runner + .act(GameAction::SelectCards { + cards: vec![victim_id], + }) + .expect("selecting the sacrifice victim after target declaration must succeed"); runner.advance_until_stack_empty(); assert_eq!( runner.state().objects[&victim_id].zone, Zone::Graveyard, - "the victim must be sacrificed before target selection", + "the victim must be sacrificed after target selection", ); assert_eq!( runner.state().objects[&target_id] diff --git a/crates/engine/tests/integration/issue_1301_cauldron_of_essence_sacrifice_target.rs b/crates/engine/tests/integration/issue_1301_cauldron_of_essence_sacrifice_target.rs index 2cb63ef1a7..5df321898c 100644 --- a/crates/engine/tests/integration/issue_1301_cauldron_of_essence_sacrifice_target.rs +++ b/crates/engine/tests/integration/issue_1301_cauldron_of_essence_sacrifice_target.rs @@ -11,13 +11,8 @@ //! targets were determined. The card's own Oracle rulings confirm this //! explicitly: "Because targets are chosen before costs are paid, the target //! of Cauldron of Essence's last ability can't be the creature sacrificed to -//! pay its cost." This engine pays non-self Sacrifice/Discard/Exile -//! activation costs BEFORE target selection as a documented shortcut (see -//! `push_activated_ability_to_stack` in `casting_costs.rs`), which let the -//! just-sacrificed permanent slip back in as a legal "creature card in your -//! graveyard" target. The fix excludes a cost-paid object that left the -//! battlefield from legal targets for any other slot of the same activation -//! (`exclude_cost_paid_object_that_left_battlefield` in `ability_utils.rs`). +//! pay its cost." The activation pipeline therefore determines legal graveyard +//! targets before paying the non-self sacrifice cost. use engine::game::engine::apply_as_current; use engine::game::scenario::{GameScenario, P0}; @@ -148,24 +143,19 @@ fn cauldron_of_essence_rejects_sacrificed_creature_as_its_own_return_target() { let mut runner = scenario.build(); engine::game::rehydrate_game_from_card_db(runner.state_mut(), db); - apply_as_current( + let result = apply_as_current( runner.state_mut(), GameAction::ActivateAbility { source_id: cauldron, ability_index: 0, }, - ) - .expect("activating Cauldron of Essence should be legal with lands and a sacrifice target"); - - let result = apply_as_current( - runner.state_mut(), - GameAction::SelectCards { cards: vec![token] }, ); assert!( result.is_err(), - "sacrificing the only creature card that could become a graveyard target must fail \ - the activation rather than silently targeting the sacrificed creature, got {:?}", + "without a creature card already in the graveyard, activation must reject before costs \ + are paid rather than treating the creature to be sacrificed as its future target, got {:?}", result ); + assert!(runner.state().battlefield.contains(&token)); } diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 1b7e4144e3..94f16542de 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -739,6 +739,7 @@ mod mindblade_render_warrior_intervening_if_2867; mod mirror_march_copy_token_exile; mod mizzixs_mastery; mod mjolnir_hammer_double_damage; +mod mogg_fanatic_target_before_cost; mod mogg_war_marshal_echo_dies_trigger; mod morbid_curiosity_cost_paid_mana_value_draw; mod morkrut_ashaya_self_sacrifice; diff --git a/crates/engine/tests/integration/mogg_fanatic_target_before_cost.rs b/crates/engine/tests/integration/mogg_fanatic_target_before_cost.rs new file mode 100644 index 0000000000..d326bca701 --- /dev/null +++ b/crates/engine/tests/integration/mogg_fanatic_target_before_cost.rs @@ -0,0 +1,570 @@ +//! CR 602.2b / CR 601.2c-h — Goblin Bombardment chooses targets before its +//! sacrifice cost. + +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::ability::{ + AbilityCost, AbilityDefinition, AbilityKind, BeholdCostAction, CounterCostSelection, Effect, + QuantityExpr, ReplacementDefinition, SacrificeCost, TapCreaturesRequirement, TargetFilter, + TargetRef, TypeFilter, TypedFilter, REMOVE_COUNTER_COST_X, +}; +use engine::types::actions::GameAction; +use engine::types::counter::{CounterMatch, CounterType}; +use engine::types::game_state::{PayCostKind, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::replacements::ReplacementEvent; +use engine::types::zones::{EtbTapState, Zone}; + +const GOBLIN_BOMBARDMENT: &str = + "Sacrifice a creature: This enchantment deals 1 damage to any target."; + +fn setup() -> (GameRunner, ObjectId, ObjectId, ObjectId, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let bombardment = scenario + .add_creature(P0, "Goblin Bombardment", 0, 0) + .as_enchantment() + .from_oracle_text(GOBLIN_BOMBARDMENT) + .id(); + let fodder = scenario.add_creature(P0, "Sacrifice Goblin", 1, 1).id(); + let target = scenario.add_creature(P1, "Target Goblin", 1, 1).id(); + let self_target = scenario.add_creature(P0, "Self-Target Goblin", 1, 1).id(); + let mut runner = scenario.build(); + for token in [fodder, target, self_target] { + runner.state_mut().objects.get_mut(&token).unwrap().is_token = true; + } + (runner, bombardment, fodder, target, self_target) +} + +fn activate(runner: &mut GameRunner, bombardment: ObjectId) { + runner + .act(GameAction::ActivateAbility { + source_id: bombardment, + ability_index: 0, + }) + .expect("Goblin Bombardment activation must enter target selection"); +} + +fn choose_target(runner: &mut GameRunner, target: ObjectId) { + let WaitingFor::TargetSelection { target_slots, .. } = runner.state().waiting_for.clone() + else { + panic!( + "expected target selection, got {:?}", + runner.state().waiting_for + ); + }; + assert!( + target_slots[0] + .legal_targets + .contains(&TargetRef::Object(target)), + "the selected Goblin must be targetable before the sacrifice cost" + ); + runner + .act(GameAction::SelectTargets { + targets: vec![TargetRef::Object(target)], + }) + .expect("selecting the target must succeed"); +} + +fn sacrifice(runner: &mut GameRunner, permanent: ObjectId, selected_target: ObjectId) { + let WaitingFor::PayCost { kind, choices, .. } = runner.state().waiting_for.clone() else { + panic!( + "expected sacrifice cost, got {:?}", + runner.state().waiting_for + ); + }; + assert_eq!(kind, PayCostKind::Sacrifice); + assert!(choices.contains(&permanent)); + assert!( + runner.state().battlefield.contains(&selected_target), + "the declared target must stay on the battlefield until the sacrifice is paid" + ); + runner + .act(GameAction::SelectCards { + cards: vec![permanent], + }) + .expect("sacrificing the chosen Goblin must succeed"); +} + +#[test] +fn goblin_bombardment_targets_before_sacrifice_and_cancel_is_lossless() { + let (mut runner, bombardment, fodder, target, _) = setup(); + activate(&mut runner, bombardment); + choose_target(&mut runner, target); + + let WaitingFor::PayCost { ref kind, .. } = runner.state().waiting_for else { + panic!("target selection must lead to the sacrifice prompt"); + }; + assert_eq!(kind, &PayCostKind::Sacrifice); + assert!(runner.state().battlefield.contains(&target)); + runner + .act(GameAction::CancelCast) + .expect("the pending sacrifice payment must be cancelable"); + assert!(runner.state().stack.is_empty()); + assert!(runner.state().battlefield.contains(&bombardment)); + assert!(runner.state().battlefield.contains(&fodder)); + assert!(runner.state().battlefield.contains(&target)); + assert!(runner.state().deferred_triggers.is_empty()); + + let (mut runner, bombardment, fodder, target, _) = setup(); + activate(&mut runner, bombardment); + choose_target(&mut runner, target); + sacrifice(&mut runner, fodder, target); + assert_eq!( + runner.state().stack.len(), + 1, + "the activation reaches the stack" + ); + runner.advance_until_stack_empty(); + assert!( + !runner.state().battlefield.contains(&target), + "the untargeted sacrifice must not invalidate the selected Goblin" + ); +} + +#[test] +fn goblin_bombardment_self_target_sacrifice_reaches_stack_and_fizzles() { + let (mut runner, bombardment, fodder, _, self_target) = setup(); + activate(&mut runner, bombardment); + choose_target(&mut runner, self_target); + sacrifice(&mut runner, self_target, self_target); + + assert_eq!( + runner.state().stack.len(), + 1, + "the activation reaches the stack" + ); + runner.advance_until_stack_empty(); + assert!(runner.state().stack.is_empty()); + assert!( + runner.state().battlefield.contains(&fodder), + "only the self-targeted Goblin was sacrificed" + ); +} + +#[test] +fn targeted_activation_surfaces_tap_creatures_cost_after_target_selection() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let source = scenario + .add_creature(P0, "Targeted Tap Cost", 1, 1) + .with_ability_definition( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Any, + damage_source: None, + excess: None, + }, + ) + .cost(AbilityCost::TapCreatures { + requirement: TapCreaturesRequirement::count(1), + filter: TargetFilter::Typed(TypedFilter::creature()), + }), + ) + .id(); + let payment = scenario.add_creature(P0, "Tap Payment", 1, 1).id(); + let target = scenario.add_creature(P1, "Tap Cost Target", 1, 1).id(); + let mut runner = scenario.build(); + + activate(&mut runner, source); + choose_target(&mut runner, target); + + let WaitingFor::PayCost { kind, choices, .. } = runner.state().waiting_for.clone() else { + panic!("target declaration must surface the tap-creatures cost"); + }; + assert_eq!(kind, PayCostKind::TapCreatures { aggregate: None }); + assert!(choices.contains(&payment)); + + runner + .act(GameAction::SelectCards { + cards: vec![payment], + }) + .expect("paying the selected tap-creatures cost must succeed"); + assert!(runner.state().objects[&payment].tapped); + assert_eq!(runner.state().stack.len(), 1); +} + +#[test] +fn target_first_mana_payment_precedes_sacrifice_and_can_be_cancelled() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let source = scenario + .add_creature(P0, "Mana-Gated Bombardment", 0, 1) + .with_ability_definition( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Any, + damage_source: None, + excess: None, + }, + ) + .cost(AbilityCost::Composite { + costs: vec![ + AbilityCost::Sacrifice(SacrificeCost::count( + TargetFilter::Typed(TypedFilter::creature()), + 1, + )), + // CR 107.4e: Hybrid payment is a player decision, so this + // keeps the activation at ManaPayment before any later + // non-mana cost is paid. + AbilityCost::Mana { + cost: ManaCost::Cost { + shards: vec![ManaCostShard::WhiteBlue], + generic: 0, + }, + }, + ], + }), + ) + .id(); + let fodder = scenario.add_creature(P0, "Committed Fodder", 1, 1).id(); + let target = scenario.add_creature(P1, "Mana-Gated Target", 1, 1).id(); + scenario.add_creature_from_oracle( + P0, + "Cost Trigger Witness", + 1, + 1, + "Whenever another creature dies, draw a card.", + ); + let mut runner = scenario.build(); + + activate(&mut runner, source); + choose_target(&mut runner, target); + + assert!(matches!( + runner.state().waiting_for, + WaitingFor::ManaPayment { .. } + )); + assert!( + runner.state().battlefield.contains(&fodder), + "the sacrifice cost is not paid before the mana-payment step" + ); + assert!(runner.state().stack.is_empty()); + assert!( + runner.state().deferred_triggers.is_empty(), + "no death trigger exists before the sacrifice cost is paid" + ); + + runner + .act(GameAction::CancelCast) + .expect("an activation may be cancelled before any cost has been paid"); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::Priority { .. } + )); + assert!( + runner.state().battlefield.contains(&fodder), + "cancelling before payment must preserve the sacrifice fodder" + ); + assert!(runner.state().stack.is_empty()); + assert!( + runner.state().deferred_triggers.is_empty(), + "rejecting cancellation must neither leak nor duplicate the local trigger" + ); +} + +/// CR 601.2c + CR 601.2g-h + CR 602.2b: an activation with an ordinary mana +/// leg and a targeted symbolic remove-X-counters cost must lock its effect +/// target, pay mana, then bind X before it asks which permanent pays counters. +/// +/// The separate target and counter-source objects make this discriminate the +/// post-mana continuation: surfacing the symbolic residual directly either +/// exposes the wrong count or bypasses the counter-source choice entirely. +#[test] +fn target_first_mana_then_targeted_x_counter_cost_binds_x_before_payment_choice() { + let charge = CounterType::Generic("charge".to_string()); + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let source = scenario + .add_creature(P0, "Mana and Counter Battery", 1, 1) + .with_ability_definition( + AbilityDefinition::new( + AbilityKind::Activated, + Effect::DealDamage { + amount: QuantityExpr::Ref { + qty: engine::types::ability::QuantityRef::Variable { + name: "X".to_string(), + }, + }, + target: TargetFilter::Any, + damage_source: None, + excess: None, + }, + ) + .cost(AbilityCost::Composite { + costs: vec![ + AbilityCost::Mana { + cost: ManaCost::generic(1), + }, + AbilityCost::RemoveCounter { + count: REMOVE_COUNTER_COST_X, + counter_type: CounterMatch::OfType(charge.clone()), + target: Some(TargetFilter::Typed(TypedFilter::new(TypeFilter::Artifact))), + selection: CounterCostSelection::SingleObject, + }, + ], + }), + ) + .id(); + let counter_source = scenario + .add_creature(P0, "Charge Battery", 1, 1) + .as_artifact() + .id(); + let target = scenario.add_creature(P1, "Damage Target", 3, 3).id(); + scenario.with_mana_pool( + P0, + vec![ManaUnit::new( + ManaType::Colorless, + ObjectId(91_001), + false, + vec![], + )], + ); + let mut runner = scenario.build(); + runner + .state_mut() + .objects + .get_mut(&counter_source) + .expect("counter source exists") + .counters + .insert(charge.clone(), 2); + + activate(&mut runner, source); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::ChooseXValue { .. } + )); + runner + .act(GameAction::ChooseX { value: 2 }) + .expect("X announcement must resume target selection"); + choose_target(&mut runner, target); + + let WaitingFor::PayCost { + kind, + choices, + count, + .. + } = runner.state().waiting_for.clone() + else { + panic!( + "mana payment must resume at the concrete counter-cost choice, got {:?}", + runner.state().waiting_for + ); + }; + assert_eq!( + kind, + PayCostKind::RemoveCounter { + counter_type: CounterMatch::OfType(charge.clone()), + count: 2, + selection: CounterCostSelection::SingleObject, + } + ); + assert_eq!(count, 1, "one counter source must be selected"); + assert!(choices.contains(&counter_source)); + assert!( + runner.state().battlefield.contains(&target), + "the effect target remains declared while the counter source is chosen" + ); + assert!(runner.state().stack.is_empty()); + + runner + .act(GameAction::SelectCards { + cards: vec![counter_source], + }) + .expect("selecting the counter source must pay the bound X cost"); + assert_eq!( + runner.state().objects[&counter_source] + .counters + .get(&charge), + None, + "the selected source pays exactly the announced two counters" + ); + assert_eq!(runner.state().stack.len(), 1); +} + +fn targeted_damage_cost_ability(cost: AbilityCost) -> AbilityDefinition { + AbilityDefinition::new( + AbilityKind::Activated, + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Any, + damage_source: None, + excess: None, + }, + ) + .cost(cost) +} + +#[test] +fn targeted_activation_surfaces_blight_cost_after_target_selection() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let source = scenario + .add_creature(P0, "Targeted Blight Cost", 1, 1) + .with_ability_definition(targeted_damage_cost_ability(AbilityCost::Blight { + count: 1, + })) + .id(); + // Keep the payment creature alive after the -1/-1 counter so this test + // observes the paid cost rather than its later state-based-action cleanup. + let blighted = scenario.add_creature(P0, "Blight Payment", 2, 2).id(); + let target = scenario.add_creature(P1, "Blight Cost Target", 1, 1).id(); + let mut runner = scenario.build(); + + activate(&mut runner, source); + choose_target(&mut runner, target); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::BlightChoice { .. } + )); + + runner + .act(GameAction::SelectCards { + cards: vec![blighted], + }) + .expect("paying the target-first blight cost must succeed"); + assert_eq!(runner.state().stack.len(), 1); + assert_eq!( + runner.state().objects[&blighted] + .counters + .get(&engine::types::counter::CounterType::Minus1Minus1), + Some(&1), + ); +} + +#[test] +fn targeted_activation_surfaces_reveal_cost_after_target_selection() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let source = scenario + .add_creature(P0, "Targeted Reveal Cost", 1, 1) + .with_ability_definition(targeted_damage_cost_ability(AbilityCost::Reveal { + count: 1, + filter: Some(TargetFilter::Typed(TypedFilter::creature())), + })) + .id(); + let revealed = scenario + .add_creature_to_hand(P0, "Reveal Payment", 1, 1) + .id(); + let target = scenario.add_creature(P1, "Reveal Cost Target", 1, 1).id(); + let mut runner = scenario.build(); + + activate(&mut runner, source); + choose_target(&mut runner, target); + let WaitingFor::PayCost { kind, choices, .. } = runner.state().waiting_for.clone() else { + panic!("target declaration must surface the reveal cost"); + }; + assert_eq!(kind, PayCostKind::Reveal); + assert!(choices.contains(&revealed)); + + runner + .act(GameAction::SelectCards { + cards: vec![revealed], + }) + .expect("revealing the target-first activation payment must succeed"); + assert_eq!(runner.state().stack.len(), 1); +} + +#[test] +fn targeted_activation_surfaces_behold_cost_after_target_selection() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let source = scenario + .add_creature(P0, "Targeted Behold Cost", 1, 1) + .with_ability_definition(targeted_damage_cost_ability(AbilityCost::Behold { + count: 1, + filter: TargetFilter::Typed(TypedFilter::creature()), + action: BeholdCostAction::ChooseOrReveal, + type_choice: None, + })) + .id(); + let behold = scenario.add_creature(P0, "Behold Payment", 1, 1).id(); + let target = scenario.add_creature(P1, "Behold Cost Target", 1, 1).id(); + let mut runner = scenario.build(); + + activate(&mut runner, source); + choose_target(&mut runner, target); + let WaitingFor::PayCost { kind, choices, .. } = runner.state().waiting_for.clone() else { + panic!("target declaration must surface the behold cost"); + }; + assert_eq!( + kind, + PayCostKind::Behold { + action: BeholdCostAction::ChooseOrReveal, + } + ); + assert!(choices.contains(&behold)); + + runner + .act(GameAction::SelectCards { + cards: vec![behold], + }) + .expect("beholding the target-first activation payment must succeed"); + assert_eq!(runner.state().stack.len(), 1); +} + +fn graveyard_exile_replacement(label: &str) -> ReplacementDefinition { + ReplacementDefinition::new(ReplacementEvent::Moved) + .destination_zone(Zone::Graveyard) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChangeZone { + destination: Zone::Exile, + origin: None, + target: TargetFilter::SelfRef, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + }, + )) + .description(label.to_string()) +} + +#[test] +fn target_first_mill_cost_resumes_after_replacement_choice() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let source = scenario + .add_creature(P0, "Targeted Mill Cost", 1, 1) + .with_ability_definition(targeted_damage_cost_ability(AbilityCost::Mill { count: 1 })) + .id(); + let target = scenario.add_creature(P1, "Mill Cost Target", 1, 1).id(); + let milled = scenario.add_card_to_library_top(P0, "Mill Payment Card"); + for label in ["Mill Redirect One", "Mill Redirect Two"] { + scenario + .add_creature(P0, label, 0, 0) + .as_enchantment() + .with_replacement_definition(graveyard_exile_replacement(label)); + } + let mut runner = scenario.build(); + + activate(&mut runner, source); + choose_target(&mut runner, target); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::ReplacementChoice { .. } + )); + + runner + .act(GameAction::ChooseReplacement { index: 0 }) + .expect("choosing the mill redirect must resume the target-first activation"); + + assert_eq!(runner.state().objects[&milled].zone, Zone::Exile); + assert_eq!(runner.state().stack.len(), 1); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::Priority { .. } + )); +} From fd53e6af9c075476d5acc153835a9c19b20ddfe7 Mon Sep 17 00:00:00 2001 From: Clayton <118192227+claytonlin1110@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:47:54 -0500 Subject: [PATCH 20/63] fix(engine): prevent-damage recipient scope no longer collapses to Any (#6793) * fix(engine): prevent-damage recipient scope no longer collapses to Any (#6682) Mutational Advantage's "Prevent all damage that would be dealt to those permanents this turn" parsed its recipient to a blanket TargetFilter::Any, protecting every permanent in the game instead of only the caster's own countered ones (Blinding Fog, Defend the Hearth, and Energy Arc hit the same fallback via different recipient shapes). The dispatch chain in parse_prevent_effect never called into parse_target's tracked-set/typed-noun grammar for the recipient position, and a bare "players" noun had no grammar arm at all. Adds a shared resolve_prevent_recipient tier ladder (chosen-target anaphor -> clause-derived population via a widened chain_prior_mass_population -> any other parse_target-recognized filter), a "players" bare-noun arm in parse_target, and the corresponding runtime plumbing: prevent_damage::resolve now freezes a TrackedSet sentinel to a concrete id at shield-creation time (avoiding drift onto whatever a later, unrelated chain publishes), and find_applicable_replacements now enforces valid_card against object damage targets on the global/stack-sourced shield path, which it previously ignored entirely for that recipient class. Co-Authored-By: Claude Sonnet 5 * fix(engine): freeze clause-derived prevent-recipient population at resolution (#6682) Addresses code review on the prevent-damage recipient-scope fix: - Snapshot semantics (HIGH): Mutational Advantage's "those permanents" now resolves through parse_target's existing TrackedSet dispatch instead of binding directly to a live copy of the grant's filter, which would have re-checked "has a counter" at every future damage event. The chain_prior_mass_population widening (from the prior commit) still feeds the runtime: affected_objects_from_events's GenericEffect arm now also recognizes Continuous-mode static grants (not just MustAttack coercion), so the population gets enumerated once and frozen into the tracked set at resolution, matching the official ruling that gained/lost counters after resolution don't change who's protected. - Runtime bug surfaced by the Energy Arc cast-pipeline test (MED): the bidirectional "by" shield's ability.targets inherited the preceding Untap clause's targets via the chain walker's generic target-propagation, and since TargetFilter::Any isn't classified as a context-ref, the shield got installed on the untapped creature as a recipient (valid_card: SelfRef) instead of routing through the source-scoped path. Extended source_scoped_prevent to also recognize TrackedSet/TrackedSetFiltered damage_source_filter shapes, mirroring the existing ParentTarget carve-out for the Maze of Ith class of bidirectional shields. - Added a cast-pipeline test for Energy Arc covering selected/nonselected damage-to and damage-by, and a cast-pipeline runtime test for Mutational Advantage's gained/lost-counter freeze semantics. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Matt Evans <1388610+matthewevans@users.noreply.github.com> --- crates/engine/src/game/effects/mod.rs | 34 +- .../engine/src/game/effects/prevent_damage.rs | 526 +++++++++++++++++- crates/engine/src/game/replacement.rs | 119 +++- .../src/parser/oracle_effect/imperative.rs | 137 ++++- .../engine/src/parser/oracle_effect/lower.rs | 21 +- crates/engine/src/parser/oracle_effect/mod.rs | 47 +- .../engine/src/parser/oracle_effect/tests.rs | 145 +++++ crates/engine/src/parser/oracle_target.rs | 30 + 8 files changed, 1013 insertions(+), 46 deletions(-) diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index c1f269c912..e4f48ea7be 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -4839,25 +4839,41 @@ fn affected_objects_from_events( ) -> Vec { let fallback_targets = ability.targets.as_slice(); match effect { - // CR 508.1a + CR 608.2c + CR 603.7c: A mass "attack this turn if able" - // coercion (Maddening Imp's `{T}` `GenericEffect{MustAttack}`) moves no - // objects and emits NO object-affecting event, so — unlike every other arm - // here — its affected population is FILTER-driven: re-enumerate the - // battlefield by the governing filter at resolution time. The publish site - // runs on the identical post-resolution board state, so this yields exactly - // the resolution-time population that "those creatures" freezes. Mirrors - // the broadcast-static binding in `effect.rs` (`Some(filter)` arm). + // CR 508.1a + CR 608.2c + CR 603.7c + CR 611.2c + CR 615.11 (issue + // #6682): A mass "attack this turn if able" coercion (Maddening Imp's + // `{T}` `GenericEffect{MustAttack}`) or a Continuous-mode broadcast + // keyword/P-T grant (Mutational Advantage's "permanents you control + // with counters on them gain hexproof and indestructible") moves no + // objects and emits NO object-affecting event, so — unlike every other + // arm here — its affected population is FILTER-driven: re-enumerate + // the battlefield by the governing filter at resolution time. The + // publish site runs on the identical post-resolution board state, so + // this yields exactly the resolution-time population that "those + // creatures"/"those permanents" freezes — verified against Mutational + // Advantage's official ruling: "The set of permanents affected by + // Mutational Advantage is determined at the time Mutational Advantage + // resolves. Permanents that gain counters later in the turn won't + // become affected by this effect, and permanents that lose all of + // their counters later in the turn won't stop being affected." + // Mirrors the broadcast-static binding in `effect.rs` (`Some(filter)` + // arm). Broader than the parser-side `is_mass_coerce_static` + // (oracle_effect/mod.rs), which still gates only the MustAttack/ + // MustAttackPlayer coercion pair for its own (unrelated) + // ParentTarget-rewrite purpose. Effect::GenericEffect { static_abilities, target, .. } => { - // Select the first coercion static (matching `is_mass_coerce_static`). + // Select the first static whose population is meant to be frozen + // at resolution rather than re-evaluated live at each future + // check — a coercion requirement or a Continuous grant. let Some(static_def) = static_abilities.iter().find(|sd| { matches!( sd.mode, crate::types::statics::StaticMode::MustAttack | crate::types::statics::StaticMode::MustAttackPlayer { .. } + | crate::types::statics::StaticMode::Continuous ) }) else { return Vec::new(); diff --git a/crates/engine/src/game/effects/prevent_damage.rs b/crates/engine/src/game/effects/prevent_damage.rs index 7ceb30291c..b8f3bb0a1c 100644 --- a/crates/engine/src/game/effects/prevent_damage.rs +++ b/crates/engine/src/game/effects/prevent_damage.rs @@ -190,6 +190,17 @@ fn untargeted_damage_filter( permanent_type: *permanent_type, }) } + // CR 608.2c + CR 611.2c + CR 615.11 (issue #6682): a tracked-set + // recipient ("those permanents"/"those creatures" — Mutational + // Advantage's clause-derived population, Energy Arc's target-derived + // untapped creatures) is an OBJECT population, not a player. The + // generic `is_context_ref()` classification below (used broadly for + // "does this need a declared target slot") also happens to cover + // `TrackedSet`/`TrackedSetFiltered`, which would otherwise + // misroute it through `resolve_player_for_context_ref` here — object + // matching is `typed_recipient_valid_card_filter`'s job, so this + // arm must be checked BEFORE the generic `is_context_ref()` catch-all. + TargetFilter::TrackedSet { .. } | TargetFilter::TrackedSetFiltered { .. } => None, filter if filter.is_context_ref() => Some(player_damage_filter( super::resolve_player_for_context_ref(state, ability, filter), )), @@ -211,6 +222,13 @@ fn typed_recipient_valid_card_filter(target: &TargetFilter) -> Option None, + // CR 608.2c + CR 611.2c + CR 615.11 (issue #6682): a tracked-set + // recipient IS an object population — checked before the generic + // `is_context_ref()` exclusion below (which would otherwise reject + // it, mirroring `untargeted_damage_filter`'s matching carve-out). + filter @ (TargetFilter::TrackedSet { .. } | TargetFilter::TrackedSetFiltered { .. }) => { + Some(filter.clone()) + } filter if filter.is_context_ref() => None, filter => Some(filter.clone()), } @@ -253,6 +271,25 @@ pub fn resolve( } }; + // CR 608.2c + CR 611.2c + CR 615.11 (issue #6682): resolve any + // `TrackedSet` sentinel in the recipient/source filters to a CONCRETE + // tracked-set id now, at shield-creation time — before it is folded into + // a `ReplacementDefinition` that may persist and be rechecked at many + // later damage events this turn. Left unresolved, the raw + // `TrackedSetId(0)` sentinel would be re-resolved against + // `state.chain_tracked_set_id` at EACH future check + // (`filter::matches_target_filter`), which drifts to whatever unrelated + // chain most recently published a tracked set by then — a stale, + // silently-wrong rebinding (Energy Arc's "those creatures" shield must + // stay bound to the untapped creatures for the rest of the turn, not + // whatever some later spell's chain happens to publish). Mirrors + // `register_transient_effect`'s identical one-shot resolution + // (`game/effects/effect.rs`) for the analogous durable continuous-effect + // case. A no-op for every non-`TrackedSet` filter. + let target = crate::game::targeting::resolve_tracked_set_sentinel(state, target); + let effect_source_filter = effect_source_filter + .map(|filter| crate::game::targeting::resolve_tracked_set_sentinel(state, filter)); + // CR 609.7 + CR 609.7a: A source-scoped prevent ("prevent all damage target // instant or sorcery spell would deal this turn") carries its chosen source // object in `ability.targets[0]` via a `ParentTargetSlot` sentinel in the @@ -265,14 +302,33 @@ pub fn resolve( // chosen creature IS the damage source, not a recipient). Same routing: the // shield is source-scoped, so it must NOT be hosted on the creature as a // recipient object (which would wrongly re-impose "recipient == creature"). - let source_scoped_prevent = - matches!( - &effect_source_filter, - Some(TargetFilter::And { filters }) - if filters - .iter() - .any(|f| matches!(f, TargetFilter::ParentTargetSlot { .. })) - ) || matches!(&effect_source_filter, Some(TargetFilter::ParentTarget)); + // + // CR 608.2c + CR 615 (issue #6682): Energy Arc's "by"-only half carries a + // `TrackedSet` source filter instead (a SET of chosen creatures, not one). + // `ability.targets` here is NOT this effect's own recipient selection — it + // is the enclosing Untap clause's targets, inherited onto this + // SequentialSibling by the chain walker's generic `should_propagate_ + // parent_targets` (the inheritance exists for OTHER riders that genuinely + // want the parent's chosen object; a source-scoped shield has no use for + // it). Without this arm, `host_on_targets` saw a non-`Any`-context-ref + // `target` (`Any` itself isn't in `is_context_ref()`'s list) plus those + // inherited targets and wrongly hosted the shield ON the untapped + // creature with a forced `valid_card: SelfRef` — recipient-scoping a + // shield that was supposed to be source-scoped only. + let source_scoped_prevent = matches!( + &effect_source_filter, + Some(TargetFilter::And { filters }) + if filters + .iter() + .any(|f| matches!(f, TargetFilter::ParentTargetSlot { .. })) + ) || matches!( + &effect_source_filter, + Some( + TargetFilter::ParentTarget + | TargetFilter::TrackedSet { .. } + | TargetFilter::TrackedSetFiltered { .. } + ) + ); // CR 615.11: A dynamic prevention amount is resolved to a concrete depletion // count at effect-resolution time; the Next(n) shield itself is always static. @@ -1474,6 +1530,460 @@ mod tests { assert_eq!(state.objects.get(&bear).unwrap().damage_marked, 2); } + /// CR 608.2c + CR 611.2c + CR 615.11 (issue #6682): A `TrackedSet(0)` + /// sentinel recipient must be resolved to a CONCRETE tracked-set id at + /// shield-creation time, not left as the raw sentinel. Proves the + /// staleness bug this resolves: without the fix, a persisting shield's + /// `valid_card: TrackedSet(0)` would be re-resolved against + /// `state.chain_tracked_set_id` at EVERY future damage check, drifting to + /// whatever unrelated chain most recently published a tracked set. Here, + /// AFTER the shield is created, an unrelated chain overwrites + /// `chain_tracked_set_id` to point at a completely different object + /// (simulating some other spell resolving later the same turn) — the + /// shield must still protect only the ORIGINAL untapped creature (Energy + /// Arc class), not the new unrelated set. + #[test] + fn tracked_set_recipient_resolves_to_concrete_id_immune_to_later_chain_overwrite() { + use crate::types::identifiers::TrackedSetId; + + let mut state = GameState::new_two_player(42); + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Energy Arc".to_string(), + Zone::Stack, + ); + let untapped_creature = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Untapped Creature".to_string(), + Zone::Battlefield, + ); + let unrelated_creature = create_object( + &mut state, + CardId(3), + PlayerId(1), + "Unrelated Creature".to_string(), + Zone::Battlefield, + ); + let damage_source = create_object( + &mut state, + CardId(4), + PlayerId(1), + "Attacker".to_string(), + Zone::Battlefield, + ); + + // The preceding Untap clause published the untapped creature as the + // chain's tracked set — the state `prevent_damage::resolve` sees. + state + .tracked_object_sets + .insert(TrackedSetId(5), vec![untapped_creature]); + state.chain_tracked_set_id = Some(TrackedSetId(5)); + + let ability = ResolvedAbility::new( + Effect::PreventDamage { + amount: PreventionAmount::All, + amount_dynamic: None, + target: TargetFilter::TrackedSet { + id: TrackedSetId(0), + }, + scope: PreventionScope::CombatDamage, + damage_source_filter: None, + prevention_duration: None, + }, + vec![], + source, + PlayerId(0), + ); + let mut events = Vec::new(); + resolve(&mut state, &ability, &mut events).unwrap(); + + // A LATER, unrelated chain publishes a fresh tracked set (e.g. some + // other spell's exile/mill effect resolving afterward this turn). + state + .tracked_object_sets + .insert(TrackedSetId(6), vec![unrelated_creature]); + state.chain_tracked_set_id = Some(TrackedSetId(6)); + + let ctx = deal_damage::DamageContext::from_source(&state, damage_source).unwrap(); + + // The original untapped creature must still be protected. + let protected_result = deal_damage::apply_damage_to_target( + &mut state, + &ctx, + TargetRef::Object(untapped_creature), + 3, + true, + &mut events, + ) + .unwrap(); + assert!( + matches!(protected_result, deal_damage::DamageResult::Applied(0)), + "the shield must stay bound to the originally-untapped creature" + ); + + // The later, unrelated creature must NOT be protected — the shield + // must not have drifted onto whatever the newest tracked set is. + let unrelated_result = deal_damage::apply_damage_to_target( + &mut state, + &ctx, + TargetRef::Object(unrelated_creature), + 3, + true, + &mut events, + ) + .unwrap(); + assert!( + matches!(unrelated_result, deal_damage::DamageResult::Applied(3)), + "the shield must NOT drift onto an unrelated later chain's tracked set" + ); + } + + /// CR 611.2c + CR 615.11 (issue #6682): Mutational Advantage's official + /// ruling — "The set of permanents affected by Mutational Advantage is + /// determined at the time Mutational Advantage resolves. Permanents that + /// gain counters later in the turn won't become affected by this effect, + /// and permanents that lose all of their counters later in the turn + /// won't stop being affected." — driven through the real cast pipeline + /// (`GameRunner::cast`), not a hand-built `ResolvedAbility`, so the parse + /// → chain-context → resolution path is exercised end to end. + /// + /// Setup: `countered` already has a +1/+1 counter (in the frozen + /// population); `uncountered` does not. AFTER the spell resolves: + /// `uncountered` gains a counter (must NOT retroactively join the + /// shielded population — a live re-check of "permanents with counters" + /// would wrongly protect it) and `countered` loses its counter (must + /// STAY protected — the shield is bound to the frozen object identity, + /// not a live filter re-evaluated at each damage event). + #[test] + fn mutational_advantage_shield_freezes_population_at_resolution() { + use crate::parser::oracle_effect::parse_effect_chain; + use crate::types::ability::AbilityKind; + use crate::types::counter::CounterType; + use crate::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit}; + use crate::types::phase::Phase; + use std::sync::Arc; + + let def = parse_effect_chain( + "Permanents you control with counters on them gain hexproof and indestructible \ + until end of turn. Prevent all damage that would be dealt to those permanents \ + this turn.", + AbilityKind::Spell, + ); + + let mut state = GameState::new_two_player(42); + state.turn_number = 2; + state.phase = Phase::PreCombatMain; + state.active_player = PlayerId(0); + state.priority_player = PlayerId(0); + state.waiting_for = WaitingFor::Priority { + player: PlayerId(0), + }; + + let countered = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Countered Creature".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&countered).unwrap(); + obj.card_types.core_types = vec![CoreType::Creature]; + obj.counters.insert(CounterType::Plus1Plus1, 1); + } + let uncountered = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Uncountered Creature".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&uncountered) + .unwrap() + .card_types + .core_types = vec![CoreType::Creature]; + + let spell = create_object( + &mut state, + CardId(3), + PlayerId(0), + "Mutational Advantage".to_string(), + Zone::Hand, + ); + { + let obj = state.objects.get_mut(&spell).unwrap(); + obj.card_types.core_types.push(CoreType::Instant); + Arc::make_mut(&mut obj.abilities).push(def); + obj.mana_cost = ManaCost::Cost { + shards: vec![ManaCostShard::Green, ManaCostShard::Blue], + generic: 1, + }; + } + for color in [ManaType::Green, ManaType::Blue, ManaType::Colorless] { + state.players[0].mana_pool.add(ManaUnit { + color, + source_id: ObjectId(0), + pip_id: crate::types::mana::ManaPipId(0), + supertype: None, + source_could_produce_two_or_more_colors: false, + restrictions: Vec::new(), + grants: vec![], + expiry: None, + }); + } + + let mut runner = crate::game::scenario::GameRunner::from_state(state); + let _outcome = runner.cast(spell).resolve(); + let state = runner.state_mut(); + + // CR 611.2c: mutate AFTER resolution — the frozen population must be + // immune to both changes. + state + .objects + .get_mut(&uncountered) + .unwrap() + .counters + .insert(CounterType::Plus1Plus1, 1); + state + .objects + .get_mut(&countered) + .unwrap() + .counters + .remove(&CounterType::Plus1Plus1); + + let attacker = create_object( + state, + CardId(4), + PlayerId(1), + "Attacker".to_string(), + Zone::Battlefield, + ); + let ctx = deal_damage::DamageContext::from_source(state, attacker).unwrap(); + let mut events = Vec::new(); + + let uncountered_result = deal_damage::apply_damage_to_target( + state, + &ctx, + TargetRef::Object(uncountered), + 3, + false, + &mut events, + ) + .unwrap(); + assert!( + matches!(uncountered_result, deal_damage::DamageResult::Applied(3)), + "a permanent that gains a counter AFTER resolution must NOT retroactively \ + join the frozen shielded population" + ); + + let countered_result = deal_damage::apply_damage_to_target( + state, + &ctx, + TargetRef::Object(countered), + 3, + false, + &mut events, + ) + .unwrap(); + assert!( + matches!(countered_result, deal_damage::DamageResult::Applied(0)), + "a permanent that loses its counter AFTER resolution must STAY protected \ + (frozen by identity, not re-checked live)" + ); + } + + /// CR 608.2c + CR 615 (issue #6682): Energy Arc's bidirectional "dealt to + /// and dealt by those creatures" — driven through the real cast pipeline + /// (`GameRunner::cast`) with a genuine SUBSET target selection out of two + /// eligible creatures, proving the shield scopes to exactly the SELECTED + /// creature in BOTH directions: + /// - combat damage dealt TO the selected creature is prevented; TO the + /// nonselected creature is not. + /// - combat damage dealt BY the selected creature (as a source) is + /// prevented; BY the nonselected creature is not. + #[test] + fn energy_arc_cast_pipeline_scopes_to_and_by_damage_to_selected_creature() { + use crate::parser::oracle_effect::parse_effect_chain; + use crate::types::ability::AbilityKind; + use crate::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit}; + use crate::types::phase::Phase; + use std::sync::Arc; + + let def = parse_effect_chain( + "Untap any number of target creatures. Prevent all combat damage that would \ + be dealt to and dealt by those creatures this turn.", + AbilityKind::Spell, + ); + + let mut state = GameState::new_two_player(42); + state.turn_number = 2; + state.phase = Phase::PreCombatMain; + state.active_player = PlayerId(0); + state.priority_player = PlayerId(0); + state.waiting_for = WaitingFor::Priority { + player: PlayerId(0), + }; + + // Two eligible creatures — only one is selected as Energy Arc's + // target, proving the shield scopes to the CHOSEN subset, not every + // creature the multi-target filter could have matched. + let selected = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Selected Creature".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&selected) + .unwrap() + .card_types + .core_types = vec![CoreType::Creature]; + state.objects.get_mut(&selected).unwrap().tapped = true; + + let nonselected = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Nonselected Creature".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&nonselected) + .unwrap() + .card_types + .core_types = vec![CoreType::Creature]; + state.objects.get_mut(&nonselected).unwrap().tapped = true; + + let opponent_creature = create_object( + &mut state, + CardId(3), + PlayerId(1), + "Opponent Creature".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&opponent_creature) + .unwrap() + .card_types + .core_types = vec![CoreType::Creature]; + + let spell = create_object( + &mut state, + CardId(4), + PlayerId(0), + "Energy Arc".to_string(), + Zone::Hand, + ); + { + let obj = state.objects.get_mut(&spell).unwrap(); + obj.card_types.core_types.push(CoreType::Instant); + Arc::make_mut(&mut obj.abilities).push(def); + obj.mana_cost = ManaCost::Cost { + shards: vec![ManaCostShard::White, ManaCostShard::Blue], + generic: 0, + }; + } + for color in [ManaType::White, ManaType::Blue] { + state.players[0].mana_pool.add(ManaUnit { + color, + source_id: ObjectId(0), + pip_id: crate::types::mana::ManaPipId(0), + supertype: None, + source_could_produce_two_or_more_colors: false, + restrictions: Vec::new(), + grants: vec![], + expiry: None, + }); + } + + let mut runner = crate::game::scenario::GameRunner::from_state(state); + // CR 601.2c: "any number of target creatures" — declare exactly ONE + // of the two eligible creatures, proving the shield binds to the + // CHOSEN subset (the driver matches declared object intent to the + // multi-target slot). + let _outcome = runner.cast(spell).target_objects(&[selected]).resolve(); + let state = runner.state_mut(); + + assert!( + !state.objects.get(&selected).unwrap().tapped, + "the selected creature must be untapped by Energy Arc's own effect" + ); + + let opponent_attacker_ctx = + deal_damage::DamageContext::from_source(state, opponent_creature).unwrap(); + let mut events = Vec::new(); + + // Damage TO: selected is shielded, nonselected is not. + let to_selected = deal_damage::apply_damage_to_target( + state, + &opponent_attacker_ctx, + TargetRef::Object(selected), + 3, + true, + &mut events, + ) + .unwrap(); + assert!( + matches!(to_selected, deal_damage::DamageResult::Applied(0)), + "combat damage dealt TO the selected creature must be prevented" + ); + let to_nonselected = deal_damage::apply_damage_to_target( + state, + &opponent_attacker_ctx, + TargetRef::Object(nonselected), + 3, + true, + &mut events, + ) + .unwrap(); + assert!( + matches!(to_nonselected, deal_damage::DamageResult::Applied(3)), + "combat damage dealt TO the nonselected creature must NOT be prevented" + ); + + // Damage BY: selected as the source is shielded, nonselected as the + // source is not. + let selected_source_ctx = deal_damage::DamageContext::from_source(state, selected).unwrap(); + let by_selected = deal_damage::apply_damage_to_target( + state, + &selected_source_ctx, + TargetRef::Object(opponent_creature), + 3, + true, + &mut events, + ) + .unwrap(); + assert!( + matches!(by_selected, deal_damage::DamageResult::Applied(0)), + "combat damage dealt BY the selected creature must be prevented" + ); + let nonselected_source_ctx = + deal_damage::DamageContext::from_source(state, nonselected).unwrap(); + let by_nonselected = deal_damage::apply_damage_to_target( + state, + &nonselected_source_ctx, + TargetRef::Object(opponent_creature), + 3, + true, + &mut events, + ) + .unwrap(); + assert!( + matches!(by_nonselected, deal_damage::DamageResult::Applied(3)), + "combat damage dealt BY the nonselected creature must NOT be prevented" + ); + } + /// CR 615.1a: A `Prevention { All }` shield is not depletion-based — it /// must remain active across multiple damage events for the rest of the /// turn (lifetime governed by `expiry: EndOfTurn` per CR 514.2). Without diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 687283a1e7..c931c0403d 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -7186,6 +7186,40 @@ pub fn find_applicable_replacements( } } } + // CR 608.2c + CR 611.2c + CR 615.1a (issue #6682): an + // OBJECT-population recipient (`valid_card` — Blinding + // Fog's "creatures", Mutational Advantage's countered + // permanents, Energy Arc's untapped-creatures tracked + // set) must ALSO gate a GLOBAL (stack-sourced) shield's + // OBJECT damage target, exactly as an object-hosted + // shield's own per-object scan already enforces it. + // Previously unreachable: every prior card whose prevent + // clause carried a real `valid_card` recipient filter + // happened to be sourced from a permanent already on the + // battlefield (object-hosted path, checked elsewhere), so + // this pending-registry path never needed to read it — + // silently turning a scoped shield into a blanket one for + // the FIRST instant/sorcery-sourced population recipient. + // Player-target damage events are unaffected: a card-shaped + // filter has no player to check against (mirrors why + // `damage_target_filter` above is the player-side gate). + if let Some(ref vc) = repl_def.valid_card { + if let ProposedEvent::Damage { + target: TargetRef::Object(obj_id), + .. + } = event + { + let ctx = match repl_def.source_controller { + Some(pid) => { + FilterContext::from_source_with_controller(ObjectId(0), pid) + } + None => FilterContext::from_source(state, ObjectId(0)), + }; + if !matches_target_filter(state, *obj_id, vc, &ctx) { + continue; + } + } + } if is_damage_prevention_replacement(state, &rid, &repl_def.event) && is_prevention_disabled(state, event) { @@ -19440,13 +19474,17 @@ mod tests { } #[test] - fn global_store_damage_path_ignores_valid_card_filter() { - // REGRESSION (BLOCKER guard): the prevent_damage typed-recipient shield - // sets a `valid_card` recipient filter that is DELIBERATELY not enforced - // for damage (prevent_damage.rs: "global shields must match any damage - // event"). The generalized scan must still prevent a damage event whose - // recipient does NOT match that typed filter — i.e. the new valid_card - // gate must NOT run on the Damage path. + fn global_store_damage_path_ignores_valid_card_filter_for_player_targets() { + // A card-shaped `valid_card` recipient filter has no player to check + // against, so it must remain a no-op for a PLAYER-target damage + // event — the generalized scan must still prevent damage dealt to a + // player even though the shield's `valid_card` is creature-shaped. + // CR 608.2c + CR 615.1a (issue #6682): `valid_card` IS now enforced + // on this path for OBJECT-target damage events (see + // `find_applicable_replacements`'s dedicated `valid_card` gate, + // covered by `game::effects::prevent_damage::tests`'s tracked-set + // recipient tests) — this test pins the complementary player-target + // case, where the gate correctly does not apply. let registry = build_replacement_registry(); let mut state = GameState::new_two_player(42); // Global prevention shield carrying a typed recipient valid_card filter @@ -19473,6 +19511,73 @@ mod tests { ); } + /// CR 608.2c + CR 611.2c + CR 615.1a (issue #6682): a GLOBAL (stack-sourced) + /// prevention shield's `valid_card` recipient filter MUST gate an + /// OBJECT-target damage event — the mass/tracked-set recipient class + /// (Blinding Fog's "creatures", Mutational Advantage's countered + /// permanents, Energy Arc's untapped creatures) that only ever reaches + /// the pending registry because its source is an instant/sorcery on the + /// stack, never a battlefield permanent. Without this gate, ANY object + /// took damage as if the shield were unscoped. + #[test] + fn global_store_damage_path_enforces_valid_card_filter_for_object_targets() { + let registry = build_replacement_registry(); + let mut state = GameState::new_two_player(42); + let mut land = GameObject::new( + ObjectId(30), + CardId(1), + PlayerId(0), + "Land".to_string(), + Zone::Battlefield, + ); + land.card_types.core_types = vec![CoreType::Land]; + state.objects.insert(ObjectId(30), land); + let mut creature = GameObject::new( + ObjectId(31), + CardId(2), + PlayerId(0), + "Creature".to_string(), + Zone::Battlefield, + ); + creature.card_types.core_types = vec![CoreType::Creature]; + state.objects.insert(ObjectId(31), creature); + + let shield = ReplacementDefinition::new(ReplacementEvent::DamageDone) + .prevention_shield(PreventionAmount::All) + .valid_card(TargetFilter::Typed(TypedFilter::creature())); + state.pending_damage_replacements.push(shield); + + // The land does NOT match the creature-shaped valid_card filter. + let land_event = ProposedEvent::Damage { + source_id: ObjectId(50), + target: TargetRef::Object(ObjectId(30)), + amount: 3, + is_combat: false, + applied: HashSet::new(), + }; + assert!( + find_applicable_replacements(&state, &land_event, ®istry).is_empty(), + "a non-matching object target must NOT be gated in by an unscoped shield" + ); + + // The creature DOES match. + let creature_event = ProposedEvent::Damage { + source_id: ObjectId(50), + target: TargetRef::Object(ObjectId(31)), + amount: 3, + is_combat: false, + applied: HashSet::new(), + }; + assert_eq!( + find_applicable_replacements(&state, &creature_event, ®istry), + vec![ReplacementId { + source: ObjectId(0), + index: 0 + }], + "a matching object target must still be gated in" + ); + } + #[test] fn global_store_damage_path_respects_unless_your_turn_condition() { // REGRESSION: global prevention shields with an `unless your turn` gate diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index 45b2d3e38b..b35b1b84ca 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -5909,6 +5909,53 @@ pub(super) fn parse_prevention_amount(rest: &str) -> PreventionAmount { } } +/// CR 608.2c + CR 611.2c + CR 615.11 (issue #6682): Resolve a prevent-damage +/// recipient phrase — the text immediately following "dealt to " or "dealt to +/// and dealt by " — to a `TargetFilter`. Shared by [`parse_prevent_effect`] +/// and `lower::try_parse_bidirectional_prevent` (Energy Arc's "dealt to and +/// dealt by those creatures") so both prevent-recipient parsers agree. +/// +/// Tries, in priority order: +/// 1. A singular chosen-target anaphor ("that creature"/"it"/"the creature"), +/// gated on `parent_target_available` (CR 608.2c, issue #1094). +/// 2. Any other recipient phrase `parse_target` recognizes as a real filter — +/// mass typed recipients ("creatures"/"players"/"creatures you control" — +/// Blinding Fog, Defend the Hearth) AND "those permanents"/"those +/// creatures", which `parse_target` already maps to a `TrackedSet` +/// sentinel. The sentinel is resolved to a concrete, FROZEN set id at +/// shield-creation time by `prevent_damage::resolve` — never left as a +/// live filter — because the antecedent population must be locked in at +/// resolution regardless of whether it came from a chosen-target clause +/// (Energy Arc's untapped creatures) or a Continuous-mode static grant +/// (Mutational Advantage's countered permanents: the resolution-time +/// population is enumerated once by the widened `GenericEffect` arm in +/// `game::effects::affected_objects_from_events`). Mutational Advantage's +/// official ruling: "The set of permanents affected by Mutational +/// Advantage is determined at the time Mutational Advantage resolves." +/// +/// Returns `None` only when neither tier recognizes the phrase — the caller +/// then falls back to the pre-existing `Any` default. +pub(super) fn resolve_prevent_recipient( + recipient: TextPair<'_>, + parent_target_available: bool, +) -> Option { + if let Some((filter, _)) = + parse_anaphoric_target_ref(recipient.original, parent_target_available) + { + return Some(filter); + } + // CR 608.2c: `ParentTarget`/`TriggeringSource`/`CostPaidObject`/`SelfRef` + // are inherited-reference filters naming a specific already-established + // object — exclusively tier 1's gated domain (`parent_target_available`). + // `parse_target` resolves these unconditionally, with no such gate, so + // this tier must reject them explicitly — otherwise a standalone recipient + // anaphor with `parent_target_available == false` would leak through here + // as an ungated `ParentTarget` binding to a nonexistent chosen target. + let (filter, _) = parse_target(recipient.original); + (!matches!(filter, TargetFilter::Any) && super::is_broadcast_population_filter(&filter)) + .then_some(filter) +} + /// CR 615: Parse "prevent" damage effects into `Effect::PreventDamage`. /// /// Handles patterns like: @@ -5999,16 +6046,16 @@ fn parse_prevent_effect(text: &str, parent_target_available: bool) -> Effect { || nom_primitives::scan_contains(rest, "to its controller") { TargetFilter::Controller - } else if let Some(anaphor_filter) = TextPair::new(text, &lower) + } else if let Some(filter) = TextPair::new(text, &lower) .strip_after("dealt to ") - .and_then(|tp| parse_anaphoric_target_ref(tp.original, parent_target_available)) - .map(|(filter, _)| filter) + .and_then(|tp| resolve_prevent_recipient(tp, parent_target_available)) { - // CR 608.2c: "prevent [amount] damage that would be dealt to that - // creature/it/the creature this turn" — a single-direction anaphor - // bound to a target an earlier clause selected. Strict superset of the - // prior `Any` fallback (only reached when `parent_target_available`). - anaphor_filter + // CR 608.2c + CR 611.2c + CR 615.11 (issue #6682): see + // `resolve_prevent_recipient` — a chosen-target anaphor or any other + // recipient phrase `parse_target` recognizes. Strict superset of the + // prior `Any` fallback: a recipient phrase that no tier recognizes + // still falls through to `Any` below. + filter } else { // Default: "that would be dealt" with no specific target → Any TargetFilter::Any @@ -20582,6 +20629,80 @@ mod tests { ); } + /// CR 615.1 (issue #6682 regression guard): a prevent clause with NO + /// recipient phrase at all ("prevent all damage that would be dealt this + /// turn" — a plain Fog effect) must still resolve to `Any`. Only a + /// clause that carries an explicit "dealt to " phrase should + /// ever be scoped away from the blanket shield — the fix must not turn a + /// genuinely unscoped prevent into a falsely-narrowed one. + #[test] + fn fog_with_no_recipient_phrase_stays_any() { + let effect = + parse_prevent_effect("Prevent all damage that would be dealt this turn.", false); + let Effect::PreventDamage { target, .. } = effect else { + panic!("expected PreventDamage"); + }; + assert_eq!(target, TargetFilter::Any); + } + + /// CR 608.2c + CR 615.11 (issue #6682): `resolve_prevent_recipient` tries a + /// chosen-target anaphor, then any other `parse_target`-recognized + /// recipient, in that priority order, and returns `None` (not `Any`) only + /// when neither tier recognizes the phrase. + #[test] + fn resolve_prevent_recipient_tries_tiers_in_priority_order() { + // Tier 1: a singular chosen-target anaphor. + let text = "that creature this turn."; + let lower = text.to_lowercase(); + let tp = TextPair::new(text, &lower); + assert_eq!( + resolve_prevent_recipient(tp, true), + Some(TargetFilter::ParentTarget), + "a chosen-target anaphor must resolve to ParentTarget" + ); + + // Tier 2: a bare mass typed recipient with no antecedent at all. + let text = "creatures this turn."; + let lower = text.to_lowercase(); + let tp = TextPair::new(text, &lower); + let resolved = + resolve_prevent_recipient(tp, false).expect("bare \"creatures\" must resolve"); + assert!( + matches!(&resolved, TargetFilter::Typed(tf) if tf.type_filters.contains(&TypeFilter::Creature)), + "bare \"creatures\" must resolve to a Typed(Creature) filter, got {resolved:?}" + ); + + // Tier 2 also recognizes "those permanents"/"those creatures" via + // `parse_target`'s own tracked-set dispatch — covers BOTH the + // target-derived antecedent class (Energy Arc's "those creatures" + // referring to earlier-chosen targets) and the clause-derived + // antecedent class (Mutational Advantage's "those permanents" + // referring to a preceding Continuous-mode grant's population), + // since the runtime freezes the tracked set from whichever producer + // clause precedes it (see `game::effects::affected_objects_from_events`). + let text = "those creatures this turn."; + let lower = text.to_lowercase(); + let tp = TextPair::new(text, &lower); + assert_eq!( + resolve_prevent_recipient(tp, false), + Some(TargetFilter::TrackedSet { + id: crate::types::identifiers::TrackedSetId(0) + }), + "\"those creatures\" must bind via parse_target's tracked-set dispatch" + ); + + // Neither tier recognizes a genuinely unclassified recipient phrase — + // `resolve_prevent_recipient` returns `None`, not `Any`. + let text = "xyzzy this turn."; + let lower = text.to_lowercase(); + let tp = TextPair::new(text, &lower); + assert_eq!( + resolve_prevent_recipient(tp, false), + None, + "an unclassified recipient phrase must fall through, not silently guess" + ); + } + /// CR 119.3 + CR 608.2c: Kaya's Wrath lifegain (issue #2943) must parse /// through the imperative GainLife path with a FilteredTrackedSetSize /// amount, not fall through to Unimplemented. diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index 292f29bd62..f3716dd26b 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -18,8 +18,8 @@ use super::super::oracle_quantity::{ parse_player_attribute_attr_clause, parse_quantity_ref, }; use super::super::oracle_target::{ - parse_anaphoric_target_ref, parse_target, parse_target_with_ctx, parse_that_clause_suffix, - parse_type_phrase, parse_type_phrase_with_ctx, + parse_target, parse_target_with_ctx, parse_that_clause_suffix, parse_type_phrase, + parse_type_phrase_with_ctx, }; use super::super::oracle_util::{parse_comparator_prefix, parse_count_expr, strip_after, TextPair}; use crate::parser::oracle_ir::ast::*; @@ -7119,14 +7119,17 @@ pub(super) fn try_parse_bidirectional_prevent( let prevention_duration = nom_primitives::scan_preceded(rest, parse_duration).map(|(_, d, _)| d); - // CR 608.2c: isolate the anaphor phrase following the bidirectional marker - // and bind it to the parent's chosen target. `parse_anaphoric_target_ref` - // returns `None` when `parent_target_available` is false — the load-bearing - // gate (a standalone "dealt to and dealt by that creature" with no prior - // target-selecting clause must NOT split into ParentTarget shields). + // CR 608.2c + CR 615: isolate the anaphor phrase following the + // bidirectional marker and resolve it via the same recipient resolution + // `parse_prevent_effect` uses (chosen target anaphor / any other + // recognized filter — Energy Arc's "those creatures" resolves via + // `parse_target`'s `TrackedSet` dispatch). `None` when no tier + // recognizes it — a standalone "dealt to and dealt by that creature" + // with no prior target-selecting clause must NOT split into ParentTarget + // shields. let anaphor_tp = TextPair::new(text, &lower).strip_after("dealt to and dealt by ")?; - let (anaphor_filter, _) = - parse_anaphoric_target_ref(anaphor_tp.original, parent_target_available)?; + let anaphor_filter = + super::imperative::resolve_prevent_recipient(anaphor_tp, parent_target_available)?; // CR 615: the recipient ("to") shield — scoped to the chosen creature as // the damage RECIPIENT (target: ParentTarget, no source restriction). diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 687f5f561d..46ce49b490 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -24461,6 +24461,40 @@ fn clause_ir_mass_population(clause: &ClauseIr) -> Option { mass_population_filter(&clause.parsed.effect).cloned() } +/// CR 608.2c + CR 611.2c + CR 615.11 (issue #6682): If this clause is a +/// `GenericEffect` Continuous-mode static grant over a BROADCAST population (a +/// keyword/P-T/ability "gain(s)"/"have"/"has" grant, not a chosen-target +/// application), return the population filter it establishes. Feeds +/// `ParseContext::chain_prior_mass_population` alongside +/// `clause_ir_mass_population` so a later same-chain "those permanents"/ +/// "those creatures" anaphor binds to the SAME population the grant locked +/// onto. Mutational Advantage's official ruling: "The set of permanents +/// affected by Mutational Advantage is determined at the time Mutational +/// Advantage resolves." Mirrors `is_mass_coerce_static`'s governing-filter +/// selection but is not restricted to the MustAttack/MustAttackPlayer +/// coercion statics that function targets — any Continuous static grant over +/// a broadcast population qualifies. Only a single static definition is +/// accepted: a `GenericEffect` carrying two or more static defs has no single +/// population to name without risking a wrong-antecedent guess. +fn clause_ir_continuous_grant_population(clause: &ClauseIr) -> Option { + let Effect::GenericEffect { + static_abilities, + target: None, + .. + } = &clause.parsed.effect + else { + return None; + }; + let [static_def] = static_abilities.as_slice() else { + return None; + }; + if static_def.mode != StaticMode::Continuous { + return None; + } + let filter = static_def.affected.as_ref()?; + is_broadcast_population_filter(filter).then(|| filter.clone()) +} + /// CR 400.1/400.2 + CR 601.2a: Map a possessive-hand player reference (as /// produced by `parse_hand_possessive_target`) to the `ControllerRef` axis /// used to scope a card filter to that same player's hand. Exhaustive over @@ -29973,11 +30007,14 @@ pub(crate) fn parse_effect_chain_ir( // than reaching past it to the trigger's own subject (Ardbert, // Warrior of Darkness). Mass effects choose nothing, so // `parent_target_available` / `ParentTarget` cannot carry this. - chain_prior_mass_population: builder - .clauses() - .iter() - .rev() - .find_map(clause_ir_mass_population), + // CR 611.2c + CR 615.11 (issue #6682): also matches a preceding + // Continuous-mode keyword/P-T grant clause (Mutational Advantage, + // Blinding Fog), so a later "those permanents"/"those creatures" + // prevent-recipient anaphor binds to the SAME locked-in + // population instead of falling back to `Any`. + chain_prior_mass_population: builder.clauses().iter().rev().find_map(|d| { + clause_ir_mass_population(d).or_else(|| clause_ir_continuous_grant_population(d)) + }), // CR 608.2c: bind a bare "it" in this chunk's counter/anaphor to the // token created by an earlier clause when that token is the chain's // most-recent object referent (Esper Terra's "put up to three lore diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 2cfbffb6bf..76a8e8b96d 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -49543,6 +49543,151 @@ fn they_binds_producer_population_across_mass_effect_family() { } } +/// CR 611.2c + CR 615.11 (issue #6682): Mutational Advantage's second clause +/// ("Prevent all damage that would be dealt to those permanents this turn") +/// must NOT collapse to the `Any` fallback that silently protected every +/// permanent in the game, including opponents'. "Those permanents" resolves +/// to a `TrackedSet` sentinel — `parse_target`'s existing dispatch for the +/// phrase (shared with Energy Arc's target-derived "those creatures") — +/// which `prevent_damage::resolve` then resolves to a concrete, FROZEN set +/// id at shield-creation time (see +/// `mutational_advantage_shield_freezes_population_at_resolution` in +/// `game::effects::prevent_damage::tests` for the runtime freeze semantics: +/// verified against the official ruling that "the set of permanents +/// affected by Mutational Advantage is determined at the time Mutational +/// Advantage resolves"). Binding directly to a live copy of the grant's +/// filter would re-check "has a counter" at every future damage event, +/// which the ruling explicitly forbids. +#[test] +fn mutational_advantage_prevent_binds_to_countered_permanents_population() { + let def = parse_effect_chain( + "Permanents you control with counters on them gain hexproof and indestructible \ + until end of turn. Prevent all damage that would be dealt to those permanents \ + this turn.", + AbilityKind::Spell, + ); + + let Effect::GenericEffect { + static_abilities, + target: None, + .. + } = &*def.effect + else { + panic!( + "expected the hexproof/indestructible grant, got {:?}", + def.effect + ); + }; + static_abilities + .first() + .and_then(|sd| sd.affected.clone()) + .expect("the grant clause must carry an affected population filter"); + + let prevent = def + .sub_ability + .as_deref() + .expect("the prevent clause must chain after the grant"); + let Effect::PreventDamage { target, .. } = &*prevent.effect else { + panic!("expected PreventDamage, got {:?}", prevent.effect); + }; + assert_eq!( + *target, + TargetFilter::TrackedSet { + id: crate::types::identifiers::TrackedSetId(0) + }, + "\"those permanents\" must resolve to the TrackedSet sentinel, not a live copy \ + of the grant's filter (which would re-check \"has a counter\" live) or Any" + ); +} + +/// CR 615.1 (issue #6682): Blinding Fog's bare "creatures" recipient (no +/// anaphor, no antecedent clause — the prevent clause comes FIRST) must +/// resolve to an unqualified `Typed(Creature)` mass filter via `parse_target`'s +/// shared grammar, not the `Any` fallback that silently prevented damage to +/// players too. +#[test] +fn blinding_fog_prevent_binds_to_bare_creatures_recipient() { + let def = parse_effect_chain( + "Prevent all damage that would be dealt to creatures this turn. Creatures you \ + control gain hexproof until end of turn.", + AbilityKind::Spell, + ); + let Effect::PreventDamage { target, .. } = &*def.effect else { + panic!("expected PreventDamage, got {:?}", def.effect); + }; + assert!( + matches!( + target, + TargetFilter::Typed(tf) + if tf.type_filters.contains(&TypeFilter::Creature) && tf.controller.is_none() + ), + "bare \"creatures\" must resolve to an unqualified Typed(Creature) filter, got {target:?}" + ); +} + +/// CR 615.1 (issue #6682): Defend the Hearth's bare "players" recipient must +/// resolve to `TargetFilter::Player`, not the `Any` fallback that silently +/// prevented combat damage to creatures too. +#[test] +fn defend_the_hearth_prevent_binds_to_bare_players_recipient() { + let def = parse_effect_chain( + "Prevent all combat damage that would be dealt to players this turn.", + AbilityKind::Spell, + ); + let Effect::PreventDamage { target, scope, .. } = &*def.effect else { + panic!("expected PreventDamage, got {:?}", def.effect); + }; + assert_eq!(*target, TargetFilter::Player); + assert_eq!(*scope, PreventionScope::CombatDamage); +} + +/// CR 608.2c + CR 615 (issue #6682): Energy Arc's bidirectional "dealt to and +/// dealt by those creatures" must bind BOTH shields to the untapped creatures +/// selected by the preceding clause — a target-derived tracked-set anaphor — +/// not the `Any` fallback that silently protected/exposed every creature. +#[test] +fn energy_arc_bidirectional_prevent_binds_to_untapped_targets() { + let def = parse_effect_chain( + "Untap any number of target creatures. Prevent all combat damage that would be \ + dealt to and dealt by those creatures this turn.", + AbilityKind::Spell, + ); + let prevent = def + .sub_ability + .as_deref() + .expect("the prevent clause must chain after the untap"); + let Effect::PreventDamage { target, .. } = &*prevent.effect else { + panic!( + "expected the recipient (\"to\") shield, got {:?}", + prevent.effect + ); + }; + assert!( + matches!(target, TargetFilter::TrackedSet { .. }), + "the recipient shield must bind to the untapped-creatures tracked set, got {target:?}" + ); + assert_ne!(*target, TargetFilter::Any); + + let by_ability = prevent + .sub_ability + .as_deref() + .expect("the source (\"by\") shield must chain as a sequential sibling"); + let Effect::PreventDamage { + damage_source_filter, + .. + } = &*by_ability.effect + else { + panic!( + "expected the source (\"by\") shield, got {:?}", + by_ability.effect + ); + }; + assert!( + matches!(damage_source_filter, Some(TargetFilter::TrackedSet { .. })), + "the source shield must also bind to the same tracked set, got {damage_source_filter:?}" + ); +} + /// CR 111.3 + CR 118.12: A token created "with" a quoted activated ability whose /// effect is a soft counter ("Counter … unless its controller pays {mana}") must /// carry that whole ability, unless-pay included, on the TOKEN. Mage's Attendant's diff --git a/crates/engine/src/parser/oracle_target.rs b/crates/engine/src/parser/oracle_target.rs index 709c827e77..ae11384ebc 100644 --- a/crates/engine/src/parser/oracle_target.rs +++ b/crates/engine/src/parser/oracle_target.rs @@ -1629,6 +1629,18 @@ pub fn parse_target_with_syntax<'a>( return (TargetFilter::Controller, rest, syntax); } + // CR 615.1 + CR 615.1a: Bare "players" — a mass, untargeted player + // recipient with no "target" keyword (Defend the Hearth: "prevent all + // combat damage that would be dealt to players this turn"). `Player` has + // no `TypeFilter` representation (it is not a card type), so the + // `parse_type_filter_word`-based fallback below never recognizes it; this + // needs its own bare-noun arm, mirroring the "target player" arm above. + if let Some((_, rest)) = + nom_on_lower(text, &lower, |input| parse_word_bounded(input, "players")) + { + return (TargetFilter::Player, rest, syntax); + } + // "the top/bottom [N] [type] card[s] of [possessive] library/graveyard" // Zone position references that appear as targets of exile/mill/reveal effects. // Returns a filter with InZone for the referenced zone and controller. @@ -11759,6 +11771,24 @@ mod tests { ); } + /// CR 615.1 (issue #6682, Defend the Hearth class): bare "players" with no + /// "target" keyword must resolve to a mass player recipient, not the + /// unclassified `Any` fallback. + #[test] + fn bare_players_resolves_to_player_filter() { + let (f, rest) = parse_target("players"); + assert_eq!(rest, ""); + assert_eq!(f, TargetFilter::Player); + } + + /// Negative: "players" must still require a word boundary — "playerskip" + /// (a hypothetical longer word) must not spuriously match the bare noun. + #[test] + fn bare_players_requires_word_boundary() { + let (f, _rest) = parse_target("playersXYZ"); + assert_ne!(f, TargetFilter::Player); + } + /// Recursively check whether any leaf of the filter is `HasChosenName`. fn filter_contains_has_chosen_name(f: &TargetFilter) -> bool { match f { From bf3b9d9721364dfed27e4d85e97cc2c90f4448d9 Mon Sep 17 00:00:00 2001 From: Parth Mishra Date: Wed, 29 Jul 2026 19:22:17 -0400 Subject: [PATCH 21/63] feat(engine): support Ozai conditional mana static (#6795) * feat(engine): support Ozai conditional mana static * fix(engine): align continuous effect controller context * fix(engine): satisfy continuous effect lint * fix(engine): preserve transient combat controller --------- Co-authored-by: matthewevans --- crates/engine/src/game/layers.rs | 420 +++++++++++++++--- .../src/game/off_zone_characteristics.rs | 10 +- crates/engine/src/game/sba.rs | 8 +- .../engine/src/parser/oracle_nom/condition.rs | 29 ++ crates/engine/src/parser/oracle_tests.rs | 80 +++- crates/engine/tests/integration/main.rs | 1 + .../ozai_phoenix_king_unspent_mana.rs | 179 ++++++++ 7 files changed, 656 insertions(+), 71 deletions(-) create mode 100644 crates/engine/tests/integration/ozai_phoenix_king_unspent_mana.rs diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 05186f0bde..031120e96d 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -49,6 +49,7 @@ use crate::types::zones::Zone; struct ActiveCombatAssignmentRuleEffect { source_id: ObjectId, controller: PlayerId, + transient_id: Option, timestamp: u64, modification: ContinuousModification, affected_filter: TargetFilter, @@ -918,6 +919,54 @@ pub(crate) fn evaluate_condition_with_recipient( evaluate_condition_with_context(state, condition, controller, source_id, Some(recipient_id)) } +/// Selects the controller that supplies "you" for an active effect's +/// condition. Printed and granted static abilities read their source's current +/// controller; resolution-created transient continuous effects retain the +/// controller that created them. +pub(crate) fn active_effect_condition_controller( + state: &GameState, + effect: &ActiveContinuousEffect, +) -> PlayerId { + condition_controller( + state, + effect.transient_id, + effect.controller, + effect.source_id, + ) +} + +fn combat_effect_condition_controller( + state: &GameState, + effect: &ActiveCombatAssignmentRuleEffect, +) -> PlayerId { + condition_controller( + state, + effect.transient_id, + effect.controller, + effect.source_id, + ) +} + +/// Resolves the player named by "you" for a condition on either a printed or +/// resolution-created continuous effect. +// CR 109.5: "you"/"your" on a static ability refers to the current controller +// of its source, while a resolved spell or ability retains its controller. +fn condition_controller( + state: &GameState, + transient_id: Option, + controller: PlayerId, + source_id: ObjectId, +) -> PlayerId { + if transient_id.is_some() { + controller + } else { + state + .objects + .get(&source_id) + .map_or(controller, |source| source.controller) + } +} + fn condition_uses_recipient_context(condition: &StaticCondition) -> bool { match condition { StaticCondition::IsPresent { @@ -3470,15 +3519,10 @@ pub(crate) fn incremental_flush_must_escalate( } // Axis 2b — source-level enabling CONDITION over the EXISTING static-ability - // sources, NARROWED to entries that actually perturb the condition. The - // condition axis CANNOT be read off the collected `ActiveContinuousEffect`s: - // `active_continuous_effects_from_static_definitions` evaluates a - // non-recipient-context (source-level) condition as a gate at COLLECTION time - // and stores `condition: None` on the resulting effect (only recipient-context - // conditions are retained for per-recipient re-evaluation). So a board- - // population gate like "as long as you control N creatures" is already consumed - // and invisible on the active-effect set. We must inspect the intact - // `StaticDefinition.condition` on each live source instead. + // sources, NARROWED to entries that actually perturb the condition. Conditions + // remain attached to collected effects for application-time evaluation, while + // this source walk supplies the before/after truth comparison required for an + // incremental full-rebuild decision. // // CR 611.3a + CR 611.3b: when such a source-level enabling condition depends // on board population, an object entering can flip the condition for the @@ -3530,7 +3574,7 @@ fn active_effects_force_incremental_escalation( /// whose enabling `condition` is board-population-dependent AND that one of the /// `entered_ids` actually perturbs. Walks the same source set as /// `collect_shared_active_continuous_effects` (`for_each_static_effect_source`) -/// but reads the intact pre-collection `condition` field. +/// and reads the source definition's `condition` field. /// O(active-source-count × entered-count); short-circuits on the first match. /// /// Three-stage test: @@ -3545,8 +3589,7 @@ fn active_effects_force_incremental_escalation( /// cannot be summarized by a single board-level boolean /// (`source_condition_gate_passes` only over-approximates them). This /// preserves the d9a40be71 behavior for that class. -/// 3. SOURCE-LEVEL gates (CR 611.3a — a single on/off switch consumed at -/// collection): apply the truth-delta short-circuit. The static's BEFORE +/// 3. SOURCE-LEVEL gates: apply the truth-delta short-circuit. The static's BEFORE /// truth was cached at the last full eval in `static_gate_truth`; recompute /// AFTER against the live post-entry board. Escalate iff `before != after`. /// Key absent (source not present / phased out at the last full eval) -> @@ -4276,14 +4319,7 @@ fn active_continuous_effects_from_static_definitions( } } - let retained_condition = if let Some(condition) = &def.condition { - if !source_condition_gate_passes(state, condition, controller, source_id) { - continue; - } - condition_uses_recipient_context(condition).then(|| condition.clone()) - } else { - None - }; + let retained_condition = def.condition.clone(); let affected_filter = def.affected.clone().unwrap_or(TargetFilter::Any); for (mod_index, modification) in def.modifications.iter().enumerate() { @@ -4446,17 +4482,9 @@ fn expand_granted_static_effects( None => continue, }; // CR 109.5 + CR 113.7: "You" inside the granted ability refers to the - // recipient's controller. Re-run any inner condition gate with the - // recipient as the source so that gating like "during your turn" - // resolves against the recipient's controller. - let retained_inner_condition = if let Some(condition) = &inner.condition { - if !source_condition_gate_passes(state, condition, recipient_controller, recipient_id) { - continue; - } - condition_uses_recipient_context(condition).then(|| condition.clone()) - } else { - None - }; + // recipient's controller. Keep its condition until application, after + // preceding layers have established that controller. + let retained_inner_condition = inner.condition.clone(); for (mod_index, modification) in inner.modifications.iter().enumerate() { if is_combat_assignment_rule_modification(modification) { continue; @@ -4785,11 +4813,14 @@ pub(crate) fn gather_transient_continuous_effects( continue; } - let retained_condition = tce - .condition - .as_ref() - .filter(|condition| condition_uses_recipient_context(condition)) - .cloned(); + let retained_condition = if let Some(condition) = &tce.condition { + if !source_condition_gate_passes(state, condition, tce.controller, tce.source_id) { + continue; + } + condition_uses_recipient_context(condition).then(|| condition.clone()) + } else { + None + }; for (mod_index, modification) in tce.modifications.iter().enumerate() { if is_combat_assignment_rule_modification(modification) { @@ -4932,7 +4963,9 @@ fn apply_combat_assignment_rule_effects_filtered( for effect in effects { let scan_ids = effect_candidate_ids(state, &effect.affected_filter, &mut zone_cache); - let ctx = FilterContext::from_source(state, effect.source_id); + let condition_controller = combat_effect_condition_controller(state, &effect); + let ctx = + FilterContext::from_source_with_controller(effect.source_id, condition_controller); let affected_ids: Vec = scan_ids .iter() .filter(|&&id| restrict_to.is_none_or(|ids| ids.contains(&id))) @@ -4942,7 +4975,7 @@ fn apply_combat_assignment_rule_effects_filtered( evaluate_condition_with_recipient( state, condition, - effect.controller, + condition_controller, effect.source_id, id, ) @@ -5074,14 +5107,7 @@ fn active_combat_assignment_rule_effects_from_static_definitions( continue; } - let retained_condition = if let Some(condition) = &def.condition { - if !source_condition_gate_passes(state, condition, controller, source_id) { - continue; - } - condition_uses_recipient_context(condition).then(|| condition.clone()) - } else { - None - }; + let retained_condition = def.condition.clone(); let affected_filter = def.affected.clone().unwrap_or(TargetFilter::Any); effects.extend( @@ -5091,6 +5117,7 @@ fn active_combat_assignment_rule_effects_from_static_definitions( .map(|modification| ActiveCombatAssignmentRuleEffect { source_id, controller, + transient_id: None, timestamp, modification: modification.clone(), affected_filter: affected_filter.clone(), @@ -5136,6 +5163,7 @@ fn collect_transient_combat_assignment_rule_effects( .map(|modification| ActiveCombatAssignmentRuleEffect { source_id: tce.source_id, controller: tce.controller, + transient_id: Some(tce.id), timestamp: tce.timestamp, modification: modification.clone(), affected_filter: tce.affected.clone(), @@ -5960,11 +5988,17 @@ fn apply_continuous_effect_filtered( // `MustAttackAwayFromSource` affected filter intact // (`effects/effect.rs`) — see the T13 regression // (`affected_population_does_not_follow_a_stolen_source`). - let ctx = if effect.transient_id.is_some() { - FilterContext::from_source_with_controller(effect.source_id, effect.controller) - } else { - FilterContext::from_source(state, effect.source_id) - }; + let condition_controller = active_effect_condition_controller(state, effect); + let ctx = + FilterContext::from_source_with_controller(effect.source_id, condition_controller); + let condition_uses_recipient = effect + .condition + .as_ref() + .is_some_and(condition_uses_recipient_context); + let non_recipient_condition_passes = effect.condition.as_ref().is_none_or(|condition| { + condition_uses_recipient + || evaluate_condition(state, condition, condition_controller, effect.source_id) + }); newly_affected_ids = scan_ids .iter() // Incremental fast path: re-apply only to the freshly-entered objects. @@ -5973,15 +6007,17 @@ fn apply_continuous_effect_filtered( .filter(|&&id| restrict_to.is_none_or(|ids| ids.contains(&id))) .filter(|&&id| matches_target_filter(state, id, &effect.affected_filter, &ctx)) .filter(|&&id| { - effect.condition.as_ref().is_none_or(|condition| { - evaluate_condition_with_recipient( - state, - condition, - effect.controller, - effect.source_id, - id, - ) - }) + non_recipient_condition_passes + && effect.condition.as_ref().is_none_or(|condition| { + !condition_uses_recipient + || evaluate_condition_with_recipient( + state, + condition, + condition_controller, + effect.source_id, + id, + ) + }) }) .copied() .collect(); @@ -7093,11 +7129,15 @@ pub(crate) fn compute_current_copiable_values( gather_active_effects_for_layer(state, Layer::Copy) .into_iter() .filter(|effect| { + let condition_controller = active_effect_condition_controller(state, effect); matches_target_filter( state, object_id, &effect.affected_filter, - &FilterContext::from_source(state, effect.source_id), + &FilterContext::from_source_with_controller( + effect.source_id, + condition_controller, + ), ) }) .filter(|effect| { @@ -7105,7 +7145,7 @@ pub(crate) fn compute_current_copiable_values( evaluate_condition_with_recipient( state, condition, - effect.controller, + active_effect_condition_controller(state, effect), effect.source_id, object_id, ) @@ -7261,7 +7301,7 @@ mod tests { use crate::types::game_state::{LayersDirty, StaticSourceIndex, TransientContinuousEffect}; use crate::types::identifiers::CardId; use crate::types::keywords::Keyword; - use crate::types::mana::{ManaColor, ManaCost, ManaCostShard}; + use crate::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType, ManaUnit}; use crate::types::player::PlayerId; use crate::types::replacements::ReplacementEvent; use crate::types::statics::StaticMode; @@ -7331,6 +7371,46 @@ mod tests { id } + fn add_unspent_blue_mana(state: &mut GameState, player: PlayerId, count: usize) { + let player = state + .players + .iter_mut() + .find(|candidate| candidate.id == player) + .expect("test player must exist"); + for _ in 0..count { + player.mana_pool.add(ManaUnit { + color: ManaType::Blue, + source_id: ObjectId(0), + pip_id: crate::types::mana::ManaPipId(0), + supertype: None, + source_could_produce_two_or_more_colors: false, + restrictions: Vec::new(), + grants: Vec::new(), + expiry: None, + }); + } + } + + fn six_unspent_mana_condition() -> StaticCondition { + StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::UnspentMana { color: None }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 6 }, + } + } + + fn install_static_definition( + state: &mut GameState, + object_id: ObjectId, + def: StaticDefinition, + ) { + let object = state.objects.get_mut(&object_id).unwrap(); + Arc::make_mut(&mut object.base_static_definitions).push(def.clone()); + object.static_definitions.push(def); + } + /// A bare non-creature Treasure token permanent (CR 111.1: `is_token`; CR 111.6: /// carries the Artifact card type and Treasure subtype). No intrinsic abilities — /// so any activated mana ability it ends up with came from a layer-6 grant. @@ -8201,6 +8281,220 @@ mod tests { assert!(!state.objects[&id].assigns_damage_as_though_unblocked); } + #[test] + fn printed_static_condition_uses_controller_after_layer_two_in_one_pass() { + let mut state = setup(); + let source = make_creature(&mut state, "Conditional Source", 2, 2, P0); + install_static_definition( + &mut state, + source, + StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::AddKeyword { + keyword: Keyword::Flying, + }]) + .condition(six_unspent_mana_condition()), + ); + add_unspent_blue_mana(&mut state, P0, 5); + add_unspent_blue_mana(&mut state, P1, 6); + state.add_transient_continuous_effect( + source, + P1, + Duration::Permanent, + TargetFilter::SpecificObject { id: source }, + vec![ContinuousModification::ChangeController], + None, + ); + + evaluate_layers(&mut state); + + let source = &state.objects[&source]; + assert_eq!(source.controller, P1); + assert!( + source.has_keyword(&Keyword::Flying), + "a layer-6 printed static must read the layer-2 controller in the same pass" + ); + } + + #[test] + fn transient_static_condition_keeps_its_captured_controller() { + let mut state = setup(); + let source = make_creature(&mut state, "Transient Source", 2, 2, P0); + let recipient = make_creature(&mut state, "Transient Recipient", 2, 2, P0); + add_unspent_blue_mana(&mut state, P0, 6); + state.add_transient_continuous_effect( + source, + P0, + Duration::Permanent, + TargetFilter::SpecificObject { id: recipient }, + vec![ContinuousModification::AddKeyword { + keyword: Keyword::Flying, + }], + Some(six_unspent_mana_condition()), + ); + state.add_transient_continuous_effect( + source, + P1, + Duration::Permanent, + TargetFilter::SpecificObject { id: source }, + vec![ContinuousModification::ChangeController], + None, + ); + + evaluate_layers(&mut state); + + assert_eq!(state.objects[&source].controller, P1); + assert!( + state.objects[&recipient].has_keyword(&Keyword::Flying), + "a resolution-created effect must keep its captured P0 condition context" + ); + } + + #[test] + fn granted_static_condition_uses_its_recipient_controller_after_layer_two() { + let mut state = setup(); + let grantor = make_creature(&mut state, "Grantor", 2, 2, P0); + let recipient = make_creature(&mut state, "Granted Static Source", 2, 2, P0); + let inner = StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::AddKeyword { + keyword: Keyword::Flying, + }]) + .condition(six_unspent_mana_condition()); + install_static_definition( + &mut state, + grantor, + StaticDefinition::continuous() + .affected(TargetFilter::SpecificObject { id: recipient }) + .modifications(vec![ContinuousModification::GrantStaticAbility { + definition: Box::new(inner), + }]), + ); + add_unspent_blue_mana(&mut state, P0, 5); + add_unspent_blue_mana(&mut state, P1, 6); + state.add_transient_continuous_effect( + grantor, + P1, + Duration::Permanent, + TargetFilter::SpecificObject { id: recipient }, + vec![ContinuousModification::ChangeController], + None, + ); + + evaluate_layers(&mut state); + + let recipient = &state.objects[&recipient]; + assert_eq!(recipient.controller, P1); + assert!( + recipient.has_keyword(&Keyword::Flying), + "a granted static must bind its condition to the recipient's layer-2 controller" + ); + } + + #[test] + fn combat_assignment_static_condition_uses_controller_after_layer_two() { + let mut state = setup(); + let source = make_creature(&mut state, "Combat Conditional Source", 2, 2, P0); + install_static_definition( + &mut state, + source, + StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::AssignDamageFromToughness]) + .condition(six_unspent_mana_condition()), + ); + add_unspent_blue_mana(&mut state, P0, 5); + add_unspent_blue_mana(&mut state, P1, 6); + state.add_transient_continuous_effect( + source, + P1, + Duration::Permanent, + TargetFilter::SpecificObject { id: source }, + vec![ContinuousModification::ChangeController], + None, + ); + + evaluate_layers(&mut state); + + let source = &state.objects[&source]; + assert_eq!(source.controller, P1); + assert!( + source.assigns_damage_from_toughness, + "post-layer combat rules must use the layer-2 controller for their condition" + ); + } + + #[test] + fn transient_combat_rule_condition_stays_off_when_captured_controller_fails() { + let mut state = setup(); + let source = make_creature(&mut state, "Transient Combat Source", 2, 2, P0); + add_unspent_blue_mana(&mut state, P1, 6); + state.add_transient_continuous_effect( + source, + P0, + Duration::Permanent, + TargetFilter::SpecificObject { id: source }, + vec![ContinuousModification::AssignDamageFromToughness], + Some(six_unspent_mana_condition()), + ); + state.add_transient_continuous_effect( + source, + P1, + Duration::Permanent, + TargetFilter::SpecificObject { id: source }, + vec![ContinuousModification::ChangeController], + None, + ); + + evaluate_layers(&mut state); + + let source = &state.objects[&source]; + assert_eq!(source.controller, P1); + assert!( + !source.assigns_damage_from_toughness, + "a transient combat rule must remain gated by its captured P0 controller" + ); + } + + #[test] + fn transient_combat_rule_filter_uses_captured_controller_after_source_is_stolen() { + // CR 109.5 + CR 613.11: a transient combat-assignment effect's + // controller-relative recipient filter remains bound to its creator, + // even after a later layer-2 effect changes the source's controller. + let mut state = setup(); + let source = make_creature(&mut state, "Transient Combat Source", 2, 2, P0); + let p0_recipient = make_creature(&mut state, "P0 Combat Recipient", 2, 2, P0); + let p1_recipient = make_creature(&mut state, "P1 Combat Recipient", 2, 2, P1); + state.add_transient_continuous_effect( + source, + P0, + Duration::Permanent, + creature_you_ctrl(), + vec![ContinuousModification::AssignDamageFromToughness], + None, + ); + state.add_transient_continuous_effect( + source, + P1, + Duration::Permanent, + TargetFilter::SpecificObject { id: source }, + vec![ContinuousModification::ChangeController], + None, + ); + + evaluate_layers(&mut state); + + assert_eq!(state.objects[&source].controller, P1); + assert!( + state.objects[&p0_recipient].assigns_damage_from_toughness, + "the captured P0 controller determines the transient effect's recipients" + ); + assert!( + !state.objects[&p1_recipient].assigns_damage_from_toughness, + "stealing the source must not retarget the transient effect to P1's creatures" + ); + } + #[test] fn combat_assignment_rule_effects_observe_final_layered_characteristics() { let mut state = setup(); diff --git a/crates/engine/src/game/off_zone_characteristics.rs b/crates/engine/src/game/off_zone_characteristics.rs index 70419cfebc..77d47ec710 100644 --- a/crates/engine/src/game/off_zone_characteristics.rs +++ b/crates/engine/src/game/off_zone_characteristics.rs @@ -2,8 +2,9 @@ use crate::game::filter::{ matches_target_filter, matches_target_filter_in_owner_zone, FilterContext, }; use crate::game::layers::{ - active_continuous_effects_from_base_static_source, collect_shared_active_continuous_effects, - evaluate_condition_with_recipient, order_active_continuous_effects, + active_continuous_effects_from_base_static_source, active_effect_condition_controller, + collect_shared_active_continuous_effects, evaluate_condition_with_recipient, + order_active_continuous_effects, }; use crate::game::quantity::resolve_quantity; use crate::types::ability::{ContinuousModification, TargetFilter, TriggerProducerOrigin}; @@ -172,8 +173,9 @@ pub(crate) fn collect_applicable_off_zone_keyword_effects( effects .into_iter() .filter(|effect| { + let condition_controller = active_effect_condition_controller(state, effect); let ctx = - FilterContext::from_source_with_controller(effect.source_id, effect.controller); + FilterContext::from_source_with_controller(effect.source_id, condition_controller); effect.layer == Layer::Ability && supports_off_zone_keyword_query(&effect.modification) && matches_off_zone_keyword_recipient( @@ -187,7 +189,7 @@ pub(crate) fn collect_applicable_off_zone_keyword_effects( evaluate_condition_with_recipient( state, condition, - effect.controller, + condition_controller, effect.source_id, object_id, ) diff --git a/crates/engine/src/game/sba.rs b/crates/engine/src/game/sba.rs index 668fbcc188..3886b06e24 100644 --- a/crates/engine/src/game/sba.rs +++ b/crates/engine/src/game/sba.rs @@ -1527,13 +1527,17 @@ fn world_acquisition_timestamp( .filter(|effect| { // Mirror apply_continuous_effect_filtered (layers.rs:4314-4332): // recipient must match affected_filter AND the effect's condition. - let ctx = crate::game::filter::FilterContext::from_source(state, effect.source_id); + let condition_controller = layers::active_effect_condition_controller(state, effect); + let ctx = crate::game::filter::FilterContext::from_source_with_controller( + effect.source_id, + condition_controller, + ); crate::game::filter::matches_target_filter(state, obj.id, &effect.affected_filter, &ctx) && effect.condition.as_ref().is_none_or(|condition| { layers::evaluate_condition_with_recipient( state, condition, - effect.controller, + condition_controller, effect.source_id, obj.id, ) diff --git a/crates/engine/src/parser/oracle_nom/condition.rs b/crates/engine/src/parser/oracle_nom/condition.rs index 084371bb9a..06be32c63e 100644 --- a/crates/engine/src/parser/oracle_nom/condition.rs +++ b/crates/engine/src/parser/oracle_nom/condition.rs @@ -3333,6 +3333,12 @@ fn parse_you_have_conditions(input: &str) -> OracleResult<'_, StaticCondition> { } if let Ok((after_or_more, _)) = tag::<_, _, OracleError<'_>>(" or more ").parse(rest) { + if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>("unspent mana").parse(after_or_more) { + return Ok(( + rest, + make_quantity_ge(QuantityRef::UnspentMana { color: None }, n), + )); + } // CR 603.4 + CR 404.2: Oversold Cemetery's intervening-if predicate // counts face-up creature cards in its controller's graveyard. if let Ok((rest, type_filters)) = @@ -14048,6 +14054,29 @@ mod tests { ); } + #[test] + fn you_have_or_more_unspent_mana_parses_word_and_digit_thresholds() { + for (text, expected) in [ + ("you have six or more unspent mana", 6), + ("you have 6 or more unspent mana", 6), + ("you have five or more unspent mana", 5), + ] { + let (rest, condition) = parse_inner_condition(text).unwrap(); + assert_eq!(rest, "", "must fully consume {text:?}"); + assert_eq!( + condition, + StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::UnspentMana { color: None }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: expected }, + }, + "expected total unspent mana threshold for {text:?}", + ); + } + } + #[test] fn test_you_have_fewer_cards_in_hand() { let (rest, c) = parse_inner_condition("you have two or fewer cards in hand").unwrap(); diff --git a/crates/engine/src/parser/oracle_tests.rs b/crates/engine/src/parser/oracle_tests.rs index ee50378e7f..8e38f075b1 100644 --- a/crates/engine/src/parser/oracle_tests.rs +++ b/crates/engine/src/parser/oracle_tests.rs @@ -36,6 +36,82 @@ fn unsupported_ability_ir_lowering_preserves_generic_and_structural_payloads() { assert_eq!(structural.description.as_deref(), Some("unsupported line")); } +#[test] +fn ozai_document_ir_lowers_keyword_transform_and_unspent_mana_gate() { + const ORACLE: &str = "Trample, firebending 4, haste\nIf you would lose unspent mana, that mana becomes red instead.\nOzai has flying and indestructible as long as you have six or more unspent mana."; + let keyword_names = [ + "trample".to_string(), + "firebending".to_string(), + "haste".to_string(), + ]; + let types = ["Legendary".to_string(), "Creature".to_string()]; + let subtypes = [ + "Human".to_string(), + "Noble".to_string(), + "Wizard".to_string(), + ]; + + let mut ir = parse_oracle_ir( + ORACLE, + "Ozai, the Phoenix King", + &keyword_names, + &types, + &subtypes, + ); + assert!( + ir.diagnostics.is_empty(), + "unexpected parse diagnostics: {ir:#?}" + ); + assert!( + ir.items + .iter() + .all(|item| !matches!(item.node, OracleNodeIr::Unsupported { .. })), + "every printed Ozai line must have a typed IR node: {ir:#?}" + ); + let parsed = lower_oracle_ir(&mut ir); + assert!( + parsed.parse_warnings.is_empty(), + "lowering must preserve a fully supported Ozai document: {:#?}", + parsed.parse_warnings + ); + assert!(parsed.statics.iter().any(|definition| matches!( + definition.mode, + StaticMode::StepEndUnspentMana { + filter: None, + action: StepEndManaAction::Transform(ManaType::Red), + } + ) && definition.affected + == Some(TargetFilter::Controller))); + + let gate = parsed + .statics + .iter() + .find(|definition| { + matches!( + definition.condition, + Some(StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::UnspentMana { color: None }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 6 }, + }) + ) + }) + .expect("Ozai's six-unspent-mana conditional static"); + assert_eq!(gate.affected, Some(TargetFilter::SelfRef)); + assert!(gate + .modifications + .contains(&ContinuousModification::AddKeyword { + keyword: Keyword::Flying, + })); + assert!(gate + .modifications + .contains(&ContinuousModification::AddKeyword { + keyword: Keyword::Indestructible, + })); +} + #[test] fn nominal_dispatch_preserves_precomputed_x_floor_for_spells_and_residuals() { let types = ["Creature".to_string()]; @@ -1193,8 +1269,8 @@ use crate::types::ability::{ SacrificeCost, SacrificeRequirement, SharedQuality, SharedQualityRelation, ShieldKind, StaticCondition, TapStateChange, TargetFilter, TriggerCondition, TypeFilter, TypedFilter, }; -use crate::types::keywords::{FlashbackCost, KeywordKind, WardCost}; -use crate::types::mana::{ManaColor, ManaCost, ManaCostShard}; +use crate::types::keywords::{FlashbackCost, Keyword, KeywordKind, WardCost}; +use crate::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType, StepEndManaAction}; use crate::types::replacements::ReplacementEvent; use crate::types::statics::{CostModifyMode, ProhibitionScope, StaticMode}; use crate::types::triggers::{PlaneswalkRole, TriggerMode}; diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 94f16542de..f6064a4c3e 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1097,6 +1097,7 @@ mod otherwise_conditional_effects; mod outrageous_robbery_look_and_play; mod overlord_impending_offer_2859; mod owner_life_change_routing_3351; +mod ozai_phoenix_king_unspent_mana; mod pact_of_negation_upkeep_payment; mod park_heights_pegasus_draw_condition_runtime; mod parker_luck; diff --git a/crates/engine/tests/integration/ozai_phoenix_king_unspent_mana.rs b/crates/engine/tests/integration/ozai_phoenix_king_unspent_mana.rs new file mode 100644 index 0000000000..a3a7b3fa72 --- /dev/null +++ b/crates/engine/tests/integration/ozai_phoenix_king_unspent_mana.rs @@ -0,0 +1,179 @@ +use engine::game::keywords::has_keyword; +use engine::game::layers::flush_layers; +use engine::game::mana_payment; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::game::turns::advance_phase; +use engine::types::ability::{ContinuousModification, Duration, TargetFilter}; +use engine::types::identifiers::ObjectId; +use engine::types::keywords::Keyword; +use engine::types::mana::{ManaCost, ManaCostShard, ManaType}; +use engine::types::phase::Phase; + +const OZAI_ORACLE: &str = "Trample, firebending 4, haste\nIf you would lose unspent mana, that mana becomes red instead.\nOzai has flying and indestructible as long as you have six or more unspent mana."; +const OPT_ORACLE: &str = "Scry 1.\nDraw a card."; + +fn produce_blue( + runner: &mut GameRunner, + source: ObjectId, + player: engine::types::player::PlayerId, + count: usize, +) { + for _ in 0..count { + let mut events = Vec::new(); + mana_payment::produce_mana( + runner.state_mut(), + source, + ManaType::Blue, + player, + false, + &mut events, + ); + } +} + +fn has_keyword_after_layers(runner: &mut GameRunner, object: ObjectId, keyword: &Keyword) -> bool { + flush_layers(runner.state_mut()); + has_keyword(&runner.state().objects[&object], keyword) +} + +#[test] +fn ozai_unspent_mana_gate_tracks_live_controller_and_cast_payment() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_library_top(P0, &["Drawn Card"]); + + let ozai = scenario + .add_creature(P0, "Ozai, the Phoenix King", 4, 4) + .from_oracle_text_with_keywords(&["trample", "firebending", "haste"], OZAI_ORACLE) + .id(); + let p1_mana_source = scenario.add_creature(P1, "P1 Mana Source", 1, 1).id(); + let thief = scenario + .add_creature(P1, "Control Effect Source", 1, 1) + .id(); + let opt = scenario + .add_spell_to_hand_from_oracle(P0, "Opt", true, OPT_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Blue], + generic: 0, + }) + .id(); + let mut runner = scenario.build(); + + produce_blue(&mut runner, p1_mana_source, P1, 6); + assert!( + !has_keyword_after_layers(&mut runner, ozai, &Keyword::Flying), + "P1's mana must not enable P0's Ozai" + ); + assert!( + !has_keyword_after_layers(&mut runner, ozai, &Keyword::Indestructible), + "the negative controller fixture must reach Ozai's parsed gate" + ); + + produce_blue(&mut runner, ozai, P0, 6); + assert!( + has_keyword_after_layers(&mut runner, ozai, &Keyword::Flying), + "six unspent mana enables flying" + ); + assert!( + has_keyword_after_layers(&mut runner, ozai, &Keyword::Indestructible), + "six unspent mana enables indestructible" + ); + assert!(has_keyword_after_layers( + &mut runner, + ozai, + &Keyword::Trample + )); + assert!(has_keyword_after_layers(&mut runner, ozai, &Keyword::Haste)); + + let outcome = runner.cast(opt).resolve(); + outcome.assert_hand_drawn(P0, 1); + + assert!( + !has_keyword_after_layers(&mut runner, ozai, &Keyword::Flying), + "paying one mana through the casting pipeline drops Ozai below six" + ); + assert!( + !has_keyword_after_layers(&mut runner, ozai, &Keyword::Indestructible), + "both conditional grants must be invalidated at five mana" + ); + assert!( + has_keyword_after_layers(&mut runner, ozai, &Keyword::Trample), + "printed trample must survive conditional-grant invalidation" + ); + assert!( + has_keyword_after_layers(&mut runner, ozai, &Keyword::Haste), + "printed haste must survive conditional-grant invalidation" + ); + + runner.state_mut().add_transient_continuous_effect( + thief, + P1, + Duration::Permanent, + TargetFilter::SpecificObject { id: ozai }, + vec![ContinuousModification::ChangeController], + None, + ); + flush_layers(runner.state_mut()); + assert_eq!(runner.state().objects[&ozai].controller, P1); + assert!( + has_keyword(&runner.state().objects[&ozai], &Keyword::Flying), + "one layer flush must evaluate Ozai's layer-6 gate with P1 as controller" + ); + assert!( + has_keyword(&runner.state().objects[&ozai], &Keyword::Indestructible,), + "P1's six mana enables the stolen Ozai" + ); +} + +#[test] +fn ozai_recolors_unspent_mana_when_entering_cleanup() { + // CR 500.5 + CR 614.1a: Ozai replaces the loss of its controller's + // unspent mana at the cleanup-step boundary, preserving the six-mana gate. + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::End); + let ozai = scenario + .add_creature(P0, "Ozai, the Phoenix King", 4, 4) + .from_oracle_text_with_keywords(&["trample", "firebending", "haste"], OZAI_ORACLE) + .id(); + let mut runner = scenario.build(); + + produce_blue(&mut runner, ozai, P0, 6); + assert!(has_keyword_after_layers( + &mut runner, + ozai, + &Keyword::Flying + )); + assert!(has_keyword_after_layers( + &mut runner, + ozai, + &Keyword::Indestructible + )); + + advance_phase(runner.state_mut(), &mut Vec::new()); + + assert_eq!(runner.state().phase, Phase::Cleanup); + assert_eq!(runner.state().players[P0.0 as usize].mana_pool.total(), 6); + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Red), + 6, + "Ozai turns would-be-lost mana red rather than letting it empty" + ); + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Blue), + 0 + ); + assert!(has_keyword_after_layers( + &mut runner, + ozai, + &Keyword::Flying + )); + assert!(has_keyword_after_layers( + &mut runner, + ozai, + &Keyword::Indestructible + )); +} From d5e5fed6f15f88a50e1325831a6c89eb0294b58b Mon Sep 17 00:00:00 2001 From: Clayton <118192227+claytonlin1110@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:51:03 -0500 Subject: [PATCH 22/63] fix(engine): resolution-time X-mana payment pays colored pips, not just generic (#6410) (#6787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(engine): resolution-time X-mana payment pays colored pips, not just generic (#6410) Elenda and Azor's attack trigger pays {X}{W}{U}{B}, but the resolution-time X-payment prompt (WaitingFor::PayAmountChoice{resource: ManaGeneric}) only ever charged the chosen amount as pure generic mana, silently dropping any colored pips alongside the X shard. PayableResource::ManaGeneric now carries the full uncocretized base cost; the submit handler concretizes X into it (ManaCost::concretize_x) and pays the resulting cost through the standard mana authority, so colored requirements are enforced like any other cost. Co-Authored-By: Claude Sonnet 5 * fix(engine): fail closed on missing base_cost, correct CR citations (#6410) Review follow-up on the ManaGeneric colored-pip fix: - Remove #[serde(default)] from PayableResource::ManaGeneric::base_cost. A legacy or incomplete serialized prompt missing the field was silently defaulting to ManaCost::zero() and then concretizing to a FREE payment (drops both the fixed pips and the X basis) instead of failing safely. Absence is now a hard deserialization error. Added a regression test locking this in for both an empty data object and the pre-fix per_x-only wire shape. - Correct the CR citations introduced by the original fix: CR 601.2f/601.2h govern spell-casting cost determination/payment and don't apply to a triggered ability's resolution-time PayCost effect. CR 107.1b is about disallowing negative/fractional values, not X. Replaced with CR 107.3f (X chosen "as it resolves" when it appears in effect text rather than a cast/activation cost) + CR 118.1 (cost definition) + CR 118.12 (the established citation this file already uses throughout for the "you may pay X. If you do, ..." resolution-time cost shape). Co-Authored-By: Claude Sonnet 5 * fix(engine): split unpayable fixed pip from a valid X=0 max (#6410) Review follow-up: max_resolution_mana_x_value collapsed "X=0 is the genuinely payable max" and "not even X=0 is payable" (e.g. {X}{W} with no white mana available) into the same max = 0, so resolve() always opened a [0, 0] WaitingFor::PayAmountChoice regardless of which case applied. When the fixed pip was actually unpayable, this let CR 608.2d be violated by presenting an impossible "pay 0" choice, then failed with an internal InvalidAction on submit instead of going through the ordinary payment-failure path. Renamed to resolution_mana_x_max and changed its return type to Option: None means the cost's fixed portion can't be paid at any X (no legal amount exists, so the caller sets cost_payment_failed_flag and never opens the prompt); Some(max) means X=0..=max is a valid range as before. Added a regression pair: an {X}{W} cost with no white mana fails immediately with no PayAmountChoice and no IfYouDo draw, and a control case with exactly the {W} pip available still presents and correctly resolves the payable [0, 0] prompt. Co-Authored-By: Claude Sonnet 5 * fix(protocol): bump wire protocol versions for the ManaGeneric shape change (#6410) PayableResource::ManaGeneric changed from { per_x } to { base_cost: ManaCost } in this branch — a GameState payload field type change with no serde default on base_cost (intentional: a missing base_cost must fail deserialization, not silently resolve to a zero-cost payment). Full-server messages and P2P game_setup/reconnect_ack both carry serialized GameState snapshots, so this is an incompatible wire shape that neither protocol version advertised. Bump both independently, per the two separate compatibility boundaries they gate: - Full-server/lobby/client WebSocket protocol: 22 -> 23 across crates/lobby-broker/src/protocol.rs (canonical PROTOCOL_VERSION + its pinned handshake test), crates/server-core/src/protocol.rs (pinned test, now protocol_version_is_23), client/src/adapter/ws-adapter.ts (mirrored literal + doc log), and scripts/check-protocol-version.mjs (the cross-language pin the protocol:check CI step enforces — missed updating this alongside the other three would have broken type-check). - P2P wire protocol (game_setup/reconnect_ack, browser-to-browser only): 15 -> 16 in client/src/network/protocol.ts, with its pinned round-trip test updated to match. All other call sites reference the constants symbolically (MIN_SUPPORTED_*, handshake-mismatch tests using PROTOCOL_VERSION +/- 1, etc.) and needed no changes. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- client/src/adapter/types.ts | 6 +- client/src/adapter/ws-adapter.ts | 8 +- client/src/network/protocol.ts | 6 + crates/engine/src/game/effects/pay.rs | 487 ++++++++++++++++-- .../src/game/engine_resolution_choices.rs | 18 +- crates/engine/src/types/game_state.rs | 51 +- crates/lobby-broker/src/protocol.rs | 12 +- crates/server-core/src/protocol.rs | 4 +- scripts/check-protocol-version.mjs | 2 +- 9 files changed, 536 insertions(+), 58 deletions(-) diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 795556b0f9..ec938837a8 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -2354,7 +2354,11 @@ export type LoopCollapseAxis = "Tokens" | "Counters" | "Life" | "Mixed"; export type PayableResource = | { type: "Energy" } - | { type: "ManaGeneric"; data: { per_x: number } } + // CR 107.3f + CR 118.1 + CR 118.12: `base_cost` is the UNCONCRETIZED mana + // cost (still carrying the X shard alongside any colored/generic pips, + // e.g. `{X}{W}{U}{B}`) — the engine concretizes X into it and pays the + // full result, so colored requirements are never dropped (#6410). + | { type: "ManaGeneric"; data: { base_cost: ManaCost } } | { type: "Counters" } | { type: "Speed" } // CR 732.2a: not a resource payment — the finite count an accepted diff --git a/client/src/adapter/ws-adapter.ts b/client/src/adapter/ws-adapter.ts index b8bfc30db9..8d3024f348 100644 --- a/client/src/adapter/ws-adapter.ts +++ b/client/src/adapter/ws-adapter.ts @@ -101,6 +101,12 @@ export class NativeEngineVersionMismatchError extends Error { * `crates/server-core/src/protocol.rs`. Bump in lockstep when either side * adds, removes, renames, or changes the type of a protocol variant field. * + * 23 — PayableResource::ManaGeneric changed from { per_x } to + * { base_cost: ManaCost } (#6410) — a GameState payload field type + * change, and base_cost intentionally carries no serde default (a + * missing base_cost must fail deserialization, not silently resolve + * to a zero-cost payment), so old and new peers can't parse each + * other's serialized state. * 22 — Viewer interaction projections and semantic object-action identities. * 21 — Native P2P host bridge identity and server-authored state revisions. * 20 — Actor-scoped priority-passing settings and filtered per-player state. @@ -115,7 +121,7 @@ export class NativeEngineVersionMismatchError extends Error { * into a MulliganDecisionPhase::BottomCards sub-phase on * WaitingFor::MulliganDecision. */ -export const PROTOCOL_VERSION = 22; +export const PROTOCOL_VERSION = 23; /** * Lowest server protocol version this client will accept in the handshake. diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 46808bdaae..1ed4258c2e 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -68,6 +68,12 @@ export function legalActionsFromWire(wire: LegalActionsWire): LegalActionsResult * of silently corrupting state. * * Bumps to date: + * 16 — PayableResource::ManaGeneric changed from { per_x } to + * { base_cost: ManaCost } (#6410) — a GameState payload field type + * change, and base_cost intentionally carries no serde default (a + * missing base_cost must fail deserialization, not silently resolve + * to a zero-cost payment), so old and new peers can't parse each + * other's serialized snapshots. * 1 — pre-compression JSON-serialization era (no longer in production) * 2 — gzip + version-prefixed binary wire format * 3 — Planechase state and action payloads in game_setup/reconnect snapshots diff --git a/crates/engine/src/game/effects/pay.rs b/crates/engine/src/game/effects/pay.rs index 1ba0c712c2..85b7c6b568 100644 --- a/crates/engine/src/game/effects/pay.rs +++ b/crates/engine/src/game/effects/pay.rs @@ -5,7 +5,7 @@ use crate::game::{casting, casting_costs}; use crate::types::ability::{AbilityCost, Effect, QuantityExpr, QuantityRef}; use crate::types::events::GameEvent; use crate::types::game_state::{GameState, PayableResource, WaitingFor}; -use crate::types::mana::{ManaCost, ManaCostShard}; +use crate::types::mana::ManaCost; use crate::types::player::PlayerId; use super::{EffectError, ResolvedAbility}; @@ -89,19 +89,34 @@ pub fn resolve( AbilityCost::Mana { cost: mana_cost } if payment_ability.chosen_x.is_none() && casting_costs::cost_has_x(mana_cost) => { - let per_x = mana_x_shard_count(mana_cost); - let max = max_resolution_mana_x_value(state, payer, ability.source_id, mana_cost); - let max = - trigger_event_amount_for_x_payment(state).map_or(max, |amount| max.min(amount)); - state.waiting_for = WaitingFor::PayAmountChoice { - player: payer, - resource: PayableResource::ManaGeneric { per_x }, - min: 0, - max, - accumulated: 0, - source_id: ability.source_id, - pending_mana_ability: None, - }; + match resolution_mana_x_max(state, payer, ability.source_id, mana_cost) { + Some(max) => { + let max = trigger_event_amount_for_x_payment(state) + .map_or(max, |amount| max.min(amount)); + state.waiting_for = WaitingFor::PayAmountChoice { + player: payer, + resource: PayableResource::ManaGeneric { + base_cost: mana_cost.clone(), + }, + min: 0, + max, + accumulated: 0, + source_id: ability.source_id, + pending_mana_ability: None, + }; + } + // CR 608.2d + CR 118.12: not even X=0 is payable — the + // cost's fixed portion (colored/generic pips alongside X, + // e.g. `{X}{W}` with no white available) can't be paid + // regardless of what X is chosen, so there is no legal + // amount to offer. Presenting a `[0, 0]` prompt here would + // let the player "choose" an impossible option; fail the + // payment outright instead, exactly like any other + // unaffordable resolution-time cost. + None => { + state.cost_payment_failed_flag = true; + } + } } // CR 107.1c + CR 107.14: "Pay any amount of {E}" — suspend the chain and // surface a `PayAmountChoice` prompt. The sub-ability continuation @@ -291,25 +306,27 @@ fn resolve_ability_cost_payment( } } -fn mana_x_shard_count(cost: &ManaCost) -> u32 { - match cost { - ManaCost::Cost { shards, .. } => shards - .iter() - .filter(|shard| matches!(shard, ManaCostShard::X)) - .count() as u32, - ManaCost::NoCost - | ManaCost::SelfManaCost - | ManaCost::SelfManaValue - | ManaCost::SelfManaCostReduced { .. } => 0, - } -} - -fn max_resolution_mana_x_value( +/// CR 608.2d: while resolving, a player can't be offered an illegal or +/// impossible choice. Returns `Some(max)` for the greatest affordable X — +/// `Some(0)` covers both "a pure `{X}` cost with nothing to spend" (paying +/// `{0}` is a trivial no-op, always legal) and "a fixed portion the player +/// can just barely afford at X=0". Returns `None` only when the cost's +/// fixed portion (any colored/generic pips alongside X, e.g. `{X}{W}` with +/// no white available) can't be paid at ANY value of X, including 0 — the +/// caller must treat that as an ordinary payment failure, never open a +/// `[0, 0]` prompt (there is no legal amount to submit). +/// +/// Monotonic by construction: `concretize_x` only ever ADDS generic mana as +/// X grows while the fixed shards stay constant, so if some X = V is +/// payable, X = 0 is payable too. The downward search below therefore never +/// needs to re-check 0 separately — reaching `max == 0` and failing there is +/// exactly the "not even 0 works" case. +fn resolution_mana_x_max( state: &GameState, payer: PlayerId, source_id: crate::types::identifiers::ObjectId, cost: &ManaCost, -) -> u32 { +) -> Option { // Resolution-time X costs are not spell casts — convoke/improvise/waterbend // tap-payments do not apply, so no spell object is passed. let mut max = casting_costs::max_x_value(state, payer, cost, None); @@ -317,10 +334,10 @@ fn max_resolution_mana_x_value( let mut concrete = cost.clone(); concrete.concretize_x(max); if casting::can_pay_effect_mana_cost_after_auto_tap(state, payer, source_id, &concrete) { - return max; + return Some(max); } if max == 0 { - return 0; + return None; } max -= 1; } @@ -1617,7 +1634,15 @@ mod tests { resolve_ability_chain(&mut state, &pay, &mut events, 0).unwrap(); match &state.waiting_for { WaitingFor::PayAmountChoice { resource, max, .. } => { - assert_eq!(*resource, PayableResource::ManaGeneric { per_x: 1 }); + assert_eq!( + *resource, + PayableResource::ManaGeneric { + base_cost: ManaCost::Cost { + shards: vec![ManaCostShard::X], + generic: 0, + }, + } + ); assert_eq!(*max, 3); } other => panic!("expected PayAmountChoice, got {other:?}"), @@ -1642,6 +1667,394 @@ mod tests { assert_eq!(state.players[0].mana_pool.mana.len(), 1); } + /// CR 107.3f + CR 118.12: Elenda and Azor's attack trigger resolves + /// `Effect::PayCost` for `{X}{W}{U}{B}`, not a pure X cost — this is a + /// resolution-time cost on a triggered ability's effect text ("you may + /// pay ... If you do, ..."), not a spell/activated-ability cost + /// announced at cast/activation time (CR 601 doesn't apply here). #6410: + /// the player attacked, paid an amount, and drew that many cards + /// WITHOUT ever being charged {W}{U}{B}. The resolution-time X-payment + /// prompt must concretize X into the FULL original cost — colored pips + /// included — not substitute a synthetic all-generic cost that silently + /// drops them. + #[test] + fn pay_x_plus_colored_mana_requires_the_colored_pips() { + use crate::game::effects::resolve_ability_chain; + use crate::game::engine_resolution_choices::{ + handle_resolution_choice, ResolutionChoiceOutcome, + }; + use crate::game::zones::create_object; + use crate::types::actions::GameAction; + use crate::types::identifiers::CardId; + use crate::types::mana::ManaCostShard; + use crate::types::zones::Zone; + + let mut state = GameState::new_two_player(42); + let source_id = create_object( + &mut state, + CardId(500), + PlayerId(0), + "Elenda and Azor".to_string(), + Zone::Battlefield, + ); + for n in 0..5 { + create_object( + &mut state, + CardId(100 + n), + PlayerId(0), + format!("Card {n}"), + Zone::Library, + ); + } + // Exactly {W}{U}{B} plus 4 colorless — enough to pay X=4 ({4}{W}{U}{B}) + // and no more, so a fix that ever drops the colored requirement would + // still pass a naive "enough total mana" check but leave W/U/B + // untapped in the pool. + for color in [ManaType::White, ManaType::Blue, ManaType::Black] { + state.players[0].mana_pool.add(ManaUnit { + color, + source_id: ObjectId(0), + pip_id: crate::types::mana::ManaPipId(0), + supertype: None, + source_could_produce_two_or_more_colors: false, + restrictions: vec![], + grants: vec![], + expiry: None, + }); + } + for _ in 0..4 { + state.players[0].mana_pool.add(ManaUnit { + color: ManaType::Colorless, + source_id: ObjectId(0), + pip_id: crate::types::mana::ManaPipId(0), + supertype: None, + source_could_produce_two_or_more_colors: false, + restrictions: vec![], + grants: vec![], + expiry: None, + }); + } + + let draw = ResolvedAbility::new( + Effect::Draw { + count: QuantityExpr::Ref { + qty: QuantityRef::Variable { + name: "X".to_string(), + }, + }, + target: TargetFilter::Controller, + }, + vec![], + source_id, + PlayerId(0), + ); + let mut pay = ResolvedAbility::new( + Effect::PayCost { + cost: AbilityCost::Mana { + cost: ManaCost::Cost { + shards: vec![ + ManaCostShard::X, + ManaCostShard::White, + ManaCostShard::Blue, + ManaCostShard::Black, + ], + generic: 0, + }, + }, + scale: None, + payer: TargetFilter::Controller, + }, + vec![], + source_id, + PlayerId(0), + ); + pay.sub_ability = Some(Box::new(draw)); + + let mut events = Vec::new(); + resolve_ability_chain(&mut state, &pay, &mut events, 0).unwrap(); + match &state.waiting_for { + WaitingFor::PayAmountChoice { resource, max, .. } => { + assert_eq!( + *resource, + PayableResource::ManaGeneric { + base_cost: ManaCost::Cost { + shards: vec![ + ManaCostShard::X, + ManaCostShard::White, + ManaCostShard::Blue, + ManaCostShard::Black + ], + generic: 0, + }, + }, + "base_cost must carry the colored pips alongside X, not just per_x" + ); + // 4 colorless available for the X portion after W/U/B are spoken for. + assert_eq!(*max, 4); + } + other => panic!("expected PayAmountChoice, got {other:?}"), + } + + let waiting_for = state.waiting_for.clone(); + let outcome = handle_resolution_choice( + &mut state, + waiting_for, + GameAction::SubmitPayAmount { amount: 4 }, + &mut events, + ) + .unwrap(); + assert!(matches!( + outcome, + ResolutionChoiceOutcome::WaitingFor(_) + | ResolutionChoiceOutcome::WaitingForWithInlineTriggers(_) + | ResolutionChoiceOutcome::WaitingForWithParkedObservers(_) + | ResolutionChoiceOutcome::ActionResult(_) + )); + // All 7 mana units (4 colorless for X + W + U + B) must be spent — + // the pre-fix code paid only 4 generic and left W/U/B untapped. + assert!( + state.players[0].mana_pool.mana.is_empty(), + "W/U/B must be paid alongside the X-derived generic, pool: {:?}", + state.players[0].mana_pool.mana + ); + assert_eq!(state.players[0].hand.len(), 4, "drew X=4 cards"); + } + + /// CR 608.2d + CR 118.12: a cost of `{X}{W}` with NO white mana available + /// can't be paid at any value of X, including 0 — there is no legal + /// amount to offer. Pre-fix, `max_resolution_mana_x_value` collapsed + /// "genuinely payable at X=0" and "not payable at all" into the same + /// `max = 0`, so this shape opened a `[0, 0]` `PayAmountChoice` that let + /// the player "choose" an impossible option, then failed with an + /// internal `InvalidAction` on submit instead of the ordinary + /// payment-failure path. Accepting the optional cost must instead fail + /// immediately: no `PayAmountChoice` ever appears, `cost_payment_failed_flag` + /// is set, and the `IfYouDo` Draw rider (gated on + /// `!cost_payment_failed_flag`) does not fire. Sibling control case: + /// `pay_x_plus_colored_mana_with_payable_fixed_pip_offers_zero_max_choice` + /// below covers the genuinely-payable `X=0` shape, which must still + /// present the prompt. + #[test] + fn pay_x_plus_colored_mana_with_unpayable_fixed_pip_fails_without_impossible_choice() { + use crate::game::effects::resolve_ability_chain; + use crate::game::engine_payment_choices::handle_optional_effect_choice; + use crate::game::zones::create_object; + use crate::types::ability::{AbilityCondition, SubAbilityLink}; + use crate::types::identifiers::CardId; + use crate::types::mana::ManaCostShard; + use crate::types::zones::Zone; + + let mut state = GameState::new_two_player(42); + let source_id = create_object( + &mut state, + CardId(500), + PlayerId(0), + "Elenda and Azor".to_string(), + Zone::Battlefield, + ); + for n in 0..5 { + create_object( + &mut state, + CardId(100 + n), + PlayerId(0), + format!("Card {n}"), + Zone::Library, + ); + } + assert!( + state.players[0].mana_pool.mana.is_empty(), + "controller must have no mana at all, so not even {{W}} at X=0 is payable" + ); + + let mut draw = ResolvedAbility::new( + Effect::Draw { + count: QuantityExpr::Ref { + qty: QuantityRef::Variable { + name: "X".to_string(), + }, + }, + target: TargetFilter::Controller, + }, + vec![], + source_id, + PlayerId(0), + ); + draw.condition = Some(AbilityCondition::effect_performed()); + draw.sub_link = SubAbilityLink::SequentialSibling; + + let mut pay = ResolvedAbility::new( + Effect::PayCost { + cost: AbilityCost::Mana { + cost: ManaCost::Cost { + shards: vec![ManaCostShard::X, ManaCostShard::White], + generic: 0, + }, + }, + scale: None, + payer: TargetFilter::Controller, + }, + vec![], + source_id, + PlayerId(0), + ); + pay.sub_ability = Some(Box::new(draw)); + pay.optional = true; + + let mut events = Vec::new(); + resolve_ability_chain(&mut state, &pay, &mut events, 0).unwrap(); + assert!(matches!( + state.waiting_for, + WaitingFor::OptionalEffectChoice { .. } + )); + + let waiting = handle_optional_effect_choice(&mut state, true, &mut events).unwrap(); + assert!( + !matches!(waiting, WaitingFor::PayAmountChoice { .. }), + "an unpayable fixed pip must never open a PayAmountChoice prompt \ + (CR 608.2d forbids offering an impossible choice), got {waiting:?}" + ); + assert!( + state.cost_payment_failed_flag, + "PayCost with an unpayable fixed {{W}} pip must set cost_payment_failed_flag, \ + exactly like any other unaffordable resolution-time cost" + ); + assert_eq!( + state.players[0].hand.len(), + 0, + "the IfYouDo Draw rider must not fire when the cost payment failed" + ); + assert!(state.players[0].mana_pool.mana.is_empty()); + } + + /// Control case for the above: `{X}{W}` where the player has EXACTLY one + /// white mana and nothing else. X=0 (paying just `{W}`) IS genuinely + /// payable, so — unlike the unpayable-fixed-pip case — accepting the + /// optional cost MUST still present a `PayAmountChoice { min: 0, max: 0 }` + /// prompt, and submitting 0 must pay the `{W}` (not silently no-op). + #[test] + fn pay_x_plus_colored_mana_with_payable_fixed_pip_offers_zero_max_choice() { + use crate::game::effects::resolve_ability_chain; + use crate::game::engine_payment_choices::handle_optional_effect_choice; + use crate::game::engine_resolution_choices::handle_resolution_choice; + use crate::game::zones::create_object; + use crate::types::ability::{AbilityCondition, SubAbilityLink}; + use crate::types::actions::GameAction; + use crate::types::identifiers::CardId; + use crate::types::mana::ManaCostShard; + use crate::types::zones::Zone; + + let mut state = GameState::new_two_player(42); + let source_id = create_object( + &mut state, + CardId(500), + PlayerId(0), + "Elenda and Azor".to_string(), + Zone::Battlefield, + ); + for n in 0..5 { + create_object( + &mut state, + CardId(100 + n), + PlayerId(0), + format!("Card {n}"), + Zone::Library, + ); + } + // Exactly {W} — enough for X=0 ({W}), nothing left over for X>0. + state.players[0].mana_pool.add(ManaUnit { + color: ManaType::White, + source_id: ObjectId(0), + pip_id: crate::types::mana::ManaPipId(0), + supertype: None, + source_could_produce_two_or_more_colors: false, + restrictions: vec![], + grants: vec![], + expiry: None, + }); + + let mut draw = ResolvedAbility::new( + Effect::Draw { + count: QuantityExpr::Ref { + qty: QuantityRef::Variable { + name: "X".to_string(), + }, + }, + target: TargetFilter::Controller, + }, + vec![], + source_id, + PlayerId(0), + ); + draw.condition = Some(AbilityCondition::effect_performed()); + draw.sub_link = SubAbilityLink::SequentialSibling; + + let mut pay = ResolvedAbility::new( + Effect::PayCost { + cost: AbilityCost::Mana { + cost: ManaCost::Cost { + shards: vec![ManaCostShard::X, ManaCostShard::White], + generic: 0, + }, + }, + scale: None, + payer: TargetFilter::Controller, + }, + vec![], + source_id, + PlayerId(0), + ); + pay.sub_ability = Some(Box::new(draw)); + pay.optional = true; + + let mut events = Vec::new(); + resolve_ability_chain(&mut state, &pay, &mut events, 0).unwrap(); + assert!(matches!( + state.waiting_for, + WaitingFor::OptionalEffectChoice { .. } + )); + + let waiting = handle_optional_effect_choice(&mut state, true, &mut events).unwrap(); + match &waiting { + WaitingFor::PayAmountChoice { + resource, min, max, .. + } => { + assert_eq!( + *resource, + PayableResource::ManaGeneric { + base_cost: ManaCost::Cost { + shards: vec![ManaCostShard::X, ManaCostShard::White], + generic: 0, + }, + } + ); + assert_eq!(*min, 0); + assert_eq!(*max, 0, "no spare mana beyond the {{W}} pip for X>0"); + } + other => { + panic!("a genuinely payable X=0 cost must still present the choice, got {other:?}") + } + } + + handle_resolution_choice( + &mut state, + waiting, + GameAction::SubmitPayAmount { amount: 0 }, + &mut events, + ) + .unwrap(); + + assert!( + state.players[0].mana_pool.mana.is_empty(), + "the {{W}} pip must be paid even though X=0" + ); + assert_eq!( + state.players[0].hand.len(), + 0, + "X=0 draws zero cards, but the IfYouDo rider still fires (payment succeeded)" + ); + assert!(!state.cost_payment_failed_flag); + } + /// CR 603.2 + CR 603.5 + CR 107.3a + CR 608.2c + CR 119.3 + CR 121.1: /// Well of Lost Dreams production-shape end-to-end. Triggered ability shape /// `optional PayCost { Mana { X } } → IfYouDo SequentialSibling Draw { X }` @@ -1760,7 +2173,15 @@ mod tests { let waiting = handle_optional_effect_choice(&mut state, true, &mut events).unwrap(); match &waiting { WaitingFor::PayAmountChoice { resource, max, .. } => { - assert_eq!(*resource, PayableResource::ManaGeneric { per_x: 1 }); + assert_eq!( + *resource, + PayableResource::ManaGeneric { + base_cost: ManaCost::Cost { + shards: vec![ManaCostShard::X], + generic: 0, + }, + } + ); assert_eq!( *max, 3, "PayAmountChoice max must be capped by life gained (3), got {max} \ diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index edf3f0a465..11613e3db6 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -12,7 +12,6 @@ use crate::types::game_state::{ PendingPlayerScopeSacrificeCompletion, PersistentAxisMaterialization, WaitingFor, }; use crate::types::identifiers::{ObjectId, TrackedSetId}; -use crate::types::mana::ManaCost; use crate::types::resolved_commands::{ ResolvedInformationAudience, ResolvedInformationEdit, ResolvedInformationLifetime, }; @@ -2456,16 +2455,21 @@ pub(super) fn handle_resolution_choice( }); } } - PayableResource::ManaGeneric { per_x } => { - let cost = ManaCost::Cost { - shards: vec![], - generic: amount.saturating_mul(per_x), - }; + PayableResource::ManaGeneric { base_cost } => { + // CR 107.3f + CR 118.1 + CR 118.12: concretize the chosen + // X into the ORIGINAL cost — any colored/generic pips + // alongside the X shard (e.g. Elenda and Azor's + // `{X}{W}{U}{B}`) survive concretization and are paid + // here too. Paying a synthetic all-generic `{N}` cost + // instead would silently drop the colored requirements + // (#6410). + let mut cost = base_cost.clone(); + cost.concretize_x(amount); if !casting::can_pay_effect_mana_cost_after_auto_tap( state, player, source_id, &cost, ) { return Err(EngineError::InvalidAction(format!( - "Player {:?} cannot pay {} generic mana", + "Player {:?} cannot pay {}", player, cost.mana_value() ))); diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 6c7a54bfbf..14b04f9678 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -9692,16 +9692,26 @@ pub enum DistributionUnit { /// resource (energy, life, generic mana, counters); `LoopCollapse` is the one /// non-payment member — its N is the finite count an accepted CR 732.2a /// object-growth loop collapses into, deducting nothing. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", content = "data")] pub enum PayableResource { /// CR 107.14: Pay any amount of `{E}` — removes N energy counters from the player. Energy, - /// CR 107.3 + CR 118.1: Pay a chosen X as generic mana while resolving an effect. - ManaGeneric { - #[serde(default = "default_one")] - per_x: u32, - }, + /// CR 107.3f + CR 118.1 + CR 118.12: Pay a chosen X as part of a + /// resolution-time mana cost. `base_cost` is the UNCONCRETIZED cost + /// (still carrying `ManaCostShard::X` plus any colored/generic pips + /// alongside it, e.g. `{X}{W}{U}{B}`); the submit handler concretizes X + /// into it (`ManaCost::concretize_x`) and pays the resulting cost through + /// the standard mana-payment authority, so colored requirements are + /// enforced exactly like any other mana cost — never dropped in favor of + /// a generic-only payment (Elenda and Azor, #6410). + /// + /// `base_cost` intentionally carries NO `#[serde(default)]`: a serialized + /// prompt missing this field must fail deserialization rather than + /// silently resolve to `ManaCost::zero()`, which would concretize to a + /// free payment (drops the fixed pips AND the X basis) instead of + /// visibly rejecting the incompatible/corrupt save. + ManaGeneric { base_cost: ManaCost }, /// CR 107.1c + CR 122.1: Choose how many counters to remove. Counters, /// CR 119.4: Pay any amount of life — N is deducted as life loss via @@ -9803,10 +9813,6 @@ impl LoopCollapseAxis { } } -fn default_one() -> u32 { - 1 -} - /// CR 115.7: Scope of retargeting — single target, all targets, or forced. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", content = "data")] @@ -21985,6 +21991,31 @@ mod tests { ); } + /// #6410 review follow-up: `PayableResource::ManaGeneric::base_cost` + /// deliberately carries NO `#[serde(default)]`. A prior revision of this + /// fix defaulted the missing field to `ManaCost::zero()`, which would + /// have concretized a legacy/incomplete serialized prompt into a FREE + /// payment (drops both the fixed colored pips and the X basis) instead + /// of failing closed. A serialized prompt missing `base_cost` — whether + /// from the pre-fix `per_x`-only wire shape or any other truncation — + /// must be REJECTED at the deserialization boundary, never silently + /// downgraded to a zero-cost payment. + #[test] + fn pay_amount_choice_mana_generic_missing_base_cost_is_rejected() { + let empty_data = r#"{"type":"ManaGeneric","data":{}}"#; + assert!( + serde_json::from_str::(empty_data).is_err(), + "a ManaGeneric prompt with no base_cost must fail to deserialize, \ + not silently default to a zero-cost payment" + ); + + let legacy_per_x_shape = r#"{"type":"ManaGeneric","data":{"per_x":1}}"#; + assert!( + serde_json::from_str::(legacy_per_x_shape).is_err(), + "the pre-fix per_x-only wire shape has no base_cost and must be rejected too" + ); + } + #[test] fn deck_pool_without_dedicated_companion_defaults_to_empty() { let mut json = serde_json::to_value(PlayerDeckPool::default()).unwrap(); diff --git a/crates/lobby-broker/src/protocol.rs b/crates/lobby-broker/src/protocol.rs index 1070675c91..488df6f389 100644 --- a/crates/lobby-broker/src/protocol.rs +++ b/crates/lobby-broker/src/protocol.rs @@ -38,6 +38,12 @@ pub enum ServerErrorCode { /// handshake. When making such changes, plan a deprecation window where /// both the old and new variants coexist, then bump and remove the old. /// +/// 23 — `PayableResource::ManaGeneric` changed from `{ per_x }` to +/// `{ base_cost: ManaCost }` (#6410) — a `GameState` payload field type +/// change, and `base_cost` intentionally carries no `#[serde(default)]` +/// (a missing `base_cost` must fail deserialization, not silently +/// resolve to a zero-cost payment), so old and new peers can't parse +/// each other's serialized snapshots. /// 20 — Actor-scoped priority-passing settings and filtered per-player state. /// 19 — Connive's exact `EventObjectSnapshot` subject and resident paused /// post-replacement drains changed serialized full-game state. Phase 4 @@ -52,7 +58,7 @@ pub enum ServerErrorCode { /// payload; mulligan bottoming folded into a /// `MulliganDecisionPhase::BottomCards` sub-phase on /// `WaitingFor::MulliganDecision`. -pub const PROTOCOL_VERSION: u32 = 22; +pub const PROTOCOL_VERSION: u32 = 23; /// Minimum protocol version accepted by lobby-only brokers at the hello /// handshake. Lobby traffic has a one-version rollout window; full game servers @@ -389,8 +395,8 @@ mod tests { #[test] fn protocol_version_tracks_priority_passing_wire_additions() { - assert_eq!(PROTOCOL_VERSION, 22); - assert_eq!(MIN_SUPPORTED_PROTOCOL, 21); + assert_eq!(PROTOCOL_VERSION, 23); + assert_eq!(MIN_SUPPORTED_PROTOCOL, 22); } #[test] diff --git a/crates/server-core/src/protocol.rs b/crates/server-core/src/protocol.rs index 43c8939750..ea2b06b7a0 100644 --- a/crates/server-core/src/protocol.rs +++ b/crates/server-core/src/protocol.rs @@ -2040,8 +2040,8 @@ mod tests { } #[test] - fn protocol_version_is_22() { - assert_eq!(PROTOCOL_VERSION, 22); + fn protocol_version_is_23() { + assert_eq!(PROTOCOL_VERSION, 23); } #[test] diff --git a/scripts/check-protocol-version.mjs b/scripts/check-protocol-version.mjs index dce6f6c21a..5bdc3fbc33 100644 --- a/scripts/check-protocol-version.mjs +++ b/scripts/check-protocol-version.mjs @@ -3,7 +3,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const EXPECTED_PROTOCOL_VERSION = 22; +const EXPECTED_PROTOCOL_VERSION = 23; function extractVersion(source, pattern, label) { const match = source.match(pattern); From a8432797de947dbe21bd11c4a1e0328066354b52 Mon Sep 17 00:00:00 2001 From: Jacob Woodson <38709105+JacobWoodson@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:08:04 -0500 Subject: [PATCH 23/63] fix(engine): tap target defending player controls for equipment attack triggers (#6678) (#6751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(engine): resolve defending player from attacker for attachment attack triggers (#6678) Captain America's Shield's "Whenever equipped creature attacks, tap target creature defending player controls" prompted for a target but never tapped it. The trigger source is the Equipment, not the attacker, so `capture_combat_status` records `defending_player: None` on the Shield's trigger-source snapshot. Three sites resolved "defending player" as `trigger_source.map(|s| s.combat_status.defending_player).unwrap_or_else(fallback)`. When `trigger_source` is `Some` but the captured value is `None`, `.map()` yields `Some(None)`, so the `resolve_defending_player` fallback never runs — the chosen target then failed the CR 608.2b resolution-time legality re-check and the ability silently fizzled. Regression from the P04 resolution-frames refactor (777dbabf). CR 508.5 / 508.5a: when an ability refers to both an attacking creature and a defending player, the defender is the player that creature is attacking. A captured `None` means "this source is not the attacker" (Equipment/Aura), not "no defender" — fall through to `resolve_defending_player`, which reads the attacker from the triggering event. - filter.rs: route the inline `DefendingPlayer` controller arm in `filter_inner_for_object` (the load-bearing target-validation path) through the single-authority `source_defending_player`, eliminating the duplicate. - filter.rs `source_defending_player`: `.and_then().or_else()` so a captured `None` falls through to the attacker fallback. - quantity.rs `source_defending_player_for_context`: same fix. Fixes the whole "equipped creature attacks -> affect defending player's permanents" class (Greatsword of Tyr, Mjolnir, Thunder Lasso, etc.). Adds a discriminating integration test driving the real declare-attackers / trigger pipeline; it fails on the prior build and passes with the fix. Co-Authored-By: Claude Opus 4.8 * fix(engine): narrow #6678 to the proven filter.rs path Address review (matthewevans): drop the quantity.rs `source_defending_player_for_context` fallback change. Its only consumers are the `AttachmentsOnLeavingObject` count and `damage_source_controller_matches` paths, which the Shield regression test does not exercise, so the change was unproven by a discriminating test. Neither consumer maps to a real card in the "equipped creature attacks -> defending player" class, so a runtime test would build for a phantom card. Restore quantity.rs to baseline; the filter.rs fix (covered by the discriminating Shield test) stands alone. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: matthewevans --- crates/engine/src/game/filter.rs | 47 +++++-- ...678_captain_america_shield_tap_defender.rs | 131 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 3 files changed, 168 insertions(+), 11 deletions(-) create mode 100644 crates/engine/tests/integration/issue_6678_captain_america_shield_tap_defender.rs diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index f79c8a8fbc..ceb819efee 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -2239,13 +2239,25 @@ fn filter_inner_for_object( _ => return false, } } + // CR 508.5a: "defending player controls" — match the object's + // controller against the defending player. Routed through the + // single-authority `source_defending_player` so an attachment + // source (Equipment/Aura), whose own captured combat status has + // no defending player, still resolves the defender of the + // *attacking creature* via the triggering event rather than + // fizzling (issue #6678). A bespoke `.map().unwrap_or_else()` + // here collapsed the captured `None` into `Some(None)` and + // suppressed that fallback. ControllerRef::DefendingPlayer => { - let defending_player = trigger_source - .map(|source| source.combat_status.defending_player) - .unwrap_or_else(|| { - crate::game::combat::resolve_defending_player(state, source_id) - }); - match defending_player { + let source_ctx = source_context_from_filter( + state, + source_id, + source_controller, + ability, + trigger_source, + recipient_id, + ); + match source_defending_player(state, &source_ctx) { Some(pid) if pid == obj_ctrl => {} _ => return false, } @@ -3992,14 +4004,27 @@ struct SourceContext<'a> { recipient_id: Option, } -/// Source-relative controller references must use the triggered source's -/// captured facts. Falling back to `source.id` after that object has changed -/// zones would let a recycled storage id answer a different ability's filter. +/// CR 508.5 + CR 508.5a: Source-relative "defending player" resolution. Prefer +/// the triggered source's captured combat facts — an attacking creature's own +/// attack trigger snapshots its defending player, and that captured fact must +/// answer even after the source changes zones (a recycled storage id must never +/// answer a different ability's filter). +/// +/// But an attachment/anthem source (Equipment, Aura) is NOT itself the attacker: +/// `capture_combat_status` finds it absent from `combat.attackers` and records +/// `defending_player: None`. "Whenever equipped creature attacks, ... defending +/// player controls" (Captain America's Shield, Greatsword of Tyr, and the rest +/// of that class) must then resolve the defender of the *attacking creature*, +/// carried by the triggering event. So a captured `None` is "no answer here", +/// not "no defender" — fall through to `resolve_defending_player`, which reads +/// the triggering event's attacker. Using `.map().unwrap_or_else()` collapsed +/// that captured `None` into a spurious `Some(None)` and suppressed the +/// fallback, silently fizzling the ability (issue #6678). fn source_defending_player(state: &GameState, source: &SourceContext<'_>) -> Option { source .trigger_source - .map(|context| context.combat_status.defending_player) - .unwrap_or_else(|| crate::game::combat::resolve_defending_player(state, source.id)) + .and_then(|context| context.combat_status.defending_player) + .or_else(|| crate::game::combat::resolve_defending_player(state, source.id)) } fn source_enchanted_player(source: &SourceContext<'_>) -> Option { diff --git a/crates/engine/tests/integration/issue_6678_captain_america_shield_tap_defender.rs b/crates/engine/tests/integration/issue_6678_captain_america_shield_tap_defender.rs new file mode 100644 index 0000000000..fe753e31a9 --- /dev/null +++ b/crates/engine/tests/integration/issue_6678_captain_america_shield_tap_defender.rs @@ -0,0 +1,131 @@ +//! Regression for GitHub issue #6678 — Captain America's Shield's attack trigger +//! must tap the chosen creature the defending player controls. +//! +//! https://github.com/phase-rs/phase/issues/6678 +//! +//! Oracle (the relevant clause): +//! "Whenever equipped creature attacks, tap target creature defending player +//! controls." +//! +//! The parse is faithful: an `Attacks` trigger watching the attached permanent +//! (`valid_card: AttachedTo`) whose effect is `SetTapState { scope: Single, +//! state: Tap }` targeting `Typed { Creature, controller: DefendingPlayer }`. +//! The bug is at runtime. The trigger SOURCE is the Equipment, which is not the +//! attacker, so `capture_combat_status` records `defending_player: None` on the +//! Shield's trigger-source snapshot. `source_defending_player` then read that +//! captured `None` through `.map(...).unwrap_or_else(...)`, collapsing it to +//! `Some(None)` and SUPPRESSING the fallback to `resolve_defending_player` +//! (which reads the defender of the *attacking creature* via the triggering +//! event, per CR 508.5a). With no defending player resolvable, the chosen target +//! failed the CR 608.2b resolution-time legality re-check and the ability +//! silently fizzled — the prompt appeared, nothing tapped. +//! +//! This drives the REAL declare-attackers / trigger pipeline (not a synthetic +//! trigger event): the equipped creature attacks, the Shield's trigger is placed +//! on the stack, its target (the defender's creature) is chosen, and the target +//! must be tapped when the trigger resolves. +//! +//! Revert-probe: restoring the `.map().unwrap_or_else()` form makes +//! `source_defending_player` return `None` for the Equipment source, the chosen +//! target is dropped as illegal at resolution, and the final `tapped` assertion +//! flips red. + +use engine::game::game_object::AttachTarget; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::ability::TargetRef; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::ObjectId; +use engine::types::phase::Phase; + +use super::rules::AttackTarget; + +// The tap clause in isolation. Verbatim from the card's Oracle text so the +// parser takes the production branch (Attacks trigger + AttachedTo subject + +// DefendingPlayer-scoped tap target). +const SHIELD_TAP_TRIGGER: &str = + "Whenever equipped creature attacks, tap target creature defending player controls."; + +/// Attach `equipment` to `host` in the live state (mirrors how the equip action +/// wires `attached_to` + `attachments`; CR 301.5). +fn attach(runner: &mut GameRunner, equipment: ObjectId, host: ObjectId) { + let state = runner.state_mut(); + state.objects.get_mut(&equipment).unwrap().attached_to = Some(AttachTarget::Object(host)); + state + .objects + .get_mut(&host) + .unwrap() + .attachments + .push(equipment); +} + +/// Drain any attack-trigger target-selection / ordering prompt, choosing +/// `target` for the Shield's "tap target creature defending player controls" +/// trigger. When there is a single legal target the engine auto-selects it and +/// the trigger is already on the stack awaiting priority — that path returns +/// without a prompt, which is fine; the resolution assertion is the real check. +fn choose_attack_trigger_target(runner: &mut GameRunner, target: ObjectId) { + for _ in 0..16 { + match runner.state().waiting_for.clone() { + WaitingFor::OrderTriggers { triggers, .. } => { + let order = (0..triggers.len()).collect(); + runner + .act(GameAction::OrderTriggers { order }) + .expect("ordering attack triggers should succeed"); + } + WaitingFor::TriggerTargetSelection { .. } => { + runner + .act(GameAction::ChooseTarget { + target: Some(TargetRef::Object(target)), + }) + .expect("choosing the attack-trigger target should succeed"); + return; + } + _ => return, + } + } + panic!("expected the Shield attack trigger to request a target"); +} + +/// CR 508.5a + CR 701.26a: an Equipment's "Whenever equipped creature attacks, +/// tap target creature defending player controls" must resolve the defending +/// player from the ATTACKING creature (carried by the triggering event), not +/// from the Equipment's own combat status — and tap the chosen target. +#[test] +fn captain_america_shield_taps_defenders_creature_on_attack() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // The equipped attacker (P0) and the Shield attached to it. + let attacker = scenario.add_creature(P0, "Equipped Attacker", 2, 2).id(); + let shield = scenario + .add_creature(P0, "Captain America's Shield", 0, 0) + .as_artifact() + .with_subtypes(vec!["Equipment"]) + .from_oracle_text(SHIELD_TAP_TRIGGER) + .id(); + + // The defending player's creature — the intended tap target, untapped. + let defender_creature = scenario.add_creature(P1, "Defender's Bear", 2, 2).id(); + + let mut runner = scenario.build(); + attach(&mut runner, shield, attacker); + + assert!( + !runner.state().objects[&defender_creature].tapped, + "precondition: the defender's creature starts untapped" + ); + + runner.advance_to_combat(); + runner + .declare_attackers(&[(attacker, AttackTarget::Player(P1))]) + .expect("the equipped creature should be a legal attacker"); + choose_attack_trigger_target(&mut runner, defender_creature); + runner.advance_until_stack_empty(); + + assert!( + runner.state().objects[&defender_creature].tapped, + "issue #6678: the Shield's attack trigger must tap the chosen creature \ + the defending player controls" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index f6064a4c3e..be89baba81 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -631,6 +631,7 @@ mod issue_654_stridehangar_automaton; mod issue_6566_granted_leave_exile; mod issue_6634_aven_courier; mod issue_6643_party_dude_opponents_attacked; +mod issue_6678_captain_america_shield_tap_defender; mod issue_680_shalai_and_hallar_forgotten_ancient; mod issue_680_shalai_upkeep_move; mod issue_688_mind_into_matter; From bbb176eb756df9349f88a681697260fdf8c2f2ed Mon Sep 17 00:00:00 2001 From: minion1227 Date: Wed, 29 Jul 2026 17:33:45 -0700 Subject: [PATCH 24/63] feat(phase-ai): add vehicles deck-feature axis + VehicleDeploymentPolicy (#6790) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(phase-ai): add vehicles deck-feature axis + VehicleDeploymentPolicy `CrewTimingPolicy` ran with `activation-constant Some(1.0)` — it applied identical weight in a dedicated Vehicles shell and in a deck holding one incidental Smuggler's Copter, because no vehicles deck-feature existed for it to scale by. This adds that axis and wires it in, so the constant is gone. CR 702.122a: "Crew N" taps any number of OTHER untapped creatures you control with total power N or greater. A Vehicle is therefore not a body the deck buys with mana — it is one it buys with board presence, and an uncrewed Vehicle is not a creature at all. Feature (`features/vehicles.rs`), structural over `CardFace` AST: - `vehicle_count` / `total_crew_cost` from `Keyword::Crew { power }`, with the Vehicle subtype as a fallback so a Vehicle whose crew keyword the parser has not attached still joins the archetype (contributing no crew cost). - `crew_body_count` / `total_crew_power` — creatures that can actually pay a crew cost. A Vehicle is excluded even when also printed as a creature, because crew taps OTHER creatures; `PtValue::Variable` ("*") is excluded rather than guessed; power 0 taps for nothing. - `commitment` — geometric mean over (Vehicle density, crew support). Both pillars mandatory: Vehicles with no bench never become creatures, and a bench with no Vehicles is just a creature deck. Policy (`policies/vehicle_deployment.rs`, `CastSpell`) asks the question that comes BEFORE `CrewTimingPolicy`'s: will deploying this Vehicle now produce a body or a brick? It adds value only when the board already meets the crew requirement and withholds it otherwise — never a veto, since holding a Vehicle for a board that may never arrive is its own mistake. Shared authorities rather than reimplementation: - `VEHICLE_SUBTYPE` is promoted from `features::reanimator` instead of being redefined, so two features cannot disagree about what a Vehicle is. - Per-creature crew power goes through the engine's `object_crew_power_contribution` + `object_has_cant_crew`, so a creature that crews by toughness or "as though its power were N greater" (CR 702.122a) and one under a `CantCrew` static (CR 702.122d) are counted exactly as the real crew payment would count them. Summing `.power` here would have been silently wrong for both. Calibration is computed, not asserted from intuition, and one anchor exposed a real defect: the first pillar scaling let a single incidental Copter in a 20-creature deck reach 0.456 — above the floor — because a saturated bench compensated for near-zero Vehicle density. Retuned so it lands at 0.373 and is correctly rejected. Anchors pinned by test: 8+16 -> 1.000, 5+10 -> 0.833, 3+6 -> 0.645, 2+4 -> 0.527, 1+20 -> 0.373 (below the 0.40 floor). `vehicle_deployment_bonus` lands in `UNTUNED_POLICY_PENALTY_FIELDS` pending a paired-seed calibration. 34 tests. Four invariants were mutated independently and each killed exactly its own guard, with no cross-masking: the self-crew exclusion, the untapped requirement, the controller requirement, and the positive-power requirement. Verified: `cargo test -p phase-ai` 1844 passed, `cargo clippy -p phase-ai -p phase-engine --all-targets --features proptest -- -D warnings` exit 0, `check-engine-authorities.sh` exit 0. Co-Authored-By: Claude Opus 5 (1M context) * fix(phase-ai): a Vehicle subtype is not a crew ability Addresses the blocker and both non-blocking findings on #6790. [BLOCKER] `crew_requirement` returned `Some(0)` for any Vehicle subtype without `Keyword::Crew`, and `VehicleDeploymentPolicy` consumed that as a live crew cost. `available >= required` then read `0 >= 0` — true on an EMPTY board — so a permanent with no crew ability scored `vehicle_deployment_crewable` and a positive bonus. CR 702.122a opens "Crew is an activated ability of Vehicle cards"; a type line does not grant it. The defect was conflating two different questions behind one predicate: - **Archetype membership** (deck classification): is this card part of the Vehicles plan? A Vehicle whose crew keyword the parser missed still is, so the subtype fallback is honest here — it only scales how strongly the axis activates. Now `vehicle_archetype_member`. - **Crew requirement** (live scoring): what does crewing this cost right now? Only `Keyword::Crew` answers that. Now `crew_requirement` / `crew_requirement_parts`, strict, and what the policy routes through. `detect` uses membership for `vehicle_count` and the strict authority for `total_crew_cost`, so a subtype-only Vehicle joins the archetype while adding nothing to pay. [NB] `crew_timing`'s comment claimed commitment 0.0 "goes silent" while `max(MIN_CREW_ACTIVATION)` always returned `Some(0.25)`. Implemented the stated opt-out rather than softening the comment: a deck with no Vehicles has no crew decision to weigh, so the policy returns `None`; above zero the weight is damped, not floored out. [NB] The suite never exercised the engine crew authorities the module docs claim. Added direct fixtures for both live branches: a creature under `StaticMode:: CantCrew` (CR 702.122d) contributing nothing, and `CrewContribution` in both kinds — `PowerDelta` ("as though its power were N greater") and `ToughnessInsteadOfPower`. Tests 34 -> 41. Each fix is proven by mutation: - restoring the subtype fallback in the policy reds all three subtype-only tests, including the registry-seam one the review asked for; - deleting the `object_has_cant_crew` call reds `cant_crew_creature_does_not_contribute`; - replacing `object_crew_power_contribution` with a raw `.power` sum reds both contribution tests — proving the policy consults the authority rather than merely importing it. Verified: `cargo test -p phase-ai` 1894 passed, `cargo clippy -p phase-ai -p phase-engine --all-targets --features proptest -- -D warnings` exit 0, `check-engine-authorities.sh` exit 0. Co-Authored-By: Claude Opus 5 (1M context) * test(phase-ai): enforce the routed-and-neutral invariant instead of skipping it Addresses the CodeRabbit finding on `722795f67`. `registry_stays_silent_for_a_subtype_only_vehicle` used `if let Some(verdict) = found { .. }`, so if `VehicleDeploymentPolicy` were ever dropped from routing for this candidate — activation regressing to `None`, say — `found` would be `None`, the body would be skipped, and the test would pass having verified nothing. That is the vacuous-negative shape, and it was undermining the exact "registered AND routed AND neutral" invariant the test exists to pin. Replaced with `.expect(..)`, matching the sibling test at the same production seam. Proven non-vacuous: forcing `activation` to return `None` now reds this test, where previously it would have passed. Separately, the CodeRabbit note about `CrewTimingPolicy` staying active at commitment 0.0 targets `c763008b8` — the commit BEFORE the fix. The opt-out landed in `722795f67` and is present at `crew_timing.rs:51`; no further change is needed there. Verified: `cargo test -p phase-ai --lib vehicle` 41 passed. Co-Authored-By: Claude Opus 5 (1M context) * fix(phase-ai): a conservative deck-time bench is not a live-action gate Reverts the zero-commitment opt-out added in the previous round, per review. The earlier note offered a choice — correct the comment, or implement the stated opt-out — and I picked the opt-out. That was the wrong branch, for a reason that lives in this PR's own code: `features::vehicles::crew_capable_power` deliberately excludes non-fixed printed power (`PtValue::Variable`), and a decklist cannot see tokens at all. So `commitment == 0.0` means "no bench provable at deck-build time", NOT "cannot crew" — `features/tests/vehicles.rs` pins exactly that case. A Vehicles deck whose bench is tokens or `*`-power creatures scores zero and can still reach a legal `CrewVehicle` action, so gating a LIVE action on a conservative STATIC classification removed the timing safeguard precisely where it is most needed. `activation` therefore never opts out. `MIN_CREW_ACTIVATION` damps the weight instead of zeroing it, so a crew action the AI is already evaluating still gets judged while a dedicated shell continues to outweigh an incidental Copter. The comment now explains that rather than describing behavior the code does not have — the defect the first note was really pointing at. Two regressions, both proven RED by reintroducing the early return: - `activation_never_opts_out_even_at_zero_commitment` — activation level. - `registry_emits_a_crew_verdict_at_zero_commitment` — the production seam, with a legal `CrewVehicle` candidate and cached commitment 0.0. - `activation_scales_above_the_floor` pins the other direction, so damping cannot silently become a flat constant again. Verified: `cargo test -p phase-ai` 1897 passed, `cargo clippy -p phase-ai -p phase-engine --all-targets --features proptest -- -D warnings` exit 0, `check-engine-authorities.sh` exit 0. Co-Authored-By: Claude Opus 5 (1M context) * fix(phase-ai): crew eligibility comes from the engine, not a partial copy Addresses the blocker on #6790. `VehicleDeploymentPolicy` assembled its own crew-eligibility filter — controlled, untapped, a creature, not `CantCrew` — and omitted the engine's `object_cant_tap` term. The authoritative path requires all five: `is_tappable_creature_for_cost` (engine.rs) gates on `!object_cant_tap`, and the payment revalidates it and rejects the crew outright. So a `CantTap` 3/3 counted toward Crew 3 and the policy awarded `vehicle_deployment_crewable` for a Vehicle that can never be crewed. Rather than add the missing term to my own filter — leaving a fourth predicate to drift — the duplicate is deleted. `engine::game::engine::creature_can_pay_crew` is a new `pub` authority composing the two halves the crew payment enforces (tappability per CR 702.122a, plus the `CantCrew` static per CR 702.122d), and the policy makes one call to it. Per-creature power already went through `object_crew_power_contribution`; now eligibility does too. Three regressions, and two fixture traps worth recording: - `object_cant_tap` has an O(1) fast path gated on a static-kind presence index, so a fixture that only pushes the static leaves the index empty, the fast path returns `false`, and the test passes for the WRONG reason. The fixture mirrors the engine's own (`restrictions.rs::creature_with_cant_tap`): static on both `static_definitions` and `base_static_definitions`, then `evaluate_layers`. - The fixture is reach-guarded through the public authority — the restricted body must NOT be able to pay crew while an identical unrestricted body MUST, so the assertion discriminates the restriction rather than observing an incidental `false`. `cant_tap_creature_cannot_pay_crew` and `cant_tap_creature_alongside_a_legal_body_still_undercounts` both go RED when the partial filter is restored; `an_unrestricted_body_of_the_same_power_does_crew` is the positive control at identical power. Tests 41 -> 44. Verified: `cargo test -p phase-ai --lib` 1900 passed, `cargo clippy -p phase-engine --lib --features proptest -- -D warnings` and `cargo clippy -p phase-ai --all-targets -- -D warnings` both exit 0, `check-engine-authorities.sh` exit 0. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- crates/engine/src/game/engine.rs | 18 + crates/phase-ai/src/config.rs | 13 + crates/phase-ai/src/features/mod.rs | 6 + crates/phase-ai/src/features/reanimator.rs | 5 +- crates/phase-ai/src/features/tests/mod.rs | 1 + .../phase-ai/src/features/tests/vehicles.rs | 275 +++++++++ crates/phase-ai/src/features/vehicles.rs | 252 ++++++++ crates/phase-ai/src/policies/crew_timing.rs | 120 +++- crates/phase-ai/src/policies/mod.rs | 1 + crates/phase-ai/src/policies/registry.rs | 3 + crates/phase-ai/src/policies/tests/mod.rs | 1 + .../src/policies/tests/vehicle_deployment.rs | 536 ++++++++++++++++++ .../src/policies/vehicle_deployment.rs | 131 +++++ 13 files changed, 1359 insertions(+), 3 deletions(-) create mode 100644 crates/phase-ai/src/features/tests/vehicles.rs create mode 100644 crates/phase-ai/src/features/vehicles.rs create mode 100644 crates/phase-ai/src/policies/tests/vehicle_deployment.rs create mode 100644 crates/phase-ai/src/policies/vehicle_deployment.rs diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index b7adf0573f..7ff86d769a 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -9972,6 +9972,24 @@ fn handle_equip_activation( /// CR 702.122a: Activate a Vehicle's crew ability from Priority. /// Unlike Equip (CR 702.6a) and Saddle (CR 702.171a), Crew has NO "Activate only as a /// sorcery" restriction — it can be activated any time the controller has priority. +/// CR 702.122a + CR 702.122d: can this creature legally be tapped to pay a crew +/// cost right now? +/// +/// Composes the two halves the crew payment path enforces — the tappability rule +/// in [`is_tappable_creature_for_cost`] (controlled, untapped, a creature, and +/// not under a `CantTap` restriction) and the `CantCrew` static — into one +/// named authority. +/// +/// `pub` so consumers outside this module (`phase-ai`'s +/// `VehicleDeploymentPolicy`) ask THIS question instead of assembling their own +/// filter from the parts. A partial duplicate silently over-counts: omitting the +/// `object_cant_tap` term alone makes a `CantTap` 3/3 look like it can pay +/// Crew 3, which it cannot. +pub fn creature_can_pay_crew(state: &GameState, id: ObjectId, player: PlayerId) -> bool { + is_tappable_creature_for_cost(state, id, player) + && !super::static_abilities::object_has_cant_crew(state, id) +} + fn is_tappable_creature_for_cost(state: &GameState, id: ObjectId, player: PlayerId) -> bool { state.objects.get(&id).is_some_and(|o| { o.controller == player diff --git a/crates/phase-ai/src/config.rs b/crates/phase-ai/src/config.rs index dc30557a9f..bf859827cf 100644 --- a/crates/phase-ai/src/config.rs +++ b/crates/phase-ai/src/config.rs @@ -533,6 +533,10 @@ pub struct PolicyPenalties { /// draw" engine (preference band, per engine). #[serde(default = "default_draw_payoff_bonus")] pub draw_payoff_bonus: f64, + /// CR 702.122a: card-equivalent value of casting a Vehicle the board can + /// already crew, scaled by surplus crew power. + #[serde(default = "default_vehicle_deployment_bonus")] + pub vehicle_deployment_bonus: f64, /// CR 601.2f: card-equivalent value of ONE generic mana saved by deploying a /// cost reducer, multiplied by the capped saved-mana total. #[serde(default = "default_cost_reduction_deploy_bonus")] @@ -620,6 +624,7 @@ impl Default for PolicyPenalties { devotion_pip_progress: default_devotion_pip_progress(), devotion_god_activation: default_devotion_god_activation(), draw_payoff_bonus: default_draw_payoff_bonus(), + vehicle_deployment_bonus: default_vehicle_deployment_bonus(), cost_reduction_deploy_bonus: default_cost_reduction_deploy_bonus(), cost_reduction_defer_penalty: default_cost_reduction_defer_penalty(), discard_payoff_bonus: default_discard_payoff_bonus(), @@ -751,6 +756,9 @@ fn default_devotion_god_activation() -> f64 { fn default_draw_payoff_bonus() -> f64 { 0.6 } +fn default_vehicle_deployment_bonus() -> f64 { + 0.5 +} fn default_cost_reduction_deploy_bonus() -> f64 { 0.2 } @@ -908,6 +916,11 @@ pub const UNTUNED_POLICY_PENALTY_FIELDS: &[(&str, &str)] = &[ "draw_payoff_bonus", "CR 121.1 per-engine draw-payoff weight — awaiting a paired-seed ai-gate calibration.", ), + ( + "vehicle_deployment_bonus", + "CR 702.122a crewable-Vehicle deployment weight — awaiting a paired-seed \ + ai-gate calibration.", + ), ( "cost_reduction_deploy_bonus", "CR 601.2f per-saved-mana deployment weight — awaiting a paired-seed ai-gate calibration.", diff --git a/crates/phase-ai/src/features/mod.rs b/crates/phase-ai/src/features/mod.rs index 278aa182de..da42c27b74 100644 --- a/crates/phase-ai/src/features/mod.rs +++ b/crates/phase-ai/src/features/mod.rs @@ -30,6 +30,7 @@ pub mod reanimator; pub mod spellslinger_prowess; pub mod tokens_wide; pub mod tribal; +pub mod vehicles; #[cfg(test)] pub mod tests; @@ -57,6 +58,7 @@ pub use reanimator::ReanimatorFeature; pub use spellslinger_prowess::SpellslingerProwessFeature; pub use tokens_wide::TokensWideFeature; pub use tribal::TribalFeature; +pub use vehicles::VehiclesFeature; use engine::game::bracket_estimate::CommanderBracketTier; @@ -103,6 +105,9 @@ pub struct DeckFeatures { /// CR 701.9: "whenever you discard" payoff density (self-discard outlets + /// engines). Disjoint from `hand_disruption`, which scores OPPONENT discard. pub discard_matters: DiscardMattersFeature, + /// CR 702.122: crewed-Vehicle density paired with the creature bench needed + /// to tap for it. Gives `CrewTimingPolicy` the deck signal it lacked. + pub vehicles: VehiclesFeature, /// Declaration-derived: the deck's declared bracket tier. Unlike the /// other fields here, this is not structurally detected from card text — /// it is a per-deck declaration set at deck-analysis time from deck @@ -153,6 +158,7 @@ impl DeckFeatures { graveyard_types: graveyard_types::detect(deck), draw_matters: draw_matters::detect(deck), discard_matters: discard_matters::detect(deck), + vehicles: vehicles::detect(deck), bracket_tier: tier, } } diff --git a/crates/phase-ai/src/features/reanimator.rs b/crates/phase-ai/src/features/reanimator.rs index 41ae1fe9bd..ce64bb6a4c 100644 --- a/crates/phase-ai/src/features/reanimator.rs +++ b/crates/phase-ai/src/features/reanimator.rs @@ -69,7 +69,10 @@ const ENABLER_BONUS_CAP: f32 = 0.20; /// CR 205.3 / CR 301.7: the artifact subtype a reanimation target/payoff can be /// besides a creature. Compared case-insensitively against `TypeFilter::Subtype` /// and `CardType.subtypes`. -const VEHICLE_SUBTYPE: &str = "Vehicle"; +/// `pub(crate)` so `features::vehicles` matches on the SAME string rather than +/// redefining it — two features disagreeing about what a Vehicle is would be a +/// silent classification split. +pub(crate) const VEHICLE_SUBTYPE: &str = "Vehicle"; /// Per-deck reanimator classification. /// diff --git a/crates/phase-ai/src/features/tests/mod.rs b/crates/phase-ai/src/features/tests/mod.rs index 3ab65d79cf..e81f4b2b85 100644 --- a/crates/phase-ai/src/features/tests/mod.rs +++ b/crates/phase-ai/src/features/tests/mod.rs @@ -16,3 +16,4 @@ pub mod mill; pub mod no_name_matching; pub mod poison; pub mod reanimator; +pub mod vehicles; diff --git a/crates/phase-ai/src/features/tests/vehicles.rs b/crates/phase-ai/src/features/tests/vehicles.rs new file mode 100644 index 0000000000..7411ba5d9c --- /dev/null +++ b/crates/phase-ai/src/features/tests/vehicles.rs @@ -0,0 +1,275 @@ +//! Unit tests for `features::vehicles` — CR 702.122 crewed-Vehicle detection. +//! No `#[cfg(test)]` in SOURCE files; tests live here. + +use engine::game::DeckEntry; +use engine::types::ability::PtValue; +use engine::types::card::CardFace; +use engine::types::card_type::{CardType, CoreType}; +use engine::types::keywords::Keyword; + +use crate::features::vehicles::*; + +fn face(name: &str, core: CoreType) -> CardFace { + CardFace { + name: name.to_string(), + card_type: CardType { + supertypes: Vec::new(), + core_types: vec![core], + subtypes: Vec::new(), + }, + ..Default::default() + } +} + +fn entry(card: CardFace, count: u32) -> DeckEntry { + DeckEntry { card, count } +} + +/// A Vehicle carrying `Keyword::Crew { power }`. +fn vehicle(name: &str, crew: u32) -> CardFace { + let mut f = face(name, CoreType::Artifact); + f.card_type.subtypes.push("Vehicle".to_string()); + f.keywords.push(Keyword::Crew { + power: crew, + once_per_turn: None, + }); + f +} + +/// A creature that can be tapped to crew. +fn body(name: &str, power: i32) -> CardFace { + let mut f = face(name, CoreType::Creature); + f.power = Some(PtValue::Fixed(power)); + f.toughness = Some(PtValue::Fixed(power.max(1))); + f +} + +/// `vehicles` Vehicles + `bodies` crew-capable creatures, padded to 36 nonland. +fn deck(vehicles: u32, bodies: u32) -> Vec { + let filler = 36u32.saturating_sub(vehicles + bodies); + vec![ + entry(vehicle("Copter", 1), vehicles), + entry(body("Bear", 2), bodies), + entry(face("Filler", CoreType::Enchantment), filler), + ] +} + +#[test] +fn empty_deck_produces_defaults() { + let f = detect(&[]); + assert_eq!(f.vehicle_count, 0); + assert_eq!(f.crew_body_count, 0); + assert_eq!(f.commitment, 0.0); +} + +#[test] +fn detects_vehicles_and_crew_cost() { + let f = detect(&[ + entry(vehicle("Skysovereign", 3), 5), + entry(body("Bear", 2), 10), + entry(face("Filler", CoreType::Enchantment), 21), + ]); + assert_eq!(f.vehicle_count, 5); + assert_eq!(f.total_crew_cost, 15, "Crew 3 x 5 copies"); +} + +#[test] +fn detects_crew_bodies_and_power() { + let f = detect(&deck(5, 10)); + assert_eq!(f.crew_body_count, 10); + assert_eq!(f.total_crew_power, 20, "power 2 x 10 bodies"); +} + +#[test] +fn a_vehicle_is_not_its_own_crew_body() { + // CR 702.122a: crew taps OTHER creatures. A Vehicle that is also printed as + // a creature card must not count toward its own bench. + let mut creature_vehicle = vehicle("Creature Vehicle", 2); + creature_vehicle + .card_type + .core_types + .push(CoreType::Creature); + creature_vehicle.power = Some(PtValue::Fixed(4)); + let f = detect(&[ + entry(creature_vehicle, 8), + entry(face("Filler", CoreType::Enchantment), 28), + ]); + assert_eq!(f.vehicle_count, 8); + assert_eq!(f.crew_body_count, 0, "a Vehicle can never crew itself"); + assert_eq!(f.commitment, 0.0, "no bench ⇒ the axis collapses"); +} + +#[test] +fn zero_power_creature_is_not_a_crew_body() { + // Tapping a 0-power creature contributes nothing toward `N`. + let f = detect(&[ + entry(vehicle("Copter", 1), 5), + entry(body("Wall", 0), 10), + entry(face("Filler", CoreType::Enchantment), 21), + ]); + assert_eq!(f.crew_body_count, 0); + assert_eq!(f.commitment, 0.0); +} + +#[test] +fn variable_power_creature_is_not_counted() { + // A `*` power has no deck-time value; the axis under-counts rather than + // inventing a bench. + let mut star = face("Tarmogoyf", CoreType::Creature); + star.power = Some(PtValue::Variable("*".to_string())); + let f = detect(&[ + entry(vehicle("Copter", 1), 5), + entry(star, 10), + entry(face("Filler", CoreType::Enchantment), 21), + ]); + assert_eq!(f.crew_body_count, 0); +} + +#[test] +fn subtype_only_vehicle_has_no_crew_requirement() { + // CR 702.122a: Crew is an activated ability; the subtype does not grant it. + // Archetype membership is the broader question and stays true — see the + // sibling test below — but the live authority must report `None`. + let mut subtype_only = face("Odd Vehicle", CoreType::Artifact); + subtype_only.card_type.subtypes.push("Vehicle".to_string()); + assert_eq!(crew_requirement(&subtype_only), None); + assert!(vehicle_archetype_member(&subtype_only)); + + let real = vehicle("Copter", 2); + assert_eq!(crew_requirement(&real), Some(2)); + assert!(vehicle_archetype_member(&real)); +} + +#[test] +fn vehicle_subtype_without_the_keyword_still_registers() { + // A Vehicle whose crew keyword the parser has not attached is still part of + // the archetype — it just contributes no crew cost. + let mut subtype_only = face("Odd Vehicle", CoreType::Artifact); + subtype_only.card_type.subtypes.push("Vehicle".to_string()); + let f = detect(&[ + entry(subtype_only, 5), + entry(body("Bear", 2), 10), + entry(face("Filler", CoreType::Enchantment), 21), + ]); + assert_eq!(f.vehicle_count, 5); + assert_eq!(f.total_crew_cost, 0); +} + +#[test] +fn noncreature_is_not_a_crew_body() { + let f = detect(&[ + entry(vehicle("Copter", 1), 5), + entry(face("Signet", CoreType::Artifact), 10), + entry(face("Filler", CoreType::Enchantment), 21), + ]); + assert_eq!(f.crew_body_count, 0); +} + +#[test] +fn vehicles_without_a_bench_collapse_commitment() { + let f = detect(&[ + entry(vehicle("Copter", 1), 5), + entry(face("Filler", CoreType::Enchantment), 31), + ]); + assert_eq!(f.vehicle_count, 5); + assert_eq!(f.commitment, 0.0); +} + +#[test] +fn creatures_without_vehicles_collapse_commitment() { + let f = detect(&[ + entry(body("Bear", 2), 20), + entry(face("Filler", CoreType::Enchantment), 16), + ]); + assert_eq!(f.vehicle_count, 0); + assert_eq!(f.commitment, 0.0); +} + +// ─── calibration anchors: computed values, pinned ──────────────────────────── + +#[test] +fn dedicated_shell_saturates() { + let f = detect(&deck(8, 16)); + assert!( + (f.commitment - 1.000).abs() < 0.01, + "expected 1.000, got {}", + f.commitment + ); +} + +#[test] +fn realistic_build_hits_its_anchor() { + let f = detect(&deck(5, 10)); + assert!( + (f.commitment - 0.833).abs() < 0.01, + "expected ≈0.833, got {}", + f.commitment + ); + assert!(f.commitment >= VEHICLES_FLOOR); +} + +#[test] +fn light_build_hits_its_anchor() { + let f = detect(&deck(3, 6)); + assert!( + (f.commitment - 0.645).abs() < 0.01, + "expected ≈0.645, got {}", + f.commitment + ); + assert!(f.commitment >= VEHICLES_FLOOR); +} + +#[test] +fn two_vehicle_splash_still_clears_the_floor() { + let f = detect(&deck(2, 4)); + assert!( + (f.commitment - 0.527).abs() < 0.01, + "expected ≈0.527, got {}", + f.commitment + ); + assert!(f.commitment >= VEHICLES_FLOOR); +} + +#[test] +fn one_incidental_copter_stays_below_the_floor() { + // The case that drove the pillar scaling: a saturated bench must NOT + // compensate for near-zero Vehicle density. + let f = detect(&deck(1, 20)); + assert!( + (f.commitment - 0.373).abs() < 0.01, + "expected ≈0.373, got {}", + f.commitment + ); + assert!( + f.commitment < VEHICLES_FLOOR, + "one Copter in a creature deck must not switch the axis on, got {}", + f.commitment + ); +} + +#[test] +fn commitment_is_format_size_neutral() { + let sixty = detect(&deck(5, 10)); + let commander = detect(&[ + entry(vehicle("Copter", 1), 9), + entry(body("Bear", 2), 18), + entry(face("Filler", CoreType::Enchantment), 36), + ]); + assert!( + (sixty.commitment - commander.commitment).abs() < 0.05, + "{} vs {}", + sixty.commitment, + commander.commitment + ); +} + +#[test] +fn lands_are_excluded_from_the_denominator() { + let with_lands = detect(&[ + entry(vehicle("Copter", 1), 5), + entry(body("Bear", 2), 10), + entry(face("Filler", CoreType::Enchantment), 21), + entry(face("Plains", CoreType::Land), 24), + ]); + assert_eq!(with_lands.commitment, detect(&deck(5, 10)).commitment); +} diff --git a/crates/phase-ai/src/features/vehicles.rs b/crates/phase-ai/src/features/vehicles.rs new file mode 100644 index 0000000000..4680a68883 --- /dev/null +++ b/crates/phase-ai/src/features/vehicles.rs @@ -0,0 +1,252 @@ +//! Vehicles feature — structural detection of a deck that wins through crewed +//! Vehicles (CR 702.122). +//! +//! Parser AST verification — VERIFIED against engine source: +//! - `Keyword::Crew { power, once_per_turn }` at +//! `crates/engine/src/types/keywords.rs:707` — the Vehicle payoff and, in +//! `power`, the exact total power CR 702.122a requires to crew it. +//! - `CardFace.keywords: Vec` at `crates/engine/src/types/card.rs:60`. +//! - `CardFace.power: Option` at `card.rs:171`; `PtValue::Fixed(i32)` +//! is the only deck-time-knowable form — a `*` power is `PtValue::Variable` +//! and is excluded rather than guessed. +//! - Vehicle subtype via the shared `reanimator::VEHICLE_SUBTYPE` constant, +//! promoted rather than redefined so the two features cannot disagree about +//! what a Vehicle is. The subtype widens ARCHETYPE membership only — CR 702.122a +//! makes Crew an activated ability, so it never stands in for a crew +//! requirement at the live seam. +//! +//! No parser remediation required — every axis is expressible over existing +//! typed AST. +//! +//! ## Why this axis exists +//! +//! CR 702.122a: "Crew N" means *tap any number of OTHER untapped creatures you +//! control with total power N or greater*. A Vehicle is therefore not a body the +//! deck pays mana for — it is a body the deck pays *board presence* for, and it +//! does nothing at all without creatures to tap. +//! +//! `CrewTimingPolicy` already decides whether a specific crew activation is +//! worth it, but it runs with `activation-constant Some(1.0)` because there is +//! no vehicles deck-feature for it to scale by. So it applies the same weight in +//! a dedicated Vehicles shell as in a deck with one incidental Smuggler's +//! Copter, and nothing anywhere values *casting* a Vehicle against whether the +//! board can actually crew it. +//! +//! ## Boundary with `equipment` +//! +//! Both axes attach value to a noncreature permanent that needs creatures. +//! Equipment moves stats onto an existing body (CR 702.6); a Vehicle BECOMES the +//! body and taps the creatures instead (CR 702.122a). The costs run opposite +//! ways — Equipment wants creatures on the battlefield afterwards, crewing takes +//! them out of combat — so the two never read the same card and a deck scoring +//! high on both is running two distinct plans. +//! +//! ## Boundary with `artifacts` +//! +//! `artifacts` counts artifact density and artifact-matters payoffs. Vehicles are +//! artifacts, so a Vehicles deck reads on that axis too — intentionally. This +//! axis is the narrower question of whether those artifacts are crewable bodies +//! with the creatures to crew them, which artifact density cannot answer. + +use engine::game::DeckEntry; +use engine::types::ability::PtValue; +use engine::types::card::CardFace; +use engine::types::card_type::CoreType; +use engine::types::keywords::Keyword; + +use crate::features::commitment; +use crate::features::reanimator::VEHICLE_SUBTYPE; + +/// Commitment at or above which Vehicles are a real plan rather than one +/// incidental Copter. Gates the vehicles-aware policies' `activation`. +pub const VEHICLES_FLOOR: f32 = 0.40; + +/// Vehicle density (per 60 nonland) at which the payoff pillar saturates. +const VEHICLE_SATURATION_PER_60: f32 = 12.0; + +/// CR 702.122a: crew taps *any number* of other creatures, so a Vehicle deck +/// needs a bench, not one big body. Two crew-capable creatures per Vehicle is +/// treated as full support. +const BODIES_PER_VEHICLE: f32 = 2.0; + +/// CR 702.122: per-deck Vehicles classification. +/// +/// Populated once per game from `DeckEntry` data. Detection is structural over +/// `CardFace.keywords` and the printed type line — never by card name. +#[derive(Debug, Clone, Default)] +pub struct VehiclesFeature { + /// Cards carrying `Keyword::Crew` — the Vehicles themselves. + pub vehicle_count: u32, + /// Summed `Crew N` requirement across those Vehicles. Consumed by policies + /// that need to know how much board presence the plan costs to switch on. + pub total_crew_cost: u32, + /// Creatures whose printed power can contribute to paying a crew cost + /// (CR 702.122a) — power must be a known, positive, fixed value. + pub crew_body_count: u32, + /// Summed printed power of those bodies. + pub total_crew_power: u32, + /// `0.0..=1.0` — how central crewed Vehicles are to this deck. + pub commitment: f32, +} + +/// Structural detection over each `DeckEntry`'s `CardFace` AST. +pub fn detect(deck: &[DeckEntry]) -> VehiclesFeature { + if deck.is_empty() { + return VehiclesFeature::default(); + } + + let mut vehicle_count = 0u32; + let mut total_crew_cost = 0u32; + let mut crew_body_count = 0u32; + let mut total_crew_power = 0u32; + let mut total_nonland = 0u32; + + for entry in deck { + let face = &entry.card; + if !face.card_type.core_types.contains(&CoreType::Land) { + total_nonland = total_nonland.saturating_add(entry.count); + } + + if vehicle_archetype_member(face) { + vehicle_count = vehicle_count.saturating_add(entry.count); + // Only a real `Keyword::Crew` contributes a cost — a subtype-only + // Vehicle joins the archetype but adds nothing to pay. + if let Some(crew) = crew_requirement(face) { + total_crew_cost = total_crew_cost.saturating_add(crew.saturating_mul(entry.count)); + } + } + + // CR 702.122a: a Vehicle taps OTHER creatures, so it can never crew + // itself — an uncrewed Vehicle is not a creature and contributes no + // power. Counting it as its own bench would let a pile of Vehicles look + // self-sufficient. + if let Some(power) = crew_capable_power(face) { + crew_body_count = crew_body_count.saturating_add(entry.count); + total_crew_power = total_crew_power.saturating_add(power.saturating_mul(entry.count)); + } + } + + let commitment = compute_commitment(vehicle_count, crew_body_count, total_nonland); + + VehiclesFeature { + vehicle_count, + total_crew_cost, + crew_body_count, + total_crew_power, + commitment, + } +} + +/// CR 702.122a: the total power required to crew this face, or `None` when it is +/// not a Vehicle. +/// +/// Keyed on `Keyword::Crew` rather than the Vehicle subtype: the keyword is what +/// actually grants the crew ability, and a card can carry it without the printed +/// subtype (or carry the subtype with its crew ability granted elsewhere). The +/// subtype is still accepted as a fallback so a Vehicle whose crew keyword the +/// parser has not attached is not silently dropped from the archetype — it just +/// contributes no crew cost. +pub(crate) fn crew_requirement(face: &CardFace) -> Option { + crew_requirement_parts(face.keywords.iter()) +} + +/// CR 702.122a: the total power required to crew, or `None` when this object has +/// no crew ability at all. +/// +/// Keyed STRICTLY on `Keyword::Crew`. CR 702.122a opens *"Crew is an activated +/// ability of Vehicle cards"* — the printed subtype does not grant it. A +/// subtype-only Vehicle therefore has no crew requirement to satisfy, and must +/// not be reported as one: returning `Some(0)` here made `available >= required` +/// true for an empty board (`0 >= 0`) and scored a live crew bonus for a +/// permanent that can never be crewed. +/// +/// Deck classification asks a different question and uses +/// [`vehicle_archetype_member`] instead — see that function for why the subtype +/// fallback is honest THERE and wrong here. +/// +/// Parts-based so it classifies a deck-time `CardFace.keywords` slice and a live +/// `GameObject.keywords` slice through one predicate. +pub(crate) fn crew_requirement_parts<'a>( + keywords: impl IntoIterator, +) -> Option { + keywords.into_iter().find_map(|keyword| match keyword { + Keyword::Crew { power, .. } => Some(*power), + _ => None, + }) +} + +/// Is this face part of the Vehicles ARCHETYPE? +/// +/// Deliberately broader than [`crew_requirement`]: a Vehicle whose crew keyword +/// the parser has not attached is still a Vehicle the deck is built around, and +/// dropping it would understate the archetype. That fallback is safe for deck +/// classification — it only decides how strongly the axis activates — and is NOT +/// safe at the live seam, where it would invent a crew cost of zero for a +/// permanent with no crew ability. +pub(crate) fn vehicle_archetype_member(face: &CardFace) -> bool { + crew_requirement(face).is_some() || face_has_vehicle_subtype(face) +} + +/// CR 205.3: the printed type line carries the Vehicle subtype. +pub(crate) fn face_has_vehicle_subtype(face: &CardFace) -> bool { + face.card_type + .subtypes + .iter() + .any(|subtype| subtype.eq_ignore_ascii_case(VEHICLE_SUBTYPE)) +} + +/// CR 702.122a: the printed power this face can contribute toward a crew cost, +/// or `None` when it cannot contribute. +/// +/// Requires a creature with a KNOWN, positive, fixed power: +/// * A Vehicle is excluded even when it is also a creature card, because crew +/// taps *other* creatures. +/// * `PtValue::Variable` ("*", "1+*") has no deck-time value, so it is excluded +/// rather than guessed — the axis under-counts instead of inventing a bench. +/// * Power 0 taps for nothing and never helps reach `N`. +pub(crate) fn crew_capable_power(face: &CardFace) -> Option { + if !face.card_type.core_types.contains(&CoreType::Creature) { + return None; + } + if face_has_vehicle_subtype(face) { + return None; + } + match face.power { + Some(PtValue::Fixed(power)) if power > 0 => u32::try_from(power).ok(), + _ => None, + } +} + +/// Calibration — every figure below is the value `compute_commitment` actually +/// returns, verified before being written down: +/// - Dedicated Vehicles shell — 8 Vehicles + 16 crew-capable creatures over 36 +/// nonland → **1.000**. +/// - Realistic build — 5 Vehicles + 10 bodies → **0.833**. +/// - Light build — 3 Vehicles + 6 bodies → **0.645**. +/// - Two-Vehicle splash — 2 Vehicles + 4 bodies → **0.527**, still a plan. +/// +/// Anti-calibration: +/// - One incidental Smuggler's Copter in a 20-creature deck → **0.373**, below +/// `VEHICLES_FLOOR`. This case drove the pillar scaling: a saturated bench must +/// NOT compensate for near-zero Vehicle density, or a single Copter would +/// switch the whole axis on. +/// - Vehicles with no bench (5 Vehicles + 0 creatures) → 0.0. +/// - Creatures with no Vehicles → 0.0. +/// +/// Geometric mean over (vehicle density, crew support): BOTH pillars are +/// mandatory. Vehicles with nothing to tap are artifacts that never become +/// creatures; creatures with no Vehicles are just a creature deck. +fn compute_commitment(vehicle_count: u32, crew_body_count: u32, total_nonland: u32) -> f32 { + let vehicle_density = (commitment::density_per_60(vehicle_count, total_nonland) + / VEHICLE_SATURATION_PER_60) + .min(1.0); + // Support is measured against THIS deck's own Vehicle count rather than a + // fixed density: a deck needs a bench proportional to the Vehicles it runs, + // and that ratio is already deck-size neutral. + let crew_support = if vehicle_count == 0 { + 0.0 + } else { + (crew_body_count as f32 / (vehicle_count as f32 * BODIES_PER_VEHICLE)).min(1.0) + }; + commitment::geometric_mean(&[vehicle_density, crew_support]) +} diff --git a/crates/phase-ai/src/policies/crew_timing.rs b/crates/phase-ai/src/policies/crew_timing.rs index 427e0da404..30e27ce19b 100644 --- a/crates/phase-ai/src/policies/crew_timing.rs +++ b/crates/phase-ai/src/policies/crew_timing.rs @@ -14,6 +14,14 @@ use super::context::PolicyContext; use super::registry::{DecisionKind, PolicyId, PolicyReason, PolicyVerdict, TacticalPolicy}; use crate::features::DeckFeatures; +/// Floor under the vehicles-commitment scale. A crew activation the AI is +/// already looking at is worth judging even in a deck the axis rates low — and +/// even at commitment 0.0, because the deck-time bench detection is deliberately +/// conservative and cannot see tokens or variable-power creatures. The weight is +/// damped rather than zeroed, unlike the payoff policies which opt out entirely +/// below their floor. +const MIN_CREW_ACTIVATION: f32 = 0.25; + pub struct CrewTimingPolicy; impl TacticalPolicy for CrewTimingPolicy { @@ -27,11 +35,30 @@ impl TacticalPolicy for CrewTimingPolicy { fn activation( &self, - _features: &DeckFeatures, + features: &DeckFeatures, _state: &GameState, _player: PlayerId, ) -> Option { - Some(1.0) // activation-constant: exact initial-Crew action self-gates in verdict. + // CR 702.122: previously `activation-constant Some(1.0)` — the exact + // initial-Crew action self-gates in `verdict`, so a constant was safe, + // but it applied identical weight in a dedicated Vehicles shell and in a + // deck running one incidental Copter. `features::vehicles` now supplies + // that deck signal. + // + // This NEVER opts out, including at commitment 0.0, and that is + // deliberate rather than an oversight. `features::vehicles` is + // conservative by design: `crew_capable_power` excludes non-fixed + // printed power (`PtValue::Variable`), and a decklist cannot see tokens + // at all. So a zero commitment means "no bench I could prove at + // deck-build time", NOT "cannot crew". A Vehicles deck whose bench is + // tokens, or `*`-power creatures, scores 0.0 and can still reach a legal + // `CrewVehicle` action — and dropping the timing safeguard exactly there + // would be worst in the case that needs it most. + // + // The weight is therefore DAMPED, never zeroed: `MIN_CREW_ACTIVATION` + // floors it so the policy keeps judging a crew action the AI is already + // looking at, while a dedicated shell still outweighs an incidental one. + Some(features.vehicles.commitment.max(MIN_CREW_ACTIVATION)) } fn verdict(&self, ctx: &PolicyContext<'_>) -> PolicyVerdict { @@ -142,6 +169,12 @@ mod tests { use engine::types::phase::Phase; use engine::types::zones::Zone; + use crate::features::vehicles::VehiclesFeature; + use crate::features::DeckFeatures; + use crate::policies::registry::{PolicyId, PolicyRegistry}; + use crate::session::AiSession; + use std::sync::Arc; + const AI: PlayerId = PlayerId(0); fn crew_fixture() -> (GameState, ObjectId, ObjectId) { @@ -294,4 +327,87 @@ mod tests { assert!(crew_has_exact_combat_use(&state, AI, vehicle, &activation)); } + + // ─── review #6790: zero cached commitment must NOT silence the safeguard ── + + #[test] + fn activation_never_opts_out_even_at_zero_commitment() { + // `features::vehicles` is conservative: it excludes variable printed + // power and cannot see tokens, so 0.0 means "no bench provable at + // deck-build time", not "cannot crew". + let zero = DeckFeatures { + vehicles: VehiclesFeature::default(), + ..Default::default() + }; + assert_eq!(zero.vehicles.commitment, 0.0); + assert_eq!( + CrewTimingPolicy.activation(&zero, &GameState::new_two_player(42), AI), + Some(MIN_CREW_ACTIVATION), + "a zero-commitment deck can still reach a legal crew action" + ); + } + + #[test] + fn activation_scales_above_the_floor() { + let committed = DeckFeatures { + vehicles: VehiclesFeature { + commitment: 0.8, + ..Default::default() + }, + ..Default::default() + }; + assert_eq!( + CrewTimingPolicy.activation(&committed, &GameState::new_two_player(42), AI), + Some(0.8), + "a dedicated shell must outweigh an incidental one" + ); + } + + #[test] + fn registry_emits_a_crew_verdict_at_zero_commitment() { + // The production seam: a live `CrewVehicle` candidate must still reach + // `CrewTimingPolicy` through the registry when cached commitment is 0.0. + // This is the regression the opt-out would have introduced — the deck + // whose bench is tokens scores 0.0 and needs the safeguard most. + let (state, vehicle, _) = crew_fixture(); + let candidate = CandidateAction { + action: GameAction::CrewVehicle { + vehicle_id: vehicle, + creature_ids: Vec::new(), + }, + metadata: ActionMetadata::for_actor(Some(AI), TacticalClass::Utility), + }; + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let config = AiConfig::default(); + let mut session = AiSession::empty(); + session.features.insert( + AI, + DeckFeatures { + vehicles: VehiclesFeature::default(), + ..Default::default() + }, + ); + let mut context = AiContext::empty(&config.weights); + context.session = Arc::new(session); + context.player = AI; + + let ctx = PolicyContext { + state: &state, + decision: &decision, + candidate: &candidate, + ai_player: AI, + config: &config, + context: &context, + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + let verdicts = PolicyRegistry::default().verdicts(&ctx); + assert!( + verdicts.iter().any(|(id, _)| *id == PolicyId::CrewTiming), + "CrewTimingPolicy must still be routed at commitment 0.0" + ); + } } diff --git a/crates/phase-ai/src/policies/mod.rs b/crates/phase-ai/src/policies/mod.rs index 63ed9ce62e..0deb97bb50 100644 --- a/crates/phase-ai/src/policies/mod.rs +++ b/crates/phase-ai/src/policies/mod.rs @@ -70,6 +70,7 @@ mod tempo_curve; mod tokens_wide; mod tribal_lord_priority; pub(crate) mod tutor; +mod vehicle_deployment; mod x_cast_gate; mod x_reference; mod x_value; diff --git a/crates/phase-ai/src/policies/registry.rs b/crates/phase-ai/src/policies/registry.rs index e438983426..aa7fbac644 100644 --- a/crates/phase-ai/src/policies/registry.rs +++ b/crates/phase-ai/src/policies/registry.rs @@ -159,6 +159,8 @@ pub enum PolicyId { /// CR 701.9: reward discarding into an on-battlefield "whenever you discard" /// engine — disjoint from `HandDisruption`, which scores OPPONENT discard. DiscardPayoff, + /// CR 702.122a: cast a Vehicle when the board can actually crew it. + VehicleDeployment, } /// Coarse routing kind for a candidate decision. Each policy declares which @@ -418,6 +420,7 @@ impl Default for PolicyRegistry { Box::new(super::cost_reduction::CostReductionPolicy), Box::new(super::draw_payoff::DrawPayoffPolicy), Box::new(super::discard_payoff::DiscardPayoffPolicy), + Box::new(super::vehicle_deployment::VehicleDeploymentPolicy), ]; Self::from_policies(policies) } diff --git a/crates/phase-ai/src/policies/tests/mod.rs b/crates/phase-ai/src/policies/tests/mod.rs index 90515eceea..7e135a8aec 100644 --- a/crates/phase-ai/src/policies/tests/mod.rs +++ b/crates/phase-ai/src/policies/tests/mod.rs @@ -21,3 +21,4 @@ pub mod removal_lethality; pub mod sac_outlet_drain_repro; pub mod score_contract_lint; pub mod self_bounce_target; +pub mod vehicle_deployment; diff --git a/crates/phase-ai/src/policies/tests/vehicle_deployment.rs b/crates/phase-ai/src/policies/tests/vehicle_deployment.rs new file mode 100644 index 0000000000..44793dbe7c --- /dev/null +++ b/crates/phase-ai/src/policies/tests/vehicle_deployment.rs @@ -0,0 +1,536 @@ +//! Unit tests for `policies::vehicle_deployment` — CR 702.122a crewable-Vehicle +//! deployment. No `#[cfg(test)]` in SOURCE files; tests live here. + +use std::sync::Arc; + +use engine::ai_support::{ActionMetadata, AiDecisionContext, CandidateAction, TacticalClass}; +use engine::game::zones::create_object; +use engine::types::ability::StaticDefinition; +use engine::types::ability::TargetFilter; +use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; +use engine::types::format::FormatConfig; +use engine::types::game_state::{CastPaymentMode, GameState, WaitingFor}; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::keywords::Keyword; +use engine::types::player::PlayerId; +use engine::types::statics::{CrewAction, CrewContributionKind, StaticMode}; +use engine::types::zones::Zone; + +use crate::config::AiConfig; +use crate::context::AiContext; +use crate::features::vehicles::{VehiclesFeature, VEHICLES_FLOOR}; +use crate::features::DeckFeatures; +use crate::policies::context::{PolicyContext, SearchDepth}; +use crate::policies::registry::{ + PolicyId, PolicyReason, PolicyRegistry, PolicyVerdict, TacticalPolicy, +}; +use crate::policies::vehicle_deployment::*; +use crate::session::AiSession; + +const AI: PlayerId = PlayerId(0); + +fn state() -> GameState { + GameState::new(FormatConfig::standard(), 2, 42) +} + +/// A Vehicle in hand with `Crew N`. +fn vehicle_in_hand(state: &mut GameState, crew: u32) -> (ObjectId, CardId) { + let card_id = CardId(state.next_object_id); + let id = create_object(state, card_id, AI, "Copter".to_string(), Zone::Hand); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Artifact); + obj.card_types.subtypes.push("Vehicle".to_string()); + obj.keywords.push(Keyword::Crew { + power: crew, + once_per_turn: None, + }); + state.players[AI.0 as usize].hand.push_back(id); + (id, card_id) +} + +/// A non-Vehicle artifact in hand. +fn artifact_in_hand(state: &mut GameState) -> (ObjectId, CardId) { + let card_id = CardId(state.next_object_id); + let id = create_object(state, card_id, AI, "Signet".to_string(), Zone::Hand); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Artifact); + state.players[AI.0 as usize].hand.push_back(id); + (id, card_id) +} + +/// An untapped creature the AI controls, with `power`. +fn creature(state: &mut GameState, power: i32, controller: PlayerId) -> ObjectId { + let card_id = CardId(state.next_object_id); + let id = create_object( + state, + card_id, + controller, + "Bear".to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + obj.power = Some(power); + obj.toughness = Some(power.max(1)); + id +} + +fn feature(commitment: f32) -> VehiclesFeature { + VehiclesFeature { + vehicle_count: 5, + total_crew_cost: 10, + crew_body_count: 10, + total_crew_power: 20, + commitment, + } +} + +fn session(commitment: f32) -> AiSession { + let features = DeckFeatures { + vehicles: feature(commitment), + ..Default::default() + }; + let mut session = AiSession::empty(); + session.features.insert(AI, features); + session +} + +fn context(config: &AiConfig, session: AiSession) -> AiContext { + let mut context = AiContext::empty(&config.weights); + context.session = Arc::new(session); + context.player = AI; + context +} + +fn cast(object_id: ObjectId, card_id: CardId) -> CandidateAction { + CandidateAction { + action: GameAction::CastSpell { + object_id, + card_id, + targets: Vec::new(), + payment_mode: CastPaymentMode::default(), + }, + metadata: ActionMetadata::for_actor(Some(AI), TacticalClass::Spell), + } +} + +fn ctx<'a>( + state: &'a GameState, + candidate: &'a CandidateAction, + decision: &'a AiDecisionContext, + context: &'a AiContext, + config: &'a AiConfig, +) -> PolicyContext<'a> { + PolicyContext { + state, + decision, + candidate, + ai_player: AI, + config, + context, + cast_facts: None, + search_depth: SearchDepth::Root, + } +} + +fn priority_decision(candidate: &CandidateAction) -> AiDecisionContext { + AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + } +} + +fn score_of(verdict: PolicyVerdict) -> (f64, PolicyReason) { + match verdict { + PolicyVerdict::Score { delta, reason } => (delta, reason), + PolicyVerdict::Reject { reason } => panic!("unexpected Reject: {reason:?}"), + } +} + +fn verdict_for( + st: &GameState, + obj: ObjectId, + card: CardId, + commitment: f32, +) -> (f64, PolicyReason) { + let config = AiConfig::default(); + let context = context(&config, session(commitment)); + let candidate = cast(obj, card); + let decision = priority_decision(&candidate); + score_of(VehicleDeploymentPolicy.verdict(&ctx(st, &candidate, &decision, &context, &config))) +} + +// ─── activation ────────────────────────────────────────────────────────────── + +#[test] +fn activation_opts_out_below_floor() { + let features = DeckFeatures { + vehicles: feature(VEHICLES_FLOOR - 0.01), + ..Default::default() + }; + assert!(VehicleDeploymentPolicy + .activation(&features, &state(), AI) + .is_none()); +} + +#[test] +fn activation_opts_in_above_floor() { + let features = DeckFeatures { + vehicles: feature(0.8), + ..Default::default() + }; + assert_eq!( + VehicleDeploymentPolicy.activation(&features, &state(), AI), + Some(0.8) + ); +} + +// ─── verdict ───────────────────────────────────────────────────────────────── + +#[test] +fn crewable_vehicle_scores_positive() { + let mut st = state(); + creature(&mut st, 3, AI); + let (obj, card) = vehicle_in_hand(&mut st, 2); + let (delta, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_crewable"); + assert!(delta > 0.0, "expected positive credit, got {delta}"); +} + +#[test] +fn uncrewable_vehicle_is_neutral_not_penalized() { + // No bodies: the Vehicle would enter as a blank. Withholding the bonus is the + // whole signal — this policy never vetoes a deployment. + let mut st = state(); + let (obj, card) = vehicle_in_hand(&mut st, 3); + let (delta, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_uncrewable"); + assert_eq!(delta, 0.0, "must never penalize, only withhold"); +} + +#[test] +fn crew_power_sums_across_multiple_bodies() { + // CR 702.122a: "any number of other untapped creatures with TOTAL power N". + let mut st = state(); + creature(&mut st, 1, AI); + creature(&mut st, 1, AI); + creature(&mut st, 1, AI); + let (obj, card) = vehicle_in_hand(&mut st, 3); + let (_, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_crewable"); +} + +#[test] +fn insufficient_total_power_is_uncrewable() { + let mut st = state(); + creature(&mut st, 1, AI); + creature(&mut st, 1, AI); + let (_, reason) = { + let (obj, card) = vehicle_in_hand(&mut st, 5); + verdict_for(&st, obj, card, 0.8) + }; + assert_eq!(reason.kind, "vehicle_deployment_uncrewable"); +} + +#[test] +fn tapped_creatures_do_not_crew() { + // CR 702.122a requires UNTAPPED creatures. + let mut st = state(); + let body = creature(&mut st, 4, AI); + st.objects.get_mut(&body).unwrap().tapped = true; + let (obj, card) = vehicle_in_hand(&mut st, 2); + let (_, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_uncrewable"); +} + +#[test] +fn opponent_creatures_do_not_crew() { + // CR 702.122a: creatures YOU control. + let mut st = state(); + creature(&mut st, 5, PlayerId(1)); + let (obj, card) = vehicle_in_hand(&mut st, 2); + let (_, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_uncrewable"); +} + +#[test] +fn non_vehicle_is_not_applicable() { + let mut st = state(); + creature(&mut st, 5, AI); + let (obj, card) = artifact_in_hand(&mut st); + let (delta, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_na"); + assert_eq!(delta, 0.0); +} + +#[test] +fn surplus_credit_is_bounded_by_the_cap() { + let mut st = state(); + for _ in 0..12 { + creature(&mut st, 5, AI); + } + let (obj, card) = vehicle_in_hand(&mut st, 1); + let (delta, _) = verdict_for(&st, obj, card, 0.8); + let config = AiConfig::default(); + let ceiling = config.policy_penalties.vehicle_deployment_bonus * 2.0; + assert!( + delta <= ceiling + f64::EPSILON, + "delta {delta} exceeded ceiling {ceiling}" + ); +} + +#[test] +fn exact_crew_requirement_is_crewable() { + // Boundary: total power exactly equals N (CR 702.122a says "N or greater"). + let mut st = state(); + creature(&mut st, 2, AI); + let (obj, card) = vehicle_in_hand(&mut st, 2); + let (_, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_crewable"); +} + +// ─── production seam ───────────────────────────────────────────────────────── + +#[test] +fn registry_routes_cast_spell_to_this_policy() { + let mut st = state(); + creature(&mut st, 3, AI); + let (obj, card) = vehicle_in_hand(&mut st, 2); + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(obj, card); + let decision = priority_decision(&candidate); + let verdicts = + PolicyRegistry::default().verdicts(&ctx(&st, &candidate, &decision, &context, &config)); + let found = verdicts + .iter() + .find(|(id, _)| *id == PolicyId::VehicleDeployment) + .map(|(_, v)| v.clone()) + .expect("VehicleDeploymentPolicy must be registered and routed for CastSpell"); + let (delta, reason) = score_of(found); + assert_eq!(reason.kind, "vehicle_deployment_crewable"); + assert!(delta > 0.0); +} + +#[test] +fn registry_stays_silent_below_the_activation_floor() { + let mut st = state(); + creature(&mut st, 3, AI); + let (obj, card) = vehicle_in_hand(&mut st, 2); + let config = AiConfig::default(); + let context = context(&config, session(VEHICLES_FLOOR - 0.01)); + let candidate = cast(obj, card); + let decision = priority_decision(&candidate); + let verdicts = + PolicyRegistry::default().verdicts(&ctx(&st, &candidate, &decision, &context, &config)); + assert!( + !verdicts + .iter() + .any(|(id, _)| *id == PolicyId::VehicleDeployment), + "policy must not contribute below its activation floor" + ); +} + +// ─── review #6790 blocker: a subtype-only Vehicle has no crew ability ─────── + +/// A Vehicle by TYPE LINE only — no `Keyword::Crew`. CR 702.122a makes Crew an +/// activated ability, which a subtype alone does not grant. +fn subtype_only_vehicle_in_hand(state: &mut GameState) -> (ObjectId, CardId) { + let card_id = CardId(state.next_object_id); + let id = create_object(state, card_id, AI, "Odd Vehicle".to_string(), Zone::Hand); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Artifact); + obj.card_types.subtypes.push("Vehicle".to_string()); + state.players[AI.0 as usize].hand.push_back(id); + (id, card_id) +} + +#[test] +fn subtype_only_vehicle_is_not_applicable_on_an_empty_board() { + // The exact regression: a synthesised `Crew 0` satisfied `0 >= 0` and scored + // a live crew bonus for a permanent that can never be crewed. + let mut st = state(); + let (obj, card) = subtype_only_vehicle_in_hand(&mut st); + let (delta, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_na"); + assert_eq!(delta, 0.0); +} + +#[test] +fn subtype_only_vehicle_is_not_applicable_with_a_full_board() { + // Same, with a board that WOULD satisfy any real crew cost — proving the + // rejection comes from the missing ability, not from an empty battlefield. + let mut st = state(); + creature(&mut st, 5, AI); + creature(&mut st, 5, AI); + let (obj, card) = subtype_only_vehicle_in_hand(&mut st); + let (delta, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_na"); + assert_eq!(delta, 0.0); +} + +#[test] +fn registry_stays_silent_for_a_subtype_only_vehicle() { + // At the production seam, per the review: registered + routed, still neutral. + let mut st = state(); + creature(&mut st, 5, AI); + let (obj, card) = subtype_only_vehicle_in_hand(&mut st); + let config = AiConfig::default(); + let context = context(&config, session(0.8)); + let candidate = cast(obj, card); + let decision = priority_decision(&candidate); + let verdicts = + PolicyRegistry::default().verdicts(&ctx(&st, &candidate, &decision, &context, &config)); + // `.expect`, not `if let` — the invariant is "registered AND routed AND + // neutral". Skipping the assertions when the policy is absent would let a + // routing regression (activation regressing to `None`) pass silently, which + // is the opposite of what this test claims to enforce. + let found = verdicts + .iter() + .find(|(id, _)| *id == PolicyId::VehicleDeployment) + .map(|(_, v)| v.clone()) + .expect("VehicleDeploymentPolicy must be registered and routed for CastSpell"); + let (delta, reason) = score_of(found); + assert_eq!(reason.kind, "vehicle_deployment_na"); + assert_eq!(delta, 0.0); +} + +// ─── review #6790 NB: the engine crew authorities must actually be consulted ── + +/// Attach a static to a battlefield object. +fn attach_static(state: &mut GameState, id: ObjectId, mode: StaticMode) { + let obj = state.objects.get_mut(&id).unwrap(); + obj.static_definitions.push(StaticDefinition::new(mode)); +} + +#[test] +fn cant_crew_creature_does_not_contribute() { + // CR 702.122d, via `object_has_cant_crew`. Without that call the 4-power body + // would cover Crew 3 and this would score. + let mut st = state(); + let body = creature(&mut st, 4, AI); + attach_static(&mut st, body, StaticMode::CantCrew); + let (obj, card) = vehicle_in_hand(&mut st, 3); + let (delta, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_uncrewable"); + assert_eq!(delta, 0.0); +} + +#[test] +fn power_delta_contribution_is_honored() { + // CR 702.122a "as though its power were N greater", via + // `object_crew_power_contribution`. A raw `.power` sum would read 1 and call + // this uncrewable; the authority reads 3. + let mut st = state(); + let body = creature(&mut st, 1, AI); + attach_static( + &mut st, + body, + StaticMode::CrewContribution { + kind: CrewContributionKind::PowerDelta { delta: 2 }, + actions: vec![CrewAction::Crew], + }, + ); + let (obj, card) = vehicle_in_hand(&mut st, 3); + let (_, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!( + reason.kind, "vehicle_deployment_crewable", + "the engine's crew-power authority must be consulted, not raw power" + ); +} + +#[test] +fn toughness_instead_of_power_contribution_is_honored() { + // CR 702.122a "using its toughness rather than its power". + let mut st = state(); + let body = creature(&mut st, 1, AI); + st.objects.get_mut(&body).unwrap().toughness = Some(4); + attach_static( + &mut st, + body, + StaticMode::CrewContribution { + kind: CrewContributionKind::ToughnessInsteadOfPower, + actions: vec![CrewAction::Crew], + }, + ); + let (obj, card) = vehicle_in_hand(&mut st, 4); + let (_, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_crewable"); +} + +// ─── review #6790 blocker: CantTap creatures cannot pay Crew ───────────────── + +/// A creature the engine's crew path rejects: `StaticMode::CantTap`. +/// +/// Mirrors the engine's own fixture (`restrictions.rs::creature_with_cant_tap`) — +/// the static goes on BOTH `static_definitions` and `base_static_definitions`, +/// then `evaluate_layers` runs, because `object_cant_tap` has an O(1) fast path +/// gated on a static-kind presence index. Skipping the layer pass would leave +/// that index empty, the fast path would return `false`, and this test would pass +/// for the wrong reason. +fn cant_tap_creature(state: &mut GameState, power: i32) -> ObjectId { + let id = creature(state, power, AI); + { + let obj = state.objects.get_mut(&id).unwrap(); + let def = StaticDefinition::new(StaticMode::CantTap).affected(TargetFilter::SelfRef); + obj.static_definitions.push(def.clone()); + Arc::make_mut(&mut obj.base_static_definitions).push(def); + } + engine::game::layers::evaluate_layers(state); + id +} + +#[test] +fn cant_tap_creature_cannot_pay_crew() { + // CR 702.122a via the engine's `creature_can_pay_crew` authority: a CantTap + // 3/3 is untapped and controlled, but cannot legally be tapped, so it + // contributes nothing toward Crew 3. The previous hand-rolled filter omitted + // `object_cant_tap` and counted it, awarding the crewable bonus for a Vehicle + // that can never be crewed. + let mut st = state(); + let restricted = cant_tap_creature(&mut st, 3); + // Guard the fixture itself through the public authority: if the static did + // not take effect, this test would pass vacuously. Paired with a plain body + // of the same power so the assertion discriminates the restriction rather + // than merely observing a `false`. + let unrestricted = creature(&mut st, 3, AI); + assert!( + !engine::game::engine::creature_can_pay_crew(&st, restricted, AI), + "fixture must actually be under a CantTap restriction" + ); + assert!( + engine::game::engine::creature_can_pay_crew(&st, unrestricted, AI), + "an identical unrestricted body must be able to pay crew" + ); + // Remove the control body again so the crew total is the restricted one only. + st.battlefield.retain(|id| *id != unrestricted); + let (obj, card) = vehicle_in_hand(&mut st, 3); + let (delta, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_uncrewable"); + assert_eq!(delta, 0.0); +} + +#[test] +fn cant_tap_creature_alongside_a_legal_body_still_undercounts() { + // Discriminating: one legal 1-power body plus a CantTap 3/3 totals 1, not 4, + // so Crew 3 stays out of reach. + let mut st = state(); + cant_tap_creature(&mut st, 3); + creature(&mut st, 1, AI); + let (obj, card) = vehicle_in_hand(&mut st, 3); + let (_, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_uncrewable"); +} + +#[test] +fn an_unrestricted_body_of_the_same_power_does_crew() { + // The positive control for the two tests above: identical power, no CantTap, + // so the difference is provably the restriction and not the power total. + let mut st = state(); + creature(&mut st, 3, AI); + let (obj, card) = vehicle_in_hand(&mut st, 3); + let (delta, reason) = verdict_for(&st, obj, card, 0.8); + assert_eq!(reason.kind, "vehicle_deployment_crewable"); + assert!(delta > 0.0); +} diff --git a/crates/phase-ai/src/policies/vehicle_deployment.rs b/crates/phase-ai/src/policies/vehicle_deployment.rs new file mode 100644 index 0000000000..6d5635eb4f --- /dev/null +++ b/crates/phase-ai/src/policies/vehicle_deployment.rs @@ -0,0 +1,131 @@ +//! `VehicleDeploymentPolicy` — value casting a Vehicle against whether the board +//! can actually crew it. +//! +//! ## The gap this closes +//! +//! CR 702.122a: an uncrewed Vehicle is not a creature. It sits on the battlefield +//! doing nothing until its controller taps *other* untapped creatures with total +//! power N or greater. So the same card is a threat on a developed board and a +//! blank on an empty one — and nothing in the AI distinguishes those two casts. +//! +//! `CrewTimingPolicy` decides whether a specific crew activation is worth it, +//! which is the question *after* the Vehicle is already down. This policy asks +//! the earlier one: is deploying it now going to produce a body, or a brick? +//! +//! It is deliberately one-directional in the same way `DrawPayoffPolicy` is: it +//! ADDS value when the crew requirement is already met and withholds it +//! otherwise. It never vetoes a Vehicle cast — holding a Vehicle for a board that +//! may never arrive is its own mistake, and the mana-efficiency and board- +//! development policies already price deploying a permanent. +//! +//! ## Performance +//! +//! `verdict()` runs per candidate per search node. The card-local check — does +//! this candidate carry a crew requirement at all — reads one card's keywords and +//! rejects every non-Vehicle candidate. Only a confirmed Vehicle pays for the +//! battlefield walk, which is bounded by the AI's own permanents and delegates +//! per-creature power to the engine's `object_crew_power_contribution` authority. +//! No affordability sweep, no `find_legal_targets`. + +use engine::game::engine::creature_can_pay_crew; +use engine::game::static_abilities::object_crew_power_contribution; +use engine::types::actions::GameAction; +use engine::types::game_state::GameState; +use engine::types::player::PlayerId; +use engine::types::statics::CrewAction; + +use crate::features::vehicles::{crew_requirement_parts, VEHICLES_FLOOR}; +use crate::features::DeckFeatures; + +use super::context::PolicyContext; +use super::registry::{DecisionKind, PolicyId, PolicyReason, PolicyVerdict, TacticalPolicy}; + +pub struct VehicleDeploymentPolicy; + +/// Cap on the surplus crew power rewarded, so a huge board cannot push one +/// Vehicle cast out of the preference band. +/// +/// `pub(crate)` so the bounded-score regression asserts against this constant +/// rather than a copied literal. +pub(crate) const MAX_REWARDED_SURPLUS: i32 = 3; + +impl TacticalPolicy for VehicleDeploymentPolicy { + fn id(&self) -> PolicyId { + PolicyId::VehicleDeployment + } + + fn decision_kinds(&self) -> &'static [DecisionKind] { + &[DecisionKind::CastSpell] + } + + fn activation( + &self, + features: &DeckFeatures, + _state: &GameState, + _player: PlayerId, + ) -> Option { + if features.vehicles.commitment < VEHICLES_FLOOR { + None + } else { + Some(features.vehicles.commitment) + } + } + + fn verdict(&self, ctx: &PolicyContext<'_>) -> PolicyVerdict { + // Card-local first: is this candidate even a Vehicle? + let Some(facts) = ctx.cast_facts() else { + return PolicyVerdict::neutral(PolicyReason::new("vehicle_deployment_na")); + }; + if !matches!(ctx.candidate.action, GameAction::CastSpell { .. }) { + return PolicyVerdict::neutral(PolicyReason::new("vehicle_deployment_na")); + } + // CR 702.122a: Crew is an ACTIVATED ABILITY. A Vehicle subtype without + // the keyword grants no crew ability, so it has no requirement to meet — + // routing through the strict authority keeps a `Crew 0` from being + // synthesised and trivially satisfied by an empty board. + let Some(crew_cost) = crew_requirement_parts(facts.object.keywords.iter()) else { + return PolicyVerdict::neutral(PolicyReason::new("vehicle_deployment_na")); + }; + + // Only now pay for the board walk. Eligibility is NOT re-derived here: + // `creature_can_pay_crew` is the engine's own composed authority, the + // same rule the crew payment path enforces (controlled, untapped, a + // creature, not `CantTap`, not `CantCrew`). Assembling that filter from + // parts is how this policy previously over-counted — it omitted the + // `object_cant_tap` term, so a `CantTap` 3/3 read as able to pay Crew 3. + // + // Per-creature power likewise goes through `object_crew_power_contribution`, + // so a body crewing "as though its power were N greater" or by toughness + // instead of power is counted exactly as the real payment would count it. + let available: i32 = ctx + .state + .battlefield + .iter() + .filter(|id| creature_can_pay_crew(ctx.state, **id, ctx.ai_player)) + .map(|id| object_crew_power_contribution(ctx.state, *id, CrewAction::Crew)) + .sum(); + + let required = i32::try_from(crew_cost).unwrap_or(i32::MAX); + if available < required { + // The Vehicle would enter as a blank. No penalty — see the module + // docs: withholding the bonus is the whole signal. + return PolicyVerdict::neutral( + PolicyReason::new("vehicle_deployment_uncrewable") + .with_fact("required", i64::from(required)) + .with_fact("available", i64::from(available)), + ); + } + + // The board can turn this into a creature the turn it lands. Scale mildly + // with surplus power, because spare bodies mean crewing costs less of the + // attack step. + let surplus = (available - required).min(MAX_REWARDED_SURPLUS); + let scaled = 1.0 + f64::from(surplus) / f64::from(MAX_REWARDED_SURPLUS); + PolicyVerdict::score( + ctx.config.policy_penalties.vehicle_deployment_bonus * scaled, + PolicyReason::new("vehicle_deployment_crewable") + .with_fact("required", i64::from(required)) + .with_fact("available", i64::from(available)), + ) + } +} From a2b2c311cb268c4739667b90a3f804354696efc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D1=96=CC=81=D0=B9=20=D0=9F=D0=BE?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=D1=81=D0=BA=D1=96?= <54000164+andriypolanski@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:46:15 -0700 Subject: [PATCH 25/63] fix(engine): batch repeated optional payments into one mode modal (#6175) (#6585) * fix(engine): batch repeated optional payments into one mode modal (#6175) * fix(engine): expose mode affordability in modal payload for UI (#6175) Stamp max_affordable_selections from the engine probe so ModeChoiceModal stops inferring legality from pool size or pip counts, and correct Spree CR citations from 702.172b to 702.172a. Co-authored-by: Cursor * fix(#6175): thread max_affordable_selections and add over-cap test Complete ModalChoice field wiring in mtgish-import and server-core, fix the ModeChoiceModal ManaType fixture, and assert batched SelectModes rejects three modes against an intact two-mana pool without payment. Co-authored-by: Cursor * fix(parser): preserve positional mode_costs entries per mode (#6175) When any mode carries a per-mode cost, build a full-length mode_costs vec with ManaCost::zero for costless modes so engine and UI index by mode index. Co-authored-by: Cursor * fix(#6175): cap max_affordable_selections by modal max_choices Stamp min(affordable, max_choices) in the batched driver so the UI cannot offer more mode picks than validate_modal_indices allows; add two-mode and frontend regressions. Co-authored-by: Cursor * chore(#6175): trigger PR head sync Co-authored-by: Cursor * fix(PR-6585): box pending trigger ownership --------- Co-authored-by: Andriy Polanski Co-authored-by: Cursor Co-authored-by: matthewevans --- client/src/adapter/types.ts | 6 + .../src/components/modal/ModeChoiceModal.tsx | 70 ++- .../modal/__tests__/ModeChoiceModal.test.tsx | 110 ++++- client/src/i18n/locales/de/game.json | 2 + client/src/i18n/locales/en/game.json | 2 + client/src/i18n/locales/es/game.json | 2 + client/src/i18n/locales/fr/game.json | 2 + client/src/i18n/locales/it/game.json | 2 + client/src/i18n/locales/pl/game.json | 2 + client/src/i18n/locales/pt/game.json | 2 + crates/engine/src/ai_support/candidates.rs | 30 +- crates/engine/src/game/ability_rw.rs | 1 + crates/engine/src/game/ability_scan.rs | 1 + crates/engine/src/game/effects/mod.rs | 243 ++++++++- crates/engine/src/game/effects/pay.rs | 2 +- crates/engine/src/game/engine_modes.rs | 36 ++ crates/engine/src/game/marksman_tests.rs | 461 ++++++++++-------- crates/engine/src/game/triggers.rs | 1 + .../triggers_push_first_contract_tests.rs | 2 + crates/engine/src/parser/oracle_modal.rs | 70 ++- crates/engine/src/types/ability.rs | 9 +- crates/engine/src/types/resolution.rs | 15 + crates/mtgish-import/src/convert/mod.rs | 1 + crates/server-core/src/filter.rs | 1 + 24 files changed, 846 insertions(+), 227 deletions(-) diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index ec938837a8..7cdfac4cc3 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1554,6 +1554,12 @@ export interface ModalChoice { allow_repeat_modes: boolean; /** Per-mode additional mana costs (Spree). Empty/absent for standard modal spells. */ mode_costs?: ManaCost[]; + /** + * CR 118.1 + CR 702.172a: When `mode_costs` is present, the engine-probed + * maximum number of mode selections affordable with current mana. The UI must + * use this — not mana-pool size or pip counts — to gate selection. + */ + max_affordable_selections?: number; /** * CR 700.2i: Per-mode pawprint weights for points-budget modals ("up to N {P} * worth of modes"). Empty/absent for non-pawprint modals. When present, diff --git a/client/src/components/modal/ModeChoiceModal.tsx b/client/src/components/modal/ModeChoiceModal.tsx index eea6aeee70..356832b7c3 100644 --- a/client/src/components/modal/ModeChoiceModal.tsx +++ b/client/src/components/modal/ModeChoiceModal.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import type { ModalChoice } from "../../adapter/types.ts"; import { useCanActForWaitingState } from "../../hooks/usePlayerId.ts"; import { useGameStore } from "../../stores/gameStore.ts"; +import { ManaCostPips } from "../mana/ManaCostPips.tsx"; import { DialogShell } from "./DialogShell.tsx"; export function ModeChoiceModal() { @@ -35,10 +36,19 @@ export function ModeChoiceModal() { // (Σ of chosen mode weights ≤ budget), NOT a mode count. The engine remains // the legality authority; this is pure display arithmetic over engine data. const pawprints = useMemo(() => modal?.mode_pawprints ?? [], [modal]); + const modeCosts = useMemo(() => modal?.mode_costs ?? [], [modal]); const isBudget = pawprints.length > 0; + const isModeCost = !isBudget && modeCosts.length > 0; const budget = modal?.max_choices ?? 0; + // CR 118.1 + CR 702.172a: per-mode payment caps come from the engine probe, + // bounded by the modal's legal selection cap (`max_choices`). + const maxAffordableSelections = Math.min( + modal?.max_affordable_selections ?? modal?.max_choices ?? 0, + modal?.max_choices ?? 0, + ); const spent = useMemo( - () => (isBudget ? selected.reduce((sum, i) => sum + (pawprints[i] ?? 0), 0) : 0), + () => + isBudget ? selected.reduce((sum, i) => sum + (pawprints[i] ?? 0), 0) : 0, [isBudget, selected, pawprints], ); @@ -54,6 +64,10 @@ export function ModeChoiceModal() { if (spentPrev + (pawprints[index] ?? 0) > budget) { return prev; } + } else if (isModeCost) { + if (prev.length >= maxAffordableSelections) { + return prev; + } } else if (prev.length >= modal.max_choices) { return prev; } @@ -68,13 +82,17 @@ export function ModeChoiceModal() { if (spentPrev + (pawprints[index] ?? 0) > budget) { return prev; } + } else if (isModeCost) { + if (prev.length >= maxAffordableSelections) { + return prev; + } } else if (prev.length >= modal.max_choices) { return prev; } return [...prev, index].sort((a, b) => a - b); }); }, - [modal, unavailableModes, isBudget, pawprints, budget], + [modal, unavailableModes, isBudget, isModeCost, pawprints, budget, maxAffordableSelections], ); const handleConfirm = useCallback(() => { @@ -84,12 +102,14 @@ export function ModeChoiceModal() { if (isBudget) { const spentSum = indices.reduce((sum, i) => sum + (pawprints[i] ?? 0), 0); if (spentSum > modal.max_choices) return; + } else if (isModeCost) { + if (indices.length > maxAffordableSelections) return; } else if (indices.length > modal.max_choices) { return; } dispatch({ type: "SelectModes", data: { indices } }); setSelected([]); - }, [modal, selected, dispatch, isBudget, pawprints]); + }, [modal, selected, dispatch, isBudget, isModeCost, pawprints, maxAffordableSelections]); const handleCancel = useCallback(() => { dispatch({ type: "CancelCast" }); @@ -100,11 +120,24 @@ export function ModeChoiceModal() { const canConfirm = selected.length >= modal.min_choices && - (isBudget ? spent <= budget : selected.length <= modal.max_choices); + (isBudget + ? spent <= budget + : isModeCost + ? selected.length <= maxAffordableSelections + : selected.length <= modal.max_choices); // A pawprint budget modal (budget 5, min weight 1) is never a single-choice // dispatch; only a genuine 1-of-1 count modal collapses to immediate dispatch. - const isSingleChoice = !isBudget && modal.min_choices === 1 && modal.max_choices === 1; - const canAdd = (index: number) => !isBudget || spent + (pawprints[index] ?? 0) <= budget; + const isSingleChoice = + !isBudget && !isModeCost && modal.min_choices === 1 && modal.max_choices === 1; + const canAdd = (index: number) => { + if (isBudget) { + return spent + (pawprints[index] ?? 0) <= budget; + } + if (isModeCost) { + return selected.length < maxAffordableSelections; + } + return selected.length < modal.max_choices; + }; // CR 602.2b vs CR 603.3c: only an ACTIVATED modal ability is cancellable at the // mode-choice step; a triggered one must resolve its mode. Read engine-provided @@ -133,7 +166,12 @@ export function ModeChoiceModal() { > {isBudget ? t("modeChoice.confirmBudget", { spent, budget }) - : t("modeChoice.confirm", { selected: selected.length, count: modal.max_choices })} + : isModeCost + ? t("modeChoice.confirmModeCost", { + selected: selected.length, + max: maxAffordableSelections, + }) + : t("modeChoice.confirm", { selected: selected.length, count: modal.max_choices })} )} {!isSingleChoice && selected.length > 0 && ( @@ -159,7 +197,14 @@ export function ModeChoiceModal() { )} + {isModeCost && modeCosts[index] && ( + + + + )} {desc} {isUnavailable && ( {t("modeChoice.alreadyChosen")} diff --git a/client/src/components/modal/__tests__/ModeChoiceModal.test.tsx b/client/src/components/modal/__tests__/ModeChoiceModal.test.tsx index 320b30ebd3..f607dab7ce 100644 --- a/client/src/components/modal/__tests__/ModeChoiceModal.test.tsx +++ b/client/src/components/modal/__tests__/ModeChoiceModal.test.tsx @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ModalChoice, WaitingFor } from "../../../adapter/types.ts"; import { useGameStore } from "../../../stores/gameStore.ts"; -import { buildGameState, buildPendingCast } from "../../../test/factories/gameStateFactory.ts"; +import { buildGameState, buildPendingCast, buildPlayers } from "../../../test/factories/gameStateFactory.ts"; import { ModeChoiceModal } from "../ModeChoiceModal.tsx"; const dispatchMock = vi.fn(); @@ -97,4 +97,112 @@ describe("ModeChoiceModal", () => { fireEvent.click(cancel); expect(dispatchMock).toHaveBeenCalledWith({ type: "CancelCast" }); }); + + it("caps per-mode payment selection using engine max_affordable_selections, not pool size", () => { + const modal: ModalChoice = { + min_choices: 0, + max_choices: 3, + max_affordable_selections: 1, + mode_count: 3, + mode_descriptions: ["Mode A", "Mode B", "Mode C"], + allow_repeat_modes: false, + mode_costs: [ + { type: "Cost", generic: 1, shards: ["R"] }, + { type: "Cost", generic: 1, shards: ["R"] }, + { type: "Cost", generic: 1, shards: ["R"] }, + ], + }; + const gameState = buildGameState({ + objects: {}, + priority_player: 0, + players: buildPlayers([ + { + id: 0, + mana_pool: { + mana: [ + { color: "Red", source_id: 1, pip_id: 1, snow: false, restrictions: [] }, + { color: "Red", source_id: 2, pip_id: 2, snow: false, restrictions: [] }, + { color: "Red", source_id: 3, pip_id: 3, snow: false, restrictions: [] }, + ], + }, + }, + 1, + ]), + waiting_for: { + type: "AbilityModeChoice", + data: { + player: 0, + modal, + source_id: 90, + mode_abilities: [], + is_activated: false, + }, + }, + }); + + useGameStore.setState({ + gameState, + waitingFor: gameState.waiting_for, + dispatch: dispatchMock, + }); + + render(); + + fireEvent.click(screen.getByText("Mode A")); + fireEvent.click(screen.getByText("Mode B")); + expect(screen.getByRole("button", { name: "Confirm (1/1 modes)" })).not.toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Confirm (1/1 modes)" })); + expect(dispatchMock).toHaveBeenCalledWith({ + type: "SelectModes", + data: { indices: [0] }, + }); + }); + + it("caps repeatable mode-cost selection at modal.max_choices", () => { + const modal: ModalChoice = { + min_choices: 0, + max_choices: 2, + max_affordable_selections: 3, + mode_count: 2, + mode_descriptions: ["Mode A", "Mode B"], + allow_repeat_modes: true, + mode_costs: [ + { type: "Cost", generic: 1, shards: [] }, + { type: "Cost", generic: 1, shards: [] }, + ], + }; + const gameState = buildGameState({ + objects: {}, + priority_player: 0, + players: buildPlayers([1, 1]), + waiting_for: { + type: "AbilityModeChoice", + data: { + player: 0, + modal, + source_id: 90, + mode_abilities: [], + is_activated: false, + }, + }, + }); + + useGameStore.setState({ + gameState, + waitingFor: gameState.waiting_for, + dispatch: dispatchMock, + }); + + render(); + + fireEvent.click(screen.getByText("Mode A")); + fireEvent.click(screen.getByText("Mode A")); + fireEvent.click(screen.getByText("Mode B")); + expect(screen.getByRole("button", { name: "Confirm (2/2 modes)" })).not.toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Confirm (2/2 modes)" })); + expect(dispatchMock).toHaveBeenCalledWith({ + type: "SelectModes", + data: { indices: [0, 0] }, + }); + }); }); diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index fb7f3e5988..ff65f578dd 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -1948,10 +1948,12 @@ "eyebrowAbility": "Fähigkeitsmodi", "eyebrowSpell": "Zauberspruchmodi", "subtitle": "Wähle den oder die anzuwendenden Modi.", + "subtitleModeCost": "Gewählte Modi: {{selected}} / {{max}} bezahlbar.", "chooseExact": "Wähle {{count}}", "chooseRange": "Wähle {{min}} bis {{max}}", "confirm": "Bestätigen ({{selected}}/{{count}})", "confirmBudget": "Bestätigen ({{spent}}/{{budget}} {P})", + "confirmModeCost": "Bestätigen ({{selected}}/{{max}} Modi)", "clear": "Leeren", "alreadyChosen": "(bereits gewählt)" }, diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index 2276c376b6..6054647b76 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -1985,10 +1985,12 @@ "eyebrowAbility": "Ability Modes", "eyebrowSpell": "Spell Modes", "subtitle": "Select the mode or modes to apply.", + "subtitleModeCost": "Selected modes: {{selected}} / {{max}} affordable.", "chooseExact": "Choose {{count}}", "chooseRange": "Choose {{min}} to {{max}}", "confirm": "Confirm ({{selected}}/{{count}})", "confirmBudget": "Confirm ({{spent}}/{{budget}} {P})", + "confirmModeCost": "Confirm ({{selected}}/{{max}} modes)", "clear": "Clear", "alreadyChosen": "(already chosen)" }, diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index 3d15c692c8..c23d5554b8 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -1948,10 +1948,12 @@ "eyebrowAbility": "Modos de habilidad", "eyebrowSpell": "Modos de hechizo", "subtitle": "Selecciona el modo o los modos a aplicar.", + "subtitleModeCost": "Modos seleccionados: {{selected}} / {{max}} asequibles.", "chooseExact": "Elige {{count}}", "chooseRange": "Elige de {{min}} a {{max}}", "confirm": "Confirmar ({{selected}}/{{count}})", "confirmBudget": "Confirmar ({{spent}}/{{budget}} {P})", + "confirmModeCost": "Confirmar ({{selected}}/{{max}} modos)", "clear": "Limpiar", "alreadyChosen": "(ya elegido)" }, diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index 4435e8d67d..ee146a5ec8 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -1948,10 +1948,12 @@ "eyebrowAbility": "Modes de capacité", "eyebrowSpell": "Modes de sort", "subtitle": "Sélectionnez le ou les modes à appliquer.", + "subtitleModeCost": "Modes sélectionnés : {{selected}} / {{max}} abordables.", "chooseExact": "Choisissez {{count}}", "chooseRange": "Choisissez de {{min}} à {{max}}", "confirm": "Confirmer ({{selected}}/{{count}})", "confirmBudget": "Confirmer ({{spent}}/{{budget}} {P})", + "confirmModeCost": "Confirmer ({{selected}}/{{max}} modes)", "clear": "Effacer", "alreadyChosen": "(déjà choisi)" }, diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index 30a3c768c1..c158c8729e 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -1948,10 +1948,12 @@ "eyebrowAbility": "Modalità abilità", "eyebrowSpell": "Modalità magia", "subtitle": "Seleziona la modalità o le modalità da applicare.", + "subtitleModeCost": "Modalità selezionate: {{selected}} / {{max}} pagabili.", "chooseExact": "Scegli {{count}}", "chooseRange": "Scegli da {{min}} a {{max}}", "confirm": "Conferma ({{selected}}/{{count}})", "confirmBudget": "Conferma ({{spent}}/{{budget}} {P})", + "confirmModeCost": "Conferma ({{selected}}/{{max}} modalità)", "clear": "Cancella", "alreadyChosen": "(già scelta)" }, diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index c7acb281b3..2078599d1f 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -1948,10 +1948,12 @@ "eyebrowAbility": "Tryby zdolności", "eyebrowSpell": "Tryby czaru", "subtitle": "Wybierz tryb lub tryby do zastosowania.", + "subtitleModeCost": "Wybrane tryby: {{selected}} / {{max}} do opłacenia.", "chooseExact": "Wybierz {{count}}", "chooseRange": "Wybierz od {{min}} do {{max}}", "confirm": "Potwierdź ({{selected}}/{{count}})", "confirmBudget": "Potwierdź ({{spent}}/{{budget}} {P})", + "confirmModeCost": "Potwierdź ({{selected}}/{{max}} trybów)", "clear": "Wyczyść", "alreadyChosen": "(już wybrane)" }, diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index be12235a48..6355bbcc6c 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -1948,10 +1948,12 @@ "eyebrowAbility": "Modos da Habilidade", "eyebrowSpell": "Modos da Mágica", "subtitle": "Selecione o modo ou modos a aplicar.", + "subtitleModeCost": "Modos selecionados: {{selected}} / {{max}} pagáveis.", "chooseExact": "Escolha {{count}}", "chooseRange": "Escolha de {{min}} a {{max}}", "confirm": "Confirmar ({{selected}}/{{count}})", "confirmBudget": "Confirmar ({{spent}}/{{budget}} {P})", + "confirmModeCost": "Confirmar ({{selected}}/{{max}} modos)", "clear": "Limpar", "alreadyChosen": "(já escolhido)" }, diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index 3639b9256d..f88d83710f 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -1759,7 +1759,7 @@ pub fn candidate_actions_broad_with_probe( } else if modal.mode_costs.is_empty() { actions } else { - // CR 702.172b: For Spree spells, filter out mode combinations the player + // CR 702.172a: For Spree spells, filter out mode combinations the player // cannot afford. Each mode has an additional cost that sums with the base cost. let local_probe = casting::PriorityCastProbe::new(state, *player); actions @@ -1798,6 +1798,7 @@ pub fn candidate_actions_broad_with_probe( modal, unavailable_modes, is_activated, + source_id, .. } => { let available: Vec = (0..modal.mode_count) @@ -1861,6 +1862,33 @@ pub fn candidate_actions_broad_with_probe( Some(*player), )); } + // CR 702.172a: Batched repeated-optional modals (Hawkeye) and Spree- + // shaped ability modals carry per-mode mana costs. Prune combinations + // the player cannot afford; the engine already capped max_choices. + if modal.mode_pawprints.is_empty() && !modal.mode_costs.is_empty() { + let local_probe = casting::PriorityCastProbe::new(state, *player); + actions.retain(|ca| match &ca.action { + GameAction::SelectModes { indices } => { + let total = indices.iter().fold( + crate::types::mana::ManaCost::zero(), + |acc, &idx| { + crate::game::restrictions::add_mana_cost( + &acc, + &modal.mode_costs[idx], + ) + }, + ); + casting::can_pay_cost_after_auto_tap_with_probe( + local_probe.state(), + *player, + *source_id, + &total, + Some(&local_probe), + ) + } + _ => true, + }); + } actions } WaitingFor::ConniveDiscard { diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index 4db0b41672..015af4ed1e 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -3929,6 +3929,7 @@ fn rw_modal_choice(m: &ModalChoice) -> RwProfile { allow_repeat_modes: _, constraints: _, mode_costs: _, + max_affordable_selections: _, mode_pawprints: _, entwine_cost: _, selection: _, diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index deb416008d..ce03d20a36 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -371,6 +371,7 @@ fn scan_modal_choice(m: &ModalChoice, mode: ScanMode) -> Axes { allow_repeat_modes: _, constraints: _, // cast-time modal-cap predicates (announcement-time, not resolution) mode_costs: _, + max_affordable_selections: _, mode_pawprints: _, entwine_cost: _, selection: _, diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index e4f48ea7be..e460ef4aa2 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -5787,15 +5787,15 @@ fn is_synchronous_mana_pay_cost(effect: &Effect) -> bool { ) } -/// CR 603.12a: Fire the first per-iteration payment prompt for a -/// repeated-optional-payment process and stash the continuation. Each -/// subsequent prompt + the once-after-loop reflexive are driven by -/// `resolve_repeated_optional_payment_choice` as the player answers each -/// `DecideOptionalEffect`. Returns having set `WaitingFor::OptionalEffectChoice` -/// (or, for an "up to 0 times" bound, a no-op resolution with K = 0). +/// CR 603.12a: Fire the repeated-optional-payment process and stash the +/// continuation. Modal reflexives (Hawkeye, Tranquil Frillback) batch payment +/// and mode choice into one `AbilityModeChoice` (CR 702.172a Spree-shaped +/// `mode_costs`). Non-modal reflexives keep the legacy per-iteration +/// `OptionalEffectChoice` driver. fn drive_repeated_optional_payment( state: &mut GameState, ability: &ResolvedAbility, + events: &mut Vec, ) -> Result<(), EffectError> { // CR 700.2d: the "up to N" payment budget. let n = match &ability.repeat_for { @@ -5810,6 +5810,151 @@ fn drive_repeated_optional_payment( // never triggers and the modal is never offered. return Ok(()); } + + if repeated_optional_reflexive_is_modal(reflexive) { + return drive_batched_repeated_optional_modal_payment(state, ability, reflexive, n, events); + } + + drive_sequential_repeated_optional_payment(state, ability, reflexive, n) +} + +fn repeated_optional_reflexive_is_modal(reflexive: &ResolvedAbility) -> bool { + reflexive.modal.is_some() && !reflexive.mode_abilities.is_empty() +} + +/// CR 702.172a + CR 603.12a: Batch "pay up to N × {cost}" with "choose up to +/// that many modes" into a single `AbilityModeChoice`. Affordability is probed +/// once at prompt time; `mode_costs` carries the per-mode payment unit for +/// display and `max_affordable_selections` stamps the engine-authoritative cap. +fn drive_batched_repeated_optional_modal_payment( + state: &mut GameState, + ability: &ResolvedAbility, + reflexive: &ResolvedAbility, + n: i32, + events: &mut Vec, +) -> Result<(), EffectError> { + let Effect::PayCost { + cost: AbilityCost::Mana { cost: payment_mana }, + .. + } = &ability.effect + else { + return Err(EffectError::InvalidParam( + "batched repeated optional payment requires a synchronous mana PayCost".to_string(), + )); + }; + + let player = ability.controller; + let source_id = ability.source_id; + let n_u32 = u32::try_from(n).unwrap_or(0); + let max_affordable = + max_affordable_mana_payments(state, player, source_id, payment_mana, n_u32); + let modal_template = reflexive + .modal + .as_ref() + .expect("modal reflexive checked by caller"); + let mode_count = modal_template.mode_count; + let max_choices = (n as usize).min(max_affordable as usize).min(mode_count); + + let mut payment_unit = ability.clone(); + payment_unit.repeat_for = None; + payment_unit.sub_ability = None; + payment_unit.optional = false; + payment_unit.condition = None; + + let mut modal = modal_template.clone(); + modal.mode_costs = vec![payment_mana.clone(); mode_count]; + modal.min_choices = 0; + modal.max_choices = max_choices; + modal.max_affordable_selections = Some(max_affordable.min(max_choices as u32)); + modal.dynamic_max_choices = None; + + let mut reflexive_prepared = reflexive.clone(); + apply_parent_chain_context(&mut reflexive_prepared, ability, None, state); + reflexive_prepared.modal = Some(modal.clone()); + + let trigger_description = reflexive_prepared + .description + .clone() + .or_else(|| ability.description.clone()); + let pending = crate::game::triggers::PendingTrigger { + source_id, + controller: player, + condition: None, + ability: Box::new(reflexive_prepared), + timestamp: state.turn_number, + target_constraints: reflexive.target_constraints.clone(), + distribute: None, + trigger_event: state.current_trigger_event.clone(), + modal: Some(modal), + mode_abilities: reflexive.mode_abilities.clone(), + description: trigger_description, + may_trigger_origin: None, + subject_match_count: freeze_reflexive_event_count(state, player, source_id), + die_result: state.die_result_this_resolution, + }; + let trigger_events = crate::game::triggers::take_pending_trigger_event_batch(state, &pending); + let pending_for_state = pending.clone(); + let entry_id = crate::game::triggers::push_pending_trigger_to_stack_with_event_batch( + state, + pending, + trigger_events, + events, + ); + state.pending_trigger = Some(Box::new(pending_for_state)); + state.pending_trigger_entry = Some(entry_id); + + state.push_repeated_optional_payment_frame(RepeatedOptionalPaymentFrame { + pending: Some(Box::new(PendingRepeatedOptionalPayment { + payment_unit: Box::new(payment_unit), + reflexive: Box::new(reflexive.clone()), + remaining: 0, + batched: true, + })), + optional_cost_payments_this_resolution: 0, + }); + + match crate::game::engine::begin_pending_trigger_target_selection(state) + .map_err(|e| EffectError::InvalidParam(e.to_string()))? + { + Some(waiting_for) => { + state.waiting_for = waiting_for; + Ok(()) + } + None => { + state + .take_active_repeated_optional_payment_frame() + .map_err(|error| EffectError::InvalidParam(error.to_string()))?; + Ok(()) + } + } +} + +/// CR 118.1: How many times `unit` can be paid from the current board/pool, +/// capped at `max_n`. Probed synchronously via auto-tap planning. +fn max_affordable_mana_payments( + state: &GameState, + player: PlayerId, + source_id: ObjectId, + unit: &ManaCost, + max_n: u32, +) -> u32 { + (0..=max_n) + .rev() + .find(|×| { + let total = pay::scale_mana_cost(unit, times); + crate::game::casting::can_pay_cost_after_auto_tap(state, player, source_id, &total) + }) + .unwrap_or(0) +} + +/// CR 603.12a: Legacy per-iteration `OptionalEffectChoice` driver for +/// repeated-optional processes whose reflexive is not a mode-choice modal. +fn drive_sequential_repeated_optional_payment( + state: &mut GameState, + ability: &ResolvedAbility, + reflexive: &ResolvedAbility, + n: i32, +) -> Result<(), EffectError> { // The PayCost-only unit prompted/paid each iteration: clear `repeat_for` and // `sub_ability` so resolving it neither re-enters this driver nor // re-resolves the reflexive, and clear `optional` (the prompt itself is the @@ -5826,8 +5971,9 @@ fn drive_repeated_optional_payment( state.push_repeated_optional_payment_frame(RepeatedOptionalPaymentFrame { pending: Some(Box::new(PendingRepeatedOptionalPayment { payment_unit: Box::new(payment_unit), - reflexive: Box::new((**reflexive).clone()), + reflexive: Box::new(reflexive.clone()), remaining: (n - 1) as u32, + batched: false, })), optional_cost_payments_this_resolution: 0, }); @@ -5840,6 +5986,85 @@ fn drive_repeated_optional_payment( Ok(()) } +/// CR 603.12a + CR 702.172a: Settle a batched repeated-optional payment when the +/// player submits `SelectModes`. Empty indices decline without paying; non-empty +/// indices pay `len × unit` via the single PayCost authority, then mode +/// resolution proceeds with K = len. +/// +/// `pending` is only cleared after a successful payment (or on empty decline). +/// A failed payment leaves the frame intact so the prompt remains retryable. +pub(super) fn settle_batched_repeated_optional_payment_on_mode_choice( + state: &mut GameState, + indices: &[usize], + events: &mut Vec, +) -> Result { + let is_batched = state + .active_repeated_optional_payment_frame() + .and_then(|frame| frame.pending.as_ref()) + .is_some_and(|pending| pending.batched); + if !is_batched { + return Ok(false); + } + + if indices.is_empty() { + let _ = state + .active_repeated_optional_payment_frame_mut() + .and_then(|frame| frame.pending.take()); + state + .take_active_repeated_optional_payment_frame() + .map_err(|error| EffectError::InvalidParam(error.to_string()))?; + crate::game::engine::drop_mid_construction_pending_trigger(state); + return Ok(true); + } + + let payment_count = indices.len(); + let mut payment = { + let frame = state + .active_repeated_optional_payment_frame() + .ok_or_else(|| { + EffectError::InvalidParam( + "batched repeated optional payment frame missing during settle".to_string(), + ) + })?; + let pending = frame.pending.as_ref().ok_or_else(|| { + EffectError::InvalidParam( + "batched repeated optional payment pending missing during settle".to_string(), + ) + })?; + (*pending.payment_unit).clone() + }; + if let Effect::PayCost { scale, .. } = &mut payment.effect { + *scale = Some(QuantityExpr::Fixed { + value: i32::try_from(payment_count).unwrap_or(i32::MAX), + }); + } else { + return Err(EffectError::InvalidParam( + "batched repeated optional payment unit must be PayCost".to_string(), + )); + } + + state.cost_payment_failed_flag = false; + resolve_ability_chain(state, &payment, events, 1)?; + if state.cost_payment_failed_flag { + // Leave `pending` in place — AbilityModeChoice stays live for retry/decline. + return Err(EffectError::InvalidParam( + "Cannot pay batched repeated optional cost".to_string(), + )); + } + + let frame = state + .active_repeated_optional_payment_frame_mut() + .ok_or_else(|| { + EffectError::InvalidParam( + "repeated optional-payment frame must remain active during batched payment" + .to_string(), + ) + })?; + frame.optional_cost_payments_this_resolution = u32::try_from(payment_count).unwrap_or(u32::MAX); + frame.pending = None; + Ok(true) +} + /// CR 603.12a + CR 608.2c: Resume a repeated-optional-payment process for one /// `DecideOptionalEffect`. On accept, pay the per-iteration cost (resolution- /// time mana payment is synchronous — auto-tap, never pauses) and count it @@ -5861,6 +6086,7 @@ pub(super) fn resolve_repeated_optional_payment_choice( payment_unit, reflexive, remaining, + batched: _, } = *pending; if accept { @@ -5904,6 +6130,7 @@ pub(super) fn resolve_repeated_optional_payment_choice( payment_unit, reflexive, remaining: remaining - 1, + batched: false, })), optional_cost_payments_this_resolution, }) @@ -8818,7 +9045,7 @@ fn resolve_chain_body( // generic `repeat_for` loop (which would re-resolve the reflexive per // payment). if is_repeated_optional_payment(ability) { - return drive_repeated_optional_payment(state, ability); + return drive_repeated_optional_payment(state, ability, events); } // CR 118.12 + CR 118.12a: "Effect unless [player] pays {cost}" — diff --git a/crates/engine/src/game/effects/pay.rs b/crates/engine/src/game/effects/pay.rs index 85b7c6b568..6b075021d1 100644 --- a/crates/engine/src/game/effects/pay.rs +++ b/crates/engine/src/game/effects/pay.rs @@ -210,7 +210,7 @@ pub fn resolve( /// `times` times and the generic component scaled. `times == 0` yields `{0}` /// (`Cost { shards: [], generic: 0 }`), which the existing mana-payment path /// treats as trivially paid. -fn scale_mana_cost(base: &ManaCost, times: u32) -> ManaCost { +pub(crate) fn scale_mana_cost(base: &ManaCost, times: u32) -> ManaCost { match base { ManaCost::NoCost | ManaCost::SelfManaCost diff --git a/crates/engine/src/game/engine_modes.rs b/crates/engine/src/game/engine_modes.rs index a95611c410..2efbd1a335 100644 --- a/crates/engine/src/game/engine_modes.rs +++ b/crates/engine/src/game/engine_modes.rs @@ -10,6 +10,7 @@ use super::ability_utils::{ record_modal_mode_choices, selected_mode_labels, target_constraints_from_modal, validate_modal_indices, }; +use super::effects; use super::engine::EngineError; use super::engine_stack; use super::triggers; @@ -38,6 +39,41 @@ pub(super) fn handle_ability_mode_choice( }; validate_modal_indices(&modal, &indices, &unavailable_modes)?; + + // Batched repeated-optional prompts snapshot `max_choices` and + // `max_affordable_selections` at offer time. Re-probe affordability on submit + // so a stale selection cannot take the payment path after the pool/board + // changed (CR 118.1 / CR 702.172a). + if !indices.is_empty() + && !modal.mode_costs.is_empty() + && state + .active_repeated_optional_payment_frame() + .and_then(|frame| frame.pending.as_ref()) + .is_some_and(|pending| pending.batched) + { + let total = indices.iter().fold(ManaCost::zero(), |acc, &idx| { + let mode_cost = modal + .mode_costs + .get(idx) + .cloned() + .unwrap_or_else(ManaCost::zero); + crate::game::restrictions::add_mana_cost(&acc, &mode_cost) + }); + if !casting::can_pay_cost_after_auto_tap(state, player, source_id, &total) { + return Err(EngineError::InvalidAction( + "Cannot afford selected ability modes".to_string(), + )); + } + } + + if effects::settle_batched_repeated_optional_payment_on_mode_choice(state, &indices, events) + .map_err(|e| EngineError::InvalidAction(e.to_string()))? + && indices.is_empty() + { + priority::clear_priority_passes(state); + return Ok(WaitingFor::Priority { player }); + } + record_modal_mode_choices(state, source_id, &modal, &indices); let mut resolved = diff --git a/crates/engine/src/game/marksman_tests.rs b/crates/engine/src/game/marksman_tests.rs index 26bd5b1582..54408f5711 100644 --- a/crates/engine/src/game/marksman_tests.rs +++ b/crates/engine/src/game/marksman_tests.rs @@ -5,9 +5,9 @@ //! - the parser (`parse_oracle_text`) for the modal AST (B4); //! - the production trigger-resolution function (`resolve_ability_chain` at //! depth 0, exactly as the engine resolves a stack trigger) to reach the -//! per-iteration `WaitingFor::OptionalEffectChoice`; -//! - the real action handler (`apply`) for every `DecideOptionalEffect` and -//! `SelectModes`, so the changed `WaitingFor`/`GameAction` route is exercised. +//! batched `WaitingFor::AbilityModeChoice`; +//! - the real action handler (`apply`) for `SelectModes`, so the changed +//! `WaitingFor`/`GameAction` route is exercised. #![cfg(test)] @@ -38,6 +38,12 @@ const FRILLBACK_ORACLE: &str = "When this creature enters, you may pay {G} up to • Exile target player's graveyard.\n\ • You gain 4 life."; +const TWO_MODE_REPEAT_ORACLE: &str = + "When this creature enters, you may pay {1} up to three times. \ + When you pay this cost one or more times, choose up to that many —\n\ + • Deal 1 damage to any target.\n\ + • You gain 1 life."; + const P0: PlayerId = PlayerId(0); const P1: PlayerId = PlayerId(1); @@ -145,9 +151,10 @@ fn tranquil_frillback_paying_once_and_choosing_life_gains_four() { resolve_ability_chain(runner.state_mut(), &resolved, &mut Vec::new(), 0) .expect("resolve Frillback ETB"); - decide(&mut runner, true); - decide(&mut runner, false); - assert_eq!(modal_cap(runner.state()), Some((0, 1))); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::AbilityModeChoice { .. } + )); runner .act(GameAction::SelectModes { indices: vec![2] }) .expect("select Frillback life mode"); @@ -159,7 +166,7 @@ fn tranquil_frillback_paying_once_and_choosing_life_gains_four() { /// Hawkeye on P0's battlefield with `mana` colorless mana in pool, the Taps /// trigger resolved through the production resolver (depth 0). Leaves the engine -/// at the first per-iteration `WaitingFor::OptionalEffectChoice`. +/// at a batched `WaitingFor::AbilityModeChoice`. fn hawkeye_runtime(mana: usize, p0_hand: &[&str]) -> crate::game::scenario::GameRunner { let mut scenario = GameScenario::new(); scenario.with_life(P1, 20); @@ -177,7 +184,7 @@ fn hawkeye_runtime(mana: usize, p0_hand: &[&str]) -> crate::game::scenario::Game ManaType::Colorless, crate::types::identifiers::ObjectId(9_999), false, - vec![] + vec![], ); mana ], @@ -185,10 +192,6 @@ fn hawkeye_runtime(mana: usize, p0_hand: &[&str]) -> crate::game::scenario::Game } let mut runner = scenario.build(); - // Resolve the Taps trigger exactly as the engine resolves a stack trigger: - // `resolve_ability_chain` at depth 0 (the production trigger-resolution - // path). This builds the per-iteration `OptionalEffectChoice` from the real - // parsed ability — no hand-built `WaitingFor`. let parsed = parse_hawkeye(); let taps = parsed .triggers @@ -205,10 +208,38 @@ fn hawkeye_runtime(mana: usize, p0_hand: &[&str]) -> crate::game::scenario::Game runner } -fn decide(runner: &mut crate::game::scenario::GameRunner, accept: bool) { +fn two_mode_repeat_runtime(mana: usize) -> crate::game::scenario::GameRunner { + let mut scenario = GameScenario::new(); + let source = scenario + .add_creature_from_oracle(P0, "Two Mode Repeat", 2, 2, TWO_MODE_REPEAT_ORACLE) + .id(); + if mana > 0 { + scenario.with_mana_pool( + P0, + vec![ + ManaUnit::new( + ManaType::Colorless, + crate::types::identifiers::ObjectId(9_999), + false, + vec![], + ); + mana + ], + ); + } + let mut runner = scenario.build(); + let parsed = parse_oracle_text( + TWO_MODE_REPEAT_ORACLE, + "Two Mode Repeat", + &[], + &["Creature".to_string()], + &[], + ); + let execute = parsed.triggers[0].execute.as_deref().expect("ETB execute"); + let resolved = build_resolved_from_def(execute, source, P0); + resolve_ability_chain(runner.state_mut(), &resolved, &mut Vec::new(), 0) + .expect("trigger resolution"); runner - .act(GameAction::DecideOptionalEffect { accept }) - .expect("DecideOptionalEffect accepted"); } fn k_count(state: &GameState) -> u32 { @@ -217,6 +248,15 @@ fn k_count(state: &GameState) -> u32 { .map_or(0, |frame| frame.optional_cost_payments_this_resolution) } +fn p0_pool_total(state: &GameState) -> usize { + state + .players + .iter() + .find(|p| p.id == P0) + .map(|p| p.mana_pool.total()) + .unwrap_or(0) +} + fn repeated_payment_driver_is_pending(state: &GameState) -> bool { state .active_repeated_optional_payment_frame() @@ -230,206 +270,260 @@ fn modal_cap(state: &GameState) -> Option<(usize, usize)> { } } -// ── B-i / B-ii / B-iii: K accounting, dynamic cap, reflexive K-gate ───── +fn max_affordable_mode_selections(state: &GameState) -> Option { + match &state.waiting_for { + WaitingFor::AbilityModeChoice { modal, .. } => modal.max_affordable_selections, + _ => None, + } +} + +fn mode_cost_count(state: &GameState) -> Option { + match &state.waiting_for { + WaitingFor::AbilityModeChoice { modal, .. } => Some(modal.mode_costs.len()), + _ => None, + } +} + +// ── Batched payment + modal cap ─────────────────────────────────────────── -/// Paying three times captures K = 3 and the reflexive is offered exactly once -/// with a modal capped at `min(K, mode_count) == 3`, `min_choices == 0`. -/// Revert discriminator: dropping the K increment in -/// `resolve_repeated_optional_payment_choice` leaves K = 0 ⇒ cap 0; dropping -/// the B4 dynamic-max wiring pins the cap at the fixed (1,1). #[test] -fn pay_three_times_caps_modal_at_three_and_offers_reflexive_once() { - let mut runner = hawkeye_runtime(3, &[]); +fn batched_modal_offered_once_with_static_cap_from_affordability() { + let runner = hawkeye_runtime(3, &[]); assert!( matches!( runner.state().waiting_for, - WaitingFor::OptionalEffectChoice { .. } + WaitingFor::AbilityModeChoice { .. } ), - "first payment prompt is offered" + "batched path emits one AbilityModeChoice, not sequential OptionalEffectChoice" + ); + assert_eq!(modal_cap(runner.state()), Some((0, 3))); + assert_eq!(max_affordable_mode_selections(runner.state()), Some(3)); + assert_eq!(mode_cost_count(runner.state()), Some(3)); + assert!( + repeated_payment_driver_is_pending(runner.state()), + "payment settles on SelectModes, not before the modal" + ); +} + +#[test] +fn afford_zero_caps_modal_at_zero_and_accepts_empty_decline() { + let mut runner = hawkeye_runtime(0, &[]); + assert_eq!(modal_cap(runner.state()), Some((0, 0))); + assert_eq!(max_affordable_mode_selections(runner.state()), Some(0)); + runner + .act(GameAction::SelectModes { indices: vec![] }) + .expect("decline with empty selection"); + assert_eq!(k_count(runner.state()), 0); + assert!( + runner + .state() + .active_repeated_optional_payment_frame() + .is_none(), + "decline clears the payment frame" ); - decide(&mut runner, true); - decide(&mut runner, true); - // Still a payment prompt before the third accept (not yet the modal). assert!(matches!( runner.state().waiting_for, - WaitingFor::OptionalEffectChoice { .. } + WaitingFor::Priority { .. } )); - decide(&mut runner, true); +} + +#[test] +fn afford_two_of_three_caps_modal_at_two() { + let runner = hawkeye_runtime(2, &[]); + assert_eq!(modal_cap(runner.state()), Some((0, 2))); + assert_eq!(max_affordable_mode_selections(runner.state()), Some(2)); +} + +#[test] +fn max_affordable_selections_is_capped_by_modal_max_choices() { + let runner = two_mode_repeat_runtime(3); + assert_eq!(modal_cap(runner.state()), Some((0, 2))); + assert_eq!( + max_affordable_mode_selections(runner.state()), + Some(2), + "affordability probe may exceed mode_count; serialized cap must not" + ); +} - assert_eq!(k_count(runner.state()), 3, "three successful payments"); +#[test] +fn over_cap_submit_with_intact_pool_rejects_without_payment() { + let mut runner = hawkeye_runtime(2, &[]); + assert_eq!(p0_pool_total(runner.state()), 2); + let err = runner.act(GameAction::SelectModes { + indices: vec![0, 1, 2], + }); + assert!( + err.is_err(), + "three modes must be rejected when only two are affordable" + ); assert_eq!( - modal_cap(runner.state()), - Some((0, 3)), - "reflexive modal offered once, capped at min(K, 3) with min 0" + p0_pool_total(runner.state()), + 2, + "no mana consumed on rejected over-cap submit" + ); + assert!( + matches!( + runner.state().waiting_for, + WaitingFor::AbilityModeChoice { .. } + ), + "batched prompt remains live" + ); + assert!( + repeated_payment_driver_is_pending(runner.state()), + "repeated-payment frame survives rejected over-cap submit" + ); +} + +#[test] +fn select_three_modes_pays_three_mana() { + let mut runner = hawkeye_runtime(3, &[]); + runner + .act(GameAction::SelectModes { + indices: vec![0, 1, 2], + }) + .expect("select three modes"); + assert_eq!( + p0_pool_total(runner.state()), + 0, + "three {{1}} payments were made in one step" ); assert!( runner .state() .active_repeated_optional_payment_frame() - .is_some_and(|frame| frame.pending.is_none()), - "the completed payment frame retains K through its reflexive modal prompt" + .is_none(), + "payment frame settles after batched mode resolution" ); - // CR 118.3a: the three {1} payments drained the mana pool. - let pool_left: usize = runner - .state() - .players - .iter() - .find(|p| p.id == P0) - .map(|p| p.mana_pool.total()) - .unwrap_or(0); - assert_eq!(pool_left, 0, "all three {{1}} payments were made"); } -/// Paying twice then declining ends the loop early at K = 2; the reflexive modal -/// is offered once capped at 2. Revert discriminator: dropping the B4 dynamic -/// cap pins it at (1,1); not stopping on decline would prompt a third payment. #[test] -fn pay_twice_then_decline_caps_modal_at_two() { +fn select_one_mode_pays_one_mana() { let mut runner = hawkeye_runtime(3, &[]); - decide(&mut runner, true); - decide(&mut runner, true); - decide(&mut runner, false); // decline the third — stop early - - assert_eq!(k_count(runner.state()), 2); - assert_eq!(modal_cap(runner.state()), Some((0, 2))); + assert_eq!(p0_pool_total(runner.state()), 3); + runner + .act(GameAction::SelectModes { indices: vec![1] }) + .expect("select Explosive only"); + assert_eq!( + p0_pool_total(runner.state()), + 2, + "issue #6175: pay only for selected modes (1 of 3)" + ); + assert!( + matches!( + runner.state().waiting_for, + WaitingFor::TriggerTargetSelection { .. } + ), + "Explosive still needs a target after the single {{1}} payment" + ); } -/// CR 603.12a: declining every payment leaves K = 0 — the reflexive NEVER fires -/// and the modal is never offered. Revert discriminator: removing the `K >= 1` -/// guard in `finish_repeated_optional_payment` resolves the reflexive at K = 0, -/// surfacing an `AbilityModeChoice` and failing this assertion. #[test] -fn decline_immediately_skips_reflexive_at_k_zero() { +fn unaffordable_submit_after_pool_drain_keeps_batched_prompt() { let mut runner = hawkeye_runtime(3, &[]); - decide(&mut runner, false); - - assert_eq!(k_count(runner.state()), 0); + for player in runner.state_mut().players.iter_mut() { + if player.id == P0 { + player.mana_pool.mana.clear(); + } + } + let err = runner.act(GameAction::SelectModes { indices: vec![0] }); assert!( - modal_cap(runner.state()).is_none(), - "no modal is offered when K == 0: {:?}", - runner.state().waiting_for + err.is_err(), + "stale SelectModes must fail when the pool can no longer pay" + ); + assert!( + matches!( + runner.state().waiting_for, + WaitingFor::AbilityModeChoice { .. } + ), + "prompt remains live after rejected submit" ); assert!( - !repeated_payment_driver_is_pending(runner.state()), - "the repeated-payment continuation is cleared" + repeated_payment_driver_is_pending(runner.state()), + "batched pending must survive failed payment / affordability reject" ); } -/// K counts only SUCCESSFUL payments, AND a failed payment ENDS the repeated -/// sequence. CR 118.3: a player can't pay a cost without the resources to pay it -/// fully. CR 603.12a: the "you may pay {1} [again]" rider is satisfied only by a -/// payment that actually happened. With funding for a single {1}, the first -/// accept pays (K=1); the second accept fails for lack of mana, so the loop -/// terminates immediately and the reflexive modal is offered exactly once, capped -/// at K=1 — NO third payment prompt is offered. -/// -/// Discriminators: (a) dropping the `if !cost_payment_failed_flag` K-guard counts -/// the failed payment, yielding K=2 and a cap of 2; (b) the pre-fix behavior of -/// offering another prompt after a failed payment (the `if remaining > 0` branch -/// running regardless of `cost_payment_failed_flag`) leaves -/// payment driver still pending with no modal offered, failing the -/// termination + cap assertions below. #[test] -fn failed_payment_ends_sequence_and_offers_reflexive_once() { - let mut runner = hawkeye_runtime(1, &[]); - decide(&mut runner, true); // pays {1} — K = 1 - decide(&mut runner, true); // accept, but no mana left — payment FAILS → loop ends +fn decline_empty_selection_skips_reflexive_at_k_zero() { + let mut runner = hawkeye_runtime(3, &[]); + runner + .act(GameAction::SelectModes { indices: vec![] }) + .expect("decline"); - assert_eq!( - k_count(runner.state()), - 1, - "only the funded payment counts toward K" - ); + assert_eq!(k_count(runner.state()), 0); assert!( - !repeated_payment_driver_is_pending(runner.state()), - "a failed payment terminates the repeated-payment sequence — no further \ - payment prompt is offered: {:?}", - runner.state().waiting_for - ); - assert_eq!( - modal_cap(runner.state()), - Some((0, 1)), - "the reflexive modal is offered exactly once, capped at K=1" + !matches!( + runner.state().waiting_for, + WaitingFor::AbilityModeChoice { .. } + ), + "no second modal after decline" ); + assert!(runner + .state() + .active_repeated_optional_payment_frame() + .is_none()); +} + +#[test] +fn afford_one_caps_modal_at_one_not_three() { + let runner = hawkeye_runtime(1, &[]); + assert_eq!(modal_cap(runner.state()), Some((0, 1))); } -// ── B-iv: a chosen mode resolves; reflexive resolves exactly once ─────── +// ── Mode resolution through apply ───────────────────────────────────────── -/// Choosing the Boomerang mode once resolves it exactly once (discard a card, -/// then draw a card — net hand size unchanged), and the resolution settles -/// without offering a second modal. Drives `SelectModes` through `apply`. -/// Revert discriminator: resolving the reflexive per payment (the old -/// `repeated_full_chain` path) would offer the modal three times. #[test] fn boomerang_mode_resolves_once_through_apply() { let mut runner = hawkeye_runtime(3, &["Mountain", "Forest"]); - decide(&mut runner, true); - decide(&mut runner, true); - decide(&mut runner, true); - - // The reflexive modal is offered once. Boomerang is mode index 2. - assert_eq!(modal_cap(runner.state()), Some((0, 3))); let hand_before = p0_hand_size(runner.state()); + assert_eq!(p0_pool_total(runner.state()), 3); runner .act(GameAction::SelectModes { indices: vec![2] }) .expect("SelectModes accepted"); - // Boomerang = discard 1, then draw 1 → net hand size unchanged, and no - // second modal is offered (reflexive resolved exactly once). + assert_eq!( + p0_pool_total(runner.state()), + 2, + "Boomerang alone costs one {{1}}" + ); assert!( !matches!( runner.state().waiting_for, WaitingFor::AbilityModeChoice { .. } ), - "no second modal after the single reflexive resolves" + "no second modal after the single batched resolve" ); assert_eq!( p0_hand_size(runner.state()), hand_before, "Boomerang discards one then draws one (net 0)" ); - assert!( - runner - .state() - .active_repeated_optional_payment_frame() - .is_none(), - "the completed payment frame is released once its reflexive resolves: {:?}", - runner.state().active_repeated_optional_payment_frame() - ); + assert!(runner + .state() + .active_repeated_optional_payment_frame() + .is_none()); assert!(matches!( runner.state().waiting_for, WaitingFor::Priority { .. } )); } -/// CR 700.2b + CR 120.3: choosing a TARGETED reflexive mode (Explosive — "Hawkeye -/// deals 2 damage to target player") resolves exactly once with correct targeting -/// after the K-payment sweep. Drives `SelectModes` then `ChooseTarget` through -/// `apply`; closes the targeted-mode coverage gap (Boomerang is the no-target -/// case). Revert discriminator: resolving the reflexive per payment (the old -/// `repeated_full_chain` path) would deal 2 damage three times (P1 → 14) and/or -/// re-offer the modal; the single post-loop reflexive deals exactly 2 (P1 → 18). #[test] fn explosive_targeted_mode_resolves_once_through_apply() { let mut runner = hawkeye_runtime(3, &[]); - decide(&mut runner, true); - decide(&mut runner, true); - decide(&mut runner, true); + assert_eq!(p1_life(runner.state()), 20); + assert_eq!(p0_pool_total(runner.state()), 3); - assert_eq!(modal_cap(runner.state()), Some((0, 3))); - assert_eq!( - p1_life(runner.state()), - 20, - "no damage before the mode resolves" - ); - - // Explosive is mode index 1 (Net = 0, Explosive = 1, Boomerang = 2). runner .act(GameAction::SelectModes { indices: vec![1] }) .expect("SelectModes accepted"); - // A targeted triggered mode surfaces a per-target prompt. + assert_eq!( + p0_pool_total(runner.state()), + 2, + "Explosive alone costs one {{1}}" + ); assert!( matches!( runner.state().waiting_for, @@ -445,90 +539,47 @@ fn explosive_targeted_mode_resolves_once_through_apply() { }) .expect("ChooseTarget accepted"); - // The reflexive Explosive ability is on the stack with its target bound; - // resolve it through the real priority/stack machinery. runner.advance_until_stack_empty(); assert_eq!( p1_life(runner.state()), 18, - "Explosive deals exactly 2 damage once (not 2×K)" - ); - assert!( - !matches!( - runner.state().waiting_for, - WaitingFor::AbilityModeChoice { .. } - ), - "no second modal after the single reflexive resolves" + "Explosive deals exactly 2 damage once" ); } -/// Fix 1 (HIGH) — CR 603.12a + CR 700.2d: at the per-iteration -/// `OptionalEffectChoice` pause, K is already nonzero and the paired -/// `RepeatedOptionalPaymentFrame` driver is `Some`. That pause spans separate -/// `apply()` calls and is a serde boundary -/// (server crash/restart via `to_persisted`/`from_persisted`, single-player -/// save/load, multiplayer host-resume). K must survive it — a roundtrip restoring -/// K = 0 collapses the reflexive modal cap below the payments actually made, -/// denying the player modes they paid for. -/// -/// Non-vacuity (MEASURED): with the field at `#[serde(skip, default)]` the -/// roundtrip restores K = 0, so resuming with a decline runs -/// `finish_repeated_optional_payment` at K = 0 → the reflexive is SKIPPED and no -/// modal is offered. Measured left/right under that revert: `restored.K` = `0` -/// vs expected `2`, and the resumed cap = `None` vs expected `Some((0, 2))`. #[test] -fn k_counter_survives_serde_roundtrip_mid_payment_loop() { +fn ability_mode_choice_with_mode_costs_survives_serde_roundtrip() { let mut runner = hawkeye_runtime(3, &[]); - decide(&mut runner, true); - decide(&mut runner, true); - // Paused at the third payment prompt: K = 2, continuation pending. - assert_eq!(k_count(runner.state()), 2); - assert!(repeated_payment_driver_is_pending(runner.state())); - assert!(matches!( - runner.state().waiting_for, - WaitingFor::OptionalEffectChoice { .. } - )); + assert_eq!(modal_cap(runner.state()), Some((0, 3))); + assert_eq!(max_affordable_mode_selections(runner.state()), Some(3)); + assert_eq!(mode_cost_count(runner.state()), Some(3)); - // Serialize across the pause (the persistence boundary) and restore. let v2 = serde_json::to_value(ResolutionStateWire::from_game_state(runner.state().clone())) - .expect("real repeated-payment prompt serializes as v2"); + .expect("batched AbilityModeChoice serializes as v2"); assert_eq!(v2["resolution_state_version"], 2); let restored: GameState = serde_json::from_value::(v2) - .expect("real repeated-payment prompt round-trips through the v2 wire") + .expect("batched AbilityModeChoice round-trips through the v2 wire") .into_game_state(); - assert_eq!( - k_count(&restored), - 2, - "K must survive the mid-payment-loop serde boundary" - ); + + assert_eq!(modal_cap(&restored), Some((0, 3))); + assert_eq!(max_affordable_mode_selections(&restored), Some(3)); + assert_eq!(mode_cost_count(&restored), Some(3)); assert!( repeated_payment_driver_is_pending(&restored), - "the paired continuation also survives" - ); - // K is eq-INCLUDED: a state differing only in K is no longer equal. Revert - // discriminator: removing K from `PartialEq` makes these compare equal, so an - // AI-search dedup or save-equality check would treat two different payment - // counts as identical. - let mut k_perturbed = restored.clone(); - k_perturbed - .active_repeated_optional_payment_frame_mut() - .expect("repeated-payment frame remains active at its prompt") - .optional_cost_payments_this_resolution = 99; - assert_ne!( - k_perturbed, restored, - "K participates in PartialEq (states differing only in K are unequal)" + "batched pending payment survives serde" ); - // Resume from the RESTORED state and decline the third payment. The reflexive - // modal cap must reflect the two payments already made (CR 700.2d), proving K - // survived: a lost K would skip the reflexive entirely. *runner.state_mut() = restored; - decide(&mut runner, false); + runner + .act(GameAction::SelectModes { + indices: vec![0, 1], + }) + .expect("resume after roundtrip"); assert_eq!( - modal_cap(runner.state()), - Some((0, 2)), - "resumed reflexive cap = min(K = 2, mode_count = 3)" + p0_pool_total(runner.state()), + 1, + "two {{1}} payments survived serde resume" ); } diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 44d1f58fd9..d9ce3a24b4 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -31222,6 +31222,7 @@ pub mod tests { allow_repeat_modes: false, constraints: vec![], mode_costs: vec![], + max_affordable_selections: None, mode_pawprints: vec![], entwine_cost: None, chooser: PlayerFilter::Controller, diff --git a/crates/engine/src/game/triggers_push_first_contract_tests.rs b/crates/engine/src/game/triggers_push_first_contract_tests.rs index 60f94276d3..dd3e432c35 100644 --- a/crates/engine/src/game/triggers_push_first_contract_tests.rs +++ b/crates/engine/src/game/triggers_push_first_contract_tests.rs @@ -381,6 +381,7 @@ fn push_first_no_legal_modes_modal_trigger_dropped_silently() { allow_repeat_modes: false, constraints: vec![], mode_costs: vec![], + max_affordable_selections: None, mode_pawprints: vec![], entwine_cost: None, chooser: PlayerFilter::Controller, @@ -495,6 +496,7 @@ fn random_modal_trigger_resolves_without_prompting() { allow_repeat_modes: false, constraints: vec![], mode_costs: vec![], + max_affordable_selections: None, mode_pawprints: vec![], entwine_cost: None, chooser: PlayerFilter::Controller, diff --git a/crates/engine/src/parser/oracle_modal.rs b/crates/engine/src/parser/oracle_modal.rs index 6a7b747287..c354dbc95e 100644 --- a/crates/engine/src/parser/oracle_modal.rs +++ b/crates/engine/src/parser/oracle_modal.rs @@ -31,6 +31,7 @@ use super::oracle_util::{parse_mana_symbols, strip_reminder_text, TextPair}; use crate::parser::oracle_ir::ast::{ parsed_clause, ModalHeaderAst, ModalOptionality, ModeAst, OracleBlockAst, }; +use crate::types::mana::ManaCost; pub(crate) fn parse_oracle_block(lines: &[&str], start: usize) -> Option<(OracleBlockAst, usize)> { let line = strip_reminder_text(lines.get(start)?.trim()); @@ -1379,6 +1380,20 @@ fn header_is_opponent_chooser_with_additional_cost( has_mode_cost || has_additional_cost_constraint } +/// CR 700.2 / CR 702.172a: when any mode carries a per-mode cost, preserve a +/// positional `mode_costs` entry for every mode (zero cost where omitted) so +/// consumers indexing by mode index stay aligned. +fn build_mode_costs(modes: &[ModeAst]) -> Vec { + if modes.iter().any(|m| m.mode_cost.is_some()) { + modes + .iter() + .map(|m| m.mode_cost.clone().unwrap_or_else(ManaCost::zero)) + .collect() + } else { + Vec::new() + } +} + fn build_modal_choice(header: &ModalHeaderAst, modes: &[ModeAst]) -> ModalChoice { let mode_count = modes.len(); let mode_pawprints: Vec = modes.iter().filter_map(|m| m.mode_pawprint).collect(); @@ -1401,7 +1416,8 @@ fn build_modal_choice(header: &ModalHeaderAst, modes: &[ModeAst]) -> ModalChoice mode_count, !mode_pawprints.is_empty(), ), - mode_costs: modes.iter().filter_map(|m| m.mode_cost.clone()).collect(), + mode_costs: build_mode_costs(modes), + max_affordable_selections: None, mode_pawprints, entwine_cost: None, // CR 700.2e: the player who chooses the mode(s). @@ -2581,6 +2597,58 @@ mod tests { assert!(modes.iter().all(|m| m.mode_cost.is_none())); } + #[test] + fn build_modal_choice_preserves_positional_mode_costs_with_zero_for_costless_modes() { + let modes = vec![ + ModeAst { + raw: "Draw a card.".to_string(), + label: None, + body: "Draw a card.".to_string(), + mode_cost: None, + mode_pawprint: None, + }, + ModeAst { + raw: "Gain 3 life.".to_string(), + label: None, + body: "Gain 3 life.".to_string(), + mode_cost: Some(ManaCost::generic(1)), + mode_pawprint: None, + }, + ModeAst { + raw: "Deal 3 damage.".to_string(), + label: None, + body: "Deal 3 damage.".to_string(), + mode_cost: Some(ManaCost::generic(2)), + mode_pawprint: None, + }, + ]; + let header = ModalHeaderAst { + raw: "Choose one or more —".to_string(), + min_choices: 1, + max_choices: 3, + allow_repeat_modes: false, + constraints: vec![], + chooser: PlayerFilter::Controller, + selection: TargetSelectionMode::Chosen, + dynamic_max_choices: None, + optionality: ModalOptionality::Mandatory, + }; + let modal = build_modal_choice(&header, &modes); + assert_eq!(modal.mode_costs.len(), modal.mode_count); + assert_eq!(modal.mode_costs[0], ManaCost::zero()); + assert_eq!(modal.mode_costs[1], ManaCost::generic(1)); + assert_eq!(modal.mode_costs[2], ManaCost::generic(2)); + } + + #[test] + fn build_modal_choice_leaves_mode_costs_empty_when_no_mode_has_cost() { + let lines = vec!["Choose one —", "• Draw a card.", "• Gain 3 life."]; + let modes = collect_mode_asts(&lines, 1); + let header = parse_modal_header_ast(lines[0]).expect("header should parse"); + let modal = build_modal_choice(&header, &modes); + assert!(modal.mode_costs.is_empty()); + } + #[test] fn build_modal_choice_leaves_pawprint_budget_unclamped() { // CR 700.2i: `max_choices` is the 5-point budget, NOT clamped to the diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index aaf1b5b3f1..88c157030a 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -16978,9 +16978,16 @@ pub struct ModalChoice { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub constraints: Vec, /// Per-mode additional mana costs (Spree). Empty for standard modal spells. - /// CR 702.172b: Chosen mode costs are additional costs, not part of the base mana cost. + /// CR 702.172a: Chosen mode costs are additional costs, not part of the base mana cost. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub mode_costs: Vec, + /// CR 118.1 + CR 702.172a: When `mode_costs` is non-empty, the engine probes + /// how many mode selections the controller can pay for with current mana + /// sources (including feasible auto-tap). The UI must cap interactive + /// selection to this value and must not infer affordability from mana-pool + /// entry count or pip arithmetic. Omitted when `mode_costs` is empty. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_affordable_selections: Option, /// CR 700.2i: Per-mode pawprint weight for points-budget modals ("up to N {P} /// worth of modes"). Empty for all non-pawprint modals. When non-empty, the /// modal is a pawprint budget modal and `max_choices` is reinterpreted as the diff --git a/crates/engine/src/types/resolution.rs b/crates/engine/src/types/resolution.rs index bc251b4e25..dfb57122dc 100644 --- a/crates/engine/src/types/resolution.rs +++ b/crates/engine/src/types/resolution.rs @@ -49,6 +49,11 @@ pub struct PendingRepeatedOptionalPayment { pub payment_unit: Box, pub reflexive: Box, pub remaining: u32, + /// CR 603.12a + CR 702.172a: When true, payment and mode choice are batched + /// into one `AbilityModeChoice` (Hawkeye / Tranquil Frillback class). When + /// false, the legacy per-iteration `OptionalEffectChoice` driver applies. + #[serde(default)] + pub batched: bool, } /// The complete parked repeated optional-payment authority. @@ -290,6 +295,10 @@ impl ResolutionFrame { /// the concrete `WaitingFor` variant by the structural API. pub const fn gate(&self) -> FrameGate { match self { + Self::RepeatedOptionalPayment(RepeatedOptionalPaymentFrame { + pending: Some(pending), + .. + }) if pending.batched => FrameGate::DirectChoice(DirectChoiceGate::AbilityModeChoice), Self::RepeatedOptionalPayment(RepeatedOptionalPaymentFrame { pending: Some(_), .. @@ -336,6 +345,7 @@ pub enum FrameGate { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum DirectChoiceGate { OptionalEffect, + AbilityModeChoice, CoinFlipKeep, Proliferate, MutateMerge, @@ -349,6 +359,10 @@ impl DirectChoiceGate { Self::OptionalEffect, WaitingFor::OptionalEffectChoice { .. } ) | (Self::OptionalEffect, WaitingFor::OpponentMayChoice { .. }) + | ( + Self::AbilityModeChoice, + WaitingFor::AbilityModeChoice { .. } + ) | (Self::CoinFlipKeep, WaitingFor::CoinFlipKeepChoice { .. }) | (Self::Proliferate, WaitingFor::ProliferateChoice { .. }) | (Self::MutateMerge, WaitingFor::MutateMergeChoice { .. }) @@ -4505,6 +4519,7 @@ mod tests { payment_unit: Box::new(resolved_draw(100)), reflexive: Box::new(resolved_draw(101)), remaining: 0, + batched: false, })), optional_cost_payments_this_resolution: 0, }, diff --git a/crates/mtgish-import/src/convert/mod.rs b/crates/mtgish-import/src/convert/mod.rs index 77ff18a5e3..3c27f76e72 100644 --- a/crates/mtgish-import/src/convert/mod.rs +++ b/crates/mtgish-import/src/convert/mod.rs @@ -1322,6 +1322,7 @@ pub(crate) fn build_ability_from_actions( allow_repeat_modes, constraints, mode_costs: Vec::new(), + max_affordable_selections: None, // Mechanical compile-keep-alive for the shared engine ModalChoice // field add; mtgish does not (yet) author pawprint modals. mode_pawprints: Vec::new(), diff --git a/crates/server-core/src/filter.rs b/crates/server-core/src/filter.rs index 05aac9ebd0..f58da3472a 100644 --- a/crates/server-core/src/filter.rs +++ b/crates/server-core/src/filter.rs @@ -478,6 +478,7 @@ mod tests { allow_repeat_modes: false, constraints: Vec::new(), mode_costs: Vec::new(), + max_affordable_selections: None, mode_pawprints: Vec::new(), entwine_cost: None, chooser: PlayerFilter::Controller, From 5a47163c62a20b566018824ab4b8055ffae32e64 Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:06:49 -0700 Subject: [PATCH 26/63] refactor(parser): emit priority spell router as native ir (#6799) * refactor(parser): add ordered ability root transforms * refactor(parser): emit priority spell router as native ir * fix(parser): require terminal die roll for result tables * fix(parser): retain spell description for override floor * refactor(parser): remove obsolete spell router compatibility * fix(parser): retain vote compatibility lowering * fix(parser): match pile ir test effects directly * fix(parser): route spell die tables through native ir * test(parser): accept native spell router snapshots * fix(parser): restore spell die table routing * fix(parser): preserve override floor root metadata * test(parser): retain dig override branch assertion * fix(parser): retain inline spell die result tables --------- Co-authored-by: matthewevans --- crates/engine/src/parser/oracle.rs | 160 +++-- crates/engine/src/parser/oracle_class.rs | 1 + .../src/parser/oracle_effect/conditions.rs | 28 +- crates/engine/src/parser/oracle_effect/mod.rs | 93 ++- .../src/parser/oracle_ir/effect_chain.rs | 67 ++ .../src/parser/oracle_ir/snapshot_tests.rs | 240 ++++++- ..._ir__snapshot_tests__aangs_journey_ir.snap | 630 +++++++++++------- ...__snapshot_tests__aerial_formation_ir.snap | 150 +++-- ...cle_ir__snapshot_tests__aetherling_ir.snap | 12 +- ...snapshot_tests__analyze_the_pollen_ir.snap | 469 +++++++++---- ...r__snapshot_tests__arni_brokenbrow_ir.snap | 3 +- ...ot_tests__barbarian_ring_activated_ir.snap | 6 +- ...le_ir__snapshot_tests__batterskull_ir.snap | 3 +- ..._snapshot_tests__birds_of_paradise_ir.snap | 3 +- ..._snapshot_tests__blunt_the_assault_ir.snap | 3 +- ..._ir__snapshot_tests__bomat_courier_ir.snap | 3 +- ...ir__snapshot_tests__bone_splinters_ir.snap | 103 ++- ...napshot_tests__boseiju_who_endures_ir.snap | 6 +- ...racle_ir__snapshot_tests__browbeat_ir.snap | 194 ++++-- ...acle_ir__snapshot_tests__carbonize_ir.snap | 413 ++++++++---- ...ests__case_of_the_stashed_skeleton_ir.snap | 3 +- ..._snapshot_tests__champions_victory_ir.snap | 111 ++- ...t_tests__component_pouch_activated_ir.snap | 12 +- ...ir__snapshot_tests__deadly_rollick_ir.snap | 113 +++- ...acle_ir__snapshot_tests__dismember_ir.snap | 115 +++- ...e_ir__snapshot_tests__evils_thrall_ir.snap | 442 ++++++++---- ...ir__snapshot_tests__experiment_one_ir.snap | 3 +- ..._snapshot_tests__figure_of_destiny_ir.snap | 9 +- ...er__oracle_ir__snapshot_tests__fog_ir.snap | 3 +- ...napshot_tests__follow_the_lumarets_ir.snap | 483 +++++++++++--- ..._snapshot_tests__ghost_lit_stalker_ir.snap | 6 +- ...apshot_tests__govern_the_guildless_ir.snap | 116 +++- ...snapshot_tests__guul_draz_assassin_ir.snap | 6 +- ...cle_ir__snapshot_tests__incinerate_ir.snap | 219 ++++-- ...acle_ir__snapshot_tests__jade_mage_ir.snap | 3 +- ...snapshot_tests__joraga_treespeaker_ir.snap | 3 +- ...pshot_tests__liliana_the_repentant_ir.snap | 3 +- ...ir__snapshot_tests__llanowar_elves_ir.snap | 3 +- ...e_ir__snapshot_tests__manamorphose_ir.snap | 187 ++++-- ...r__snapshot_tests__mother_of_runes_ir.snap | 3 +- ...ir__snapshot_tests__questing_beast_ir.snap | 3 +- ...r__snapshot_tests__repeat_offender_ir.snap | 3 +- ..._snapshot_tests__stoneforge_mystic_ir.snap | 3 +- ...apshot_tests__swords_to_plowshares_ir.snap | 194 ++++-- ..._snapshot_tests__sylvan_safekeeper_ir.snap | 3 +- ..._thespians_stage_generic_activated_ir.snap | 6 +- ..._snapshot_tests__touch_of_the_void_ir.snap | 241 +++++-- ..._ir__snapshot_tests__village_rites_ir.snap | 97 ++- ..._snapshot_tests__vines_of_vastwood_ir.snap | 218 ++++-- ...__snapshot_tests__walking_ballista_ir.snap | 6 +- crates/engine/src/parser/oracle_ir/trigger.rs | 9 +- .../src/parser/oracle_separate_piles.rs | 26 +- crates/engine/src/parser/oracle_special.rs | 70 +- crates/engine/src/parser/oracle_trigger.rs | 4 +- 54 files changed, 3849 insertions(+), 1464 deletions(-) diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index 6ec166a236..ba5cea9b6e 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -70,7 +70,9 @@ use super::oracle_ir::doc::{ OracleItemId, OracleItemIr, OracleNodeIr, OracleSourceSpan, OracleUnitSource, PrintedAbilityIndex, PrintedTriggerIndex, SpellPayloadIr, UnsupportedAbilityIr, }; -use super::oracle_ir::effect_chain::{AbilityIr, ShellStage}; +use super::oracle_ir::effect_chain::{ + AbilityIr, AbilityRootTransform, AbilityShellIr, ResidualConditionPolicy, ShellStage, +}; use super::oracle_ir::feature::ItemIdTracks; use super::oracle_ir::relation::{DocumentRelationIr, LinkedChoiceKind, LinkedReturnOutcome}; use super::oracle_ir::replacement::ReplacementIr; @@ -95,9 +97,9 @@ use super::oracle_replacement::{ use super::oracle_saga::{is_saga_chapter, parse_saga_chapters}; use super::oracle_spacecraft::parse_spacecraft_threshold_lines; use super::oracle_special::{ - attach_die_result_branches_to_chain, find_terminal_roll_die, normalize_self_refs_for_static, - parse_cumulative_upkeep_keyword, parse_defiler_cost_reduction, parse_die_result_branches_ir, - parse_harmonize_keyword, parse_mayhem_keyword, parse_solve_condition, try_parse_die_roll_table, + normalize_self_refs_for_static, parse_cumulative_upkeep_keyword, parse_defiler_cost_reduction, + parse_die_result_branches_ir, parse_harmonize_keyword, parse_mayhem_keyword, + parse_solve_condition, try_parse_die_roll_table, }; use super::oracle_static::{ is_speed_unlock_sentence, lower_static_ir, parse_alternative_keyword_cost, @@ -2848,6 +2850,26 @@ fn ability_word_to_ability_condition( ) } +/// CR 614.6 + CR 614.15: Preserve an unbindable self-replacement on the +/// `instead_override` honest-failure floor without eagerly lowering it. +/// +/// A separate override cannot be emitted as an independent effect: if the +/// replacement applied, its original event never happens. Until the document +/// relation can bind this particular shape, the unsupported root is the only +/// rules-honest representation. +fn apply_instead_override_residual_floor( + ability_ir: &mut AbilityIr, + effect_line: &str, + condition_policy: ResidualConditionPolicy, +) { + ability_ir + .root_transforms + .push(AbilityRootTransform::InsteadOverrideResidual { + fragment: effect_line.to_string(), + condition_policy, + }); +} + /// Single-authority merge for composing a freshly-parsed `AbilityCondition` onto an /// existing one on an `AbilityDefinition`. /// @@ -3794,8 +3816,8 @@ impl<'a> DocEmitter<'a> { /// body and stamps the result in the same step — see `OracleDocBuilder:: /// finish`'s doc block for why moving it there is order-equivalent to the /// `finish()`-time walk it replaced. - fn ability_ir_at(&mut self, line: usize, ir: AbilityIr) { - self.emit_at(line, OracleNodeIr::Spell(ir)); + fn ability_ir_at(&mut self, line: usize, ir: AbilityIr) -> OracleItemId { + self.emit_at(line, OracleNodeIr::Spell(ir)) } /// Emit the honest-failure residual for a line the parser could not model. /// @@ -5045,7 +5067,7 @@ pub(crate) fn parse_oracle_ir( // path serves both forms) to gate this ability. let aw_condition = strip_ability_word_with_name(cost_text) .and_then(|(aw_name, _)| ability_word_to_condition(&aw_name)); - let (mut ir, effect_text) = parse_activated_ability_ir( + let (mut ir, _effect_text) = parse_activated_ability_ir( cost_text, effect_text, &line, @@ -5071,17 +5093,16 @@ pub(crate) fn parse_oracle_ir( } ir.shell.min_x_value = ir.shell.min_x_value.max(min_x_value); i += 1; - // CR 706.3b: consume a following table only when the same native IR - // lowers to a terminal empty roll. Textual roll markers alone do not - // own subsequent lines. - let mut lowered_for_die_guard = lower_ability_ir(&ir); - if has_roll_die_pattern(&effect_text.to_lowercase()) - && find_terminal_roll_die(&mut lowered_for_die_guard).is_some() - { + // CR 706.3b: An immediately following valid results table belongs to + // this ability's die roll, even when later instructions remain in + // the same activated-ability chain. + if ir.has_result_table_roll_die() { let (branches, next_line) = parse_die_result_branches_ir(&lines, i, AbilityKind::Spell); - ir.die_results = branches; - i = next_line; + if !branches.is_empty() { + ir.die_results = branches; + i = next_line; + } } emitter.ability_ir_at(item_line, ir); continue; @@ -5163,9 +5184,7 @@ pub(crate) fn parse_oracle_ir( i += 1; // CR 706.3b: Preserve table rows as trigger IR until body lowering // attaches them before finalization. - if has_roll_die_pattern(&lower) { - i = attach_trigger_die_result_branches(&mut triggers, &lines, i); - } + i = attach_trigger_die_result_branches(&mut triggers, &lines, i); for __item in triggers { emitter.trigger_ir_at(item_line, TriggerNodeIr::Parsed(Box::new(__item))); } @@ -6252,18 +6271,33 @@ pub(crate) fn parse_oracle_ir( // parsing would mis-parse "Each opponent separates ..." as // Unimplemented{separate} followed by a stray Sacrifice // sub-ability with a `repeat_for` rider. - let mut def = if let Some(pile_def) = - crate::parser::oracle_separate_piles::parse_separate_into_piles( + let mut ability_ir = if let Some(pile) = + crate::parser::oracle_separate_piles::parse_separate_into_piles_ir( parse_line, AbilityKind::Spell, + &ctx, ) { - pile_def - } else if let Some(vote_def) = - crate::parser::oracle_vote::parse_vote_block(parse_line, AbilityKind::Spell) - { - vote_def + AbilityIr { + source_text: parse_line.to_string(), + body: pile.effect_chain(AbilityKind::Spell), + shell: AbilityShellIr::default(), + die_results: vec![], + root_transforms: vec![], + } + } else if let Some(vote) = crate::parser::oracle_vote::parse_vote_block_ir( + parse_line, + AbilityKind::Spell, + &ctx, + ) { + AbilityIr { + source_text: parse_line.to_string(), + body: vote.effect_chain(AbilityKind::Spell), + shell: AbilityShellIr::default(), + die_results: vec![], + root_transforms: vec![], + } } else { - parse_effect_chain_with_context(parse_line, AbilityKind::Spell, &mut ctx) + parse_ability_ir_with_context(parse_line, AbilityKind::Spell, &mut ctx) }; // CR 614.15 + CR 608.2c: a PARTIAL cross-line self-replacement whose @@ -6300,11 +6334,15 @@ pub(crate) fn parse_oracle_ir( }); let is_cross_line_dig_alt = dig_alt.is_some(); if let Some(alt) = dig_alt { - def = alt; + ability_ir = alt; } - def.min_x_value = spell_min_x_value; - def.description = Some(description); + ability_ir + .root_transforms + .push(AbilityRootTransform::SetMinXValue(spell_min_x_value)); + ability_ir + .root_transforms + .push(AbilityRootTransform::SetDescription(description.clone())); // CR 608.2c: Compose ability word condition with chain-extracted condition. // When both exist (e.g., Revolt + MV ≤ 4), compose through // `merge_ability_condition` which dedupes structurally-equal conditions @@ -6314,26 +6352,31 @@ pub(crate) fn parse_oracle_ir( // Ability-word condition (if any) is the "existing" baseline — // the chain-extracted condition is merged onto it, preserving the // historical `[ability_word, chain]` ordering when both are distinct. - let chain = def.condition.take(); - def.condition = match ( - ability_word_to_ability_condition(&aw_condition, &mut ctx), - chain, - ) { - (Some(aw), Some(chain)) => Some(merge_ability_condition(Some(aw), chain)), - (Some(aw), None) => Some(aw), - (None, chain) => chain, - }; + if let Some(ability_word_condition) = + ability_word_to_ability_condition(&aw_condition, &mut ctx) + { + ability_ir + .root_transforms + .push(AbilityRootTransform::PrependCondition( + ability_word_condition, + )); + } if let Some(instead_condition) = instead_condition { - def.condition = Some(merge_ability_condition( - def.condition.take(), - instead_condition, - )); + ability_ir + .root_transforms + .push(AbilityRootTransform::AppendCondition(instead_condition)); } i = next_i; - // CR 706: If the parsed chain ends with "roll a dN", consume - // subsequent d20 table lines and attach them as die result branches. - if has_roll_die_pattern(&lower) { - i = attach_die_result_branches_to_chain(&mut def, &lines, i); + // CR 706.3b: An immediately following valid results table belongs to + // this paragraph's die roll, even when the same ability has later + // instructions based on that result. + if ability_ir.has_result_table_roll_die() { + let (branches, next_i) = + parse_die_result_branches_ir(&lines, i, AbilityKind::Spell); + if !branches.is_empty() { + ability_ir.die_results = branches; + i = next_i; + } } // CR 608.2c + CR 614.15: Cross-line "instead" self-replacement — a // separate printed line (usually an ability word, per CR 614.15) @@ -6365,14 +6408,14 @@ pub(crate) fn parse_oracle_ir( && !scan_contains(&effect_line_lower, "would"); if is_instead || is_cross_line_dig_alt || is_instead_replacement_line(&effect_line) { - if def.condition.is_some() { + if lower_ability_ir(&ability_ir).condition.is_some() { if let Some(base) = emitter.last_ability_id() { let Some(_) = previous_spell else { unreachable!( "`spells_emitted` holds only spell nodes, and all three spell shapes lower" ); }; - let override_item = emitter.ability_at(item_line, def); + let override_item = emitter.ability_ir_at(item_line, ability_ir); document_relations.push(DocumentRelationIr::SelfReplacementOverride { base, override_item, @@ -6399,9 +6442,11 @@ pub(crate) fn parse_oracle_ir( // Fail honestly instead: the base ability stands as printed and the // unbindable override is reported as unimplemented. This mirrors the // intra-chain `InsteadLowering::ConditionUnlowerable` floor. - def.effect = Box::new(Effect::unimplemented("instead_override", &effect_line)); - def.sub_ability = None; - def.else_ability = None; + apply_instead_override_residual_floor( + &mut ability_ir, + &effect_line, + ResidualConditionPolicy::Preserve, + ); } } else if is_unbindable_self_replacement && emitter.last_ability_node().is_some() { // CR 614.6 + CR 614.15: the residual self-replacement printings — a @@ -6422,12 +6467,13 @@ pub(crate) fn parse_oracle_ir( // survives in BOTH branches. Until that exists, fail honestly: the base // ability stands exactly as printed and the override is reported // unimplemented. Never an independent ability. - def.effect = Box::new(Effect::unimplemented("instead_override", &effect_line)); - def.condition = None; - def.sub_ability = None; - def.else_ability = None; + apply_instead_override_residual_floor( + &mut ability_ir, + &effect_line, + ResidualConditionPolicy::Clear, + ); } - emitter.ability_at(item_line, def); + emitter.ability_ir_at(item_line, ability_ir); continue; } diff --git a/crates/engine/src/parser/oracle_class.rs b/crates/engine/src/parser/oracle_class.rs index 19907c57dc..0012add21f 100644 --- a/crates/engine/src/parser/oracle_class.rs +++ b/crates/engine/src/parser/oracle_class.rs @@ -165,6 +165,7 @@ pub(crate) fn parse_class_oracle_text( ..AbilityShellIr::default() }, die_results: vec![], + root_transforms: vec![], }; items.push(( *level_line, diff --git a/crates/engine/src/parser/oracle_effect/conditions.rs b/crates/engine/src/parser/oracle_effect/conditions.rs index 6775a3a3c9..39a3ef62a4 100644 --- a/crates/engine/src/parser/oracle_effect/conditions.rs +++ b/crates/engine/src/parser/oracle_effect/conditions.rs @@ -22,8 +22,11 @@ use super::super::oracle_target::{parse_target, parse_type_phrase, parse_zone_wo use super::super::oracle_util::{parse_comparison_suffix, parse_subtype, TextPair}; use super::sequence::parse_dig_from_among; use super::{parse_effect_chain, scan_contains_phrase, ParseContext}; -use crate::parser::oracle_ir::ast::ContinuationAst; +use crate::parser::oracle_ir::ast::{parsed_clause, ContinuationAst}; use crate::parser::oracle_ir::diagnostic::OracleDiagnostic; +use crate::parser::oracle_ir::effect_chain::{ + AbilityIr, AbilityRootTransform, AbilityShellIr, EffectChainIr, +}; use crate::types::ability::{ AbilityCondition, AbilityDefinition, AbilityKind, AdditionalCostOrigin, CastManaObjectScope, CastManaSpentMetric, CastVariantPaid, CoinFlipResult, Comparator, ControllerRef, CountScope, @@ -4167,8 +4170,8 @@ fn is_replacement_marker_tail(after_instead_lower: &str) -> bool { /// you may instead reveal two creature and/or land cards from among them and put /// them into your hand." /// -/// Returns a new AbilityDefinition carrying the alternative Dig plus condition; the -/// caller wraps the preceding Dig as `else_ability`. Class coverage: any card of form +/// Returns native IR carrying the alternative Dig plus condition; the caller wraps +/// the preceding Dig as `else_ability`. Class coverage: any card of form /// "look at top N / reveal a card from among them ... if , you may /// instead reveal M cards from among them" (CR 608.2c replacement effect). pub(crate) fn try_parse_dig_instead_alternative( @@ -4176,7 +4179,7 @@ pub(crate) fn try_parse_dig_instead_alternative( previous: Option<&AbilityDefinition>, kind: AbilityKind, ctx: &mut ParseContext, -) -> Option { +) -> Option { // Gate: previous effect must be a Dig that the alternative can piggy-back on. let prev = previous?; let Effect::Dig { @@ -4301,9 +4304,20 @@ pub(crate) fn try_parse_dig_instead_alternative( source: DigSource::Library, }; - let mut result = AbilityDefinition::new(kind, alt_effect); - result.condition = Some(condition); - Some(result) + Some(AbilityIr { + source_text: text.to_string(), + body: EffectChainIr::single_clause( + text, + kind, + parsed_clause(alt_effect), + None, + None, + false, + ), + shell: AbilityShellIr::default(), + die_results: vec![], + root_transforms: vec![AbilityRootTransform::AppendCondition(condition)], + }) } pub(crate) fn parse_additional_cost_instead_condition_fragment( diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 46ce49b490..6bf403efc0 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -155,9 +155,9 @@ use self::subject::{ use crate::parser::oracle_ir::ast::*; pub(crate) use crate::parser::oracle_ir::context::{ParseContext, TokenPtFollowup}; use crate::parser::oracle_ir::effect_chain::{ - AbilityIr, AbilityShellIr, AbsorbKind, ClauseDisposition, ClauseIr, ClauseIrBuilder, - DieResultBranchIr, EffectChainIr, OtherwiseKind, PlayerScopeRewrite, PriorModifier, - ReplaceMeaningKind, ReplicateKind, ShellStage, + AbilityIr, AbilityRootTransform, AbilityShellIr, AbsorbKind, ClauseDisposition, ClauseIr, + ClauseIrBuilder, DieResultBranchIr, EffectChainIr, OtherwiseKind, PlayerScopeRewrite, + PriorModifier, ReplaceMeaningKind, ReplicateKind, ResidualConditionPolicy, ShellStage, }; use crate::types::mana::ManaExpiry; @@ -26983,7 +26983,7 @@ pub(crate) enum ChainLoweringMode { /// # The order is pinned and behavior-load-bearing /// /// **chain → die results → finalize → anchor → `sub_link` → envelope stamps → -/// stages.** +/// stages → root transforms.** /// /// It is not an aesthetic choice: it reproduces what the whole-body recognizers /// in `oracle.rs` do by hand. Every one of them stamps its root fields *after* @@ -27022,21 +27022,92 @@ pub(crate) fn lower_ability_ir(ir: &AbilityIr) -> AbilityDefinition { } } } + apply_ability_root_transforms(&mut def, &ir.root_transforms); def } +/// Apply ordered whole-ability transforms after every chain and shell stage. +/// +/// CR 608.2c: this is the only point where the final root is known, so the +/// condition order is preserved without assuming any particular clause becomes +/// that root during assembly. +fn apply_ability_root_transforms(def: &mut AbilityDefinition, transforms: &[AbilityRootTransform]) { + for transform in transforms { + match transform { + AbilityRootTransform::SetMinXValue(min_x_value) => { + def.min_x_value = *min_x_value; + } + AbilityRootTransform::SetDescription(description) => { + def.description = Some(description.clone()); + } + AbilityRootTransform::InsteadOverrideResidual { + fragment, + condition_policy, + } => { + *def.effect = Effect::unimplemented("instead_override", fragment); + def.sub_ability = None; + def.else_ability = None; + match condition_policy { + ResidualConditionPolicy::Preserve => {} + ResidualConditionPolicy::Clear => def.condition = None, + } + } + AbilityRootTransform::PrependCondition(condition) => { + def.condition = match def.condition.take() { + Some(chain) => Some(crate::parser::oracle::merge_ability_condition( + Some(condition.clone()), + chain, + )), + None => Some(condition.clone()), + }; + } + AbilityRootTransform::AppendCondition(condition) => { + def.condition = Some(crate::parser::oracle::merge_ability_condition( + def.condition.take(), + condition.clone(), + )); + } + } + } +} + /// CR 706.3b: Attach typed die-result branches before finalization so every /// caller lowers each branch through the same `lower_ability_ir` authority. pub(crate) fn attach_die_result_branches_before_finalization( def: &mut AbilityDefinition, die_results: &[DieResultBranchIr], +) { + attach_die_result_branches_to_roll_before_finalization( + def, + die_results, + super::oracle_special::find_result_table_roll_die, + ); +} + +/// CR 706.3b: Trigger result tables retain their established terminal-roll +/// ownership. Trigger IR only captures tables for terminal rolls. +pub(crate) fn attach_terminal_die_result_branches_before_finalization( + def: &mut AbilityDefinition, + die_results: &[DieResultBranchIr], +) { + attach_die_result_branches_to_roll_before_finalization( + def, + die_results, + super::oracle_special::find_terminal_roll_die, + ); +} + +/// CR 706.3b: Lower typed result branches through the single ability-IR +/// authority before assigning them to their selected roll instruction. +fn attach_die_result_branches_to_roll_before_finalization( + def: &mut AbilityDefinition, + die_results: &[DieResultBranchIr], + find_roll_die: for<'a> fn(&'a mut AbilityDefinition) -> Option<&'a mut Effect>, ) { if die_results.is_empty() { return; } - if let Some(Effect::RollDie { results, .. }) = - super::oracle_special::find_terminal_roll_die(def) - { + if let Some(Effect::RollDie { results, .. }) = find_roll_die(def) { *results = die_results .iter() .map(|DieResultBranchIr { min, max, effect }| DieResultBranch { @@ -27133,6 +27204,7 @@ pub(crate) fn parse_ability_ir( body, shell: AbilityShellIr::default(), die_results: vec![], + root_transforms: vec![], }; } if let Some(body) = parse_for_each_attacker_copy_blocker_ir(text, kind, ctx) { @@ -27141,6 +27213,7 @@ pub(crate) fn parse_ability_ir( body, shell: AbilityShellIr::default(), die_results: vec![], + root_transforms: vec![], }; } if let ChainLoweringMode::WithContext = mode { @@ -27150,6 +27223,7 @@ pub(crate) fn parse_ability_ir( body, shell: AbilityShellIr::default(), die_results: vec![], + root_transforms: vec![], }; } } @@ -27164,6 +27238,7 @@ pub(crate) fn parse_ability_ir( ..AbilityShellIr::default() }, die_results: vec![], + root_transforms: vec![], }; } AbilityIr { @@ -27171,6 +27246,7 @@ pub(crate) fn parse_ability_ir( body: parse_effect_chain_ir(text, kind, ctx), shell: AbilityShellIr::default(), die_results: vec![], + root_transforms: vec![], } } @@ -29069,10 +29145,11 @@ pub(crate) fn parse_effect_chain_ir( prev_clause.map(|c| AbilityDefinition::new(kind, c.parsed.effect.clone())); let inherited_where_x_expression = prev_clause.and_then(|c| c.where_x_expression.clone()); - if let Some(alt_def) = + if let Some(alt_ir) = try_parse_dig_instead_alternative(normalized_text, prev_temp.as_ref(), kind, ctx) { if !builder.is_empty() { + let alt_def = lower_ability_ir(&alt_ir); // Store the alt_def's effect as `parsed.effect` so followup continuation // matching (e.g., PutRest for "put the rest on the bottom") can see that // the effective last clause is a Dig. In the old code, `defs.last()` was diff --git a/crates/engine/src/parser/oracle_ir/effect_chain.rs b/crates/engine/src/parser/oracle_ir/effect_chain.rs index 6835009e1e..32c8940a40 100644 --- a/crates/engine/src/parser/oracle_ir/effect_chain.rs +++ b/crates/engine/src/parser/oracle_ir/effect_chain.rs @@ -117,6 +117,33 @@ impl EffectChainIr { } } +impl AbilityIr { + /// CR 706.3b: Whether the raw body contains an unassigned die roll that can + /// own an immediately following results table. This collection gate scans + /// source-ordered direct clauses and their pre-lowered sequential + /// sub-ability chains. The P4/P9 roll producers emit ordinary clauses; + /// duplicating full `ClauseDisposition` assembly here would create a second + /// reachability authority. Post-assembly attachment remains authoritative. + pub(crate) fn has_result_table_roll_die(&self) -> bool { + self.body.clauses.iter().any(|clause| { + matches!(&clause.parsed.effect, crate::types::ability::Effect::RollDie { results, .. } if results.is_empty()) + || clause + .parsed + .sub_ability + .as_deref() + .is_some_and(ability_definition_has_result_table_roll_die) + }) + } +} + +fn ability_definition_has_result_table_roll_die(def: &AbilityDefinition) -> bool { + matches!(def.effect.as_ref(), crate::types::ability::Effect::RollDie { results, .. } if results.is_empty()) + || def + .sub_ability + .as_deref() + .is_some_and(ability_definition_has_result_table_roll_die) +} + /// Root-level `AbilityDefinition` metadata that no `ClauseIr` can express. /// /// The shell is the typed replacement for the `AbilityDefinition` escape hatch: @@ -407,6 +434,46 @@ pub(crate) struct AbilityIr { /// /// Empty is the default for every ordinary ability IR and is a lowering no-op. pub(crate) die_results: Vec, + /// Ordered root transforms applied after whole-ability lowering. + /// + /// This is intentionally separate from [`AbilityShellIr`]. The shell carries + /// the activation envelope; these transforms compose post-chain resolution + /// metadata whose order depends on the root that chain assembly selected. + /// An empty list is a lowering no-op. + pub(crate) root_transforms: Vec, +} + +/// A root-level transform applied only after an [`AbilityIr`] has been fully +/// lowered. +/// +/// CR 608.2c: chain assembly may change which parsed clause becomes the root, +/// so a whole-ability condition cannot be assigned to the first clause. These +/// transforms operate on the finalized root in their stored order. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) enum AbilityRootTransform { + /// CR 601.2b: stamp the announced-X floor from this printed ability. + SetMinXValue(u32), + /// Preserve the complete printed source text for this multi-line ability. + SetDescription(String), + /// CR 614.6 + CR 614.15: replace an unbindable self-replacement's final + /// lowered root with the explicit honest-failure floor. + InsteadOverrideResidual { + fragment: String, + condition_policy: ResidualConditionPolicy, + }, + /// CR 608.2c: prepend a condition (ability word) before the chain-derived + /// root condition. + PrependCondition(AbilityCondition), + /// CR 608.2c: append a condition extracted from a line-level `instead`. + AppendCondition(AbilityCondition), +} + +/// Whether an honest unbindable override floor retains the condition the legacy +/// parser had already lowered, or clears it for a partial replacement. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub(crate) enum ResidualConditionPolicy { + Preserve, + Clear, } /// CR 608.2c + CR 601.2c: Subject of a "does the same / does so" effect-replication diff --git a/crates/engine/src/parser/oracle_ir/snapshot_tests.rs b/crates/engine/src/parser/oracle_ir/snapshot_tests.rs index 5fc9feef72..87ebb80a4b 100644 --- a/crates/engine/src/parser/oracle_ir/snapshot_tests.rs +++ b/crates/engine/src/parser/oracle_ir/snapshot_tests.rs @@ -9,7 +9,7 @@ use crate::parser::oracle_ir::diagnostic::OracleDiagnostic; use crate::parser::oracle_ir::doc::{OracleDocIr, OracleNodeIr}; use crate::parser::oracle_ir::trigger::TriggerNodeIr; use crate::types::ability::MultiTargetSpec; -use crate::types::ability::{Effect, TriggerCondition}; +use crate::types::ability::{Effect, TargetChoiceTiming, TriggerCondition}; use crate::types::game_state::DistributionUnit; fn ability_has_unimplemented(def: &crate::types::ability::AbilityDefinition) -> bool { @@ -201,6 +201,62 @@ fn nonterminal_activated_die_roll_does_not_consume_following_ability() { )); } +/// CR 700.3 + CR 701.38: Priority 9 keeps its pile and vote roots native until +/// document lowering; their nested per-choice and chosen-pile payloads remain +/// deliberately pre-lowered effect internals. +#[test] +fn priority_nine_spell_router_keeps_vote_and_pile_roots_native() { + let (vote_ir, vote_lowered) = parse_two_layer( + "Starting with you, each player votes for evidence or bribery. For each evidence vote, investigate. For each bribery vote, create a Treasure token.", + "Vote Spell Fixture", + &["Sorcery"], + &[], + ); + assert!(matches!(vote_ir.items[0].node, OracleNodeIr::Spell(_))); + assert!(matches!( + vote_lowered.abilities[0].effect.as_ref(), + Effect::Vote { .. } + )); + + let (pile_ir, pile_lowered) = parse_two_layer( + "Reveal the top five cards of your library. An opponent separates those cards into two piles. Put one pile into your hand and the other into your graveyard.", + "Fact or Fiction", + &["Instant"], + &[], + ); + assert!(matches!(pile_ir.items[0].node, OracleNodeIr::Spell(_))); + assert!(matches!( + pile_lowered.abilities[0].effect.as_ref(), + Effect::SeparateIntoPiles { .. } + )); +} + +/// CR 601.2b: Priority 9 keeps all aggregate source text and the X floor on +/// the native node until document lowering. +#[test] +fn priority_nine_multiline_spell_keeps_description_and_x_floor_in_ir() { + let oracle_text = "Draw a card.\nThen draw a card.\nX can't be 0."; + let (ir, lowered) = parse_two_layer(oracle_text, "Multiline Spell Fixture", &["Sorcery"], &[]); + let OracleNodeIr::Spell(ability) = &ir.items[0].node else { + panic!("multiline spell must remain native IR"); + }; + assert!(ability.root_transforms.iter().any(|transform| matches!( + transform, + crate::parser::oracle_ir::effect_chain::AbilityRootTransform::SetMinXValue(1) + ))); + assert!(ability.root_transforms.iter().any(|transform| matches!( + transform, + crate::parser::oracle_ir::effect_chain::AbilityRootTransform::SetDescription(description) + if description == "Draw a card.\nThen draw a card." + ))); + assert_eq!(lowered.abilities.len(), 1); + assert_eq!(lowered.abilities[0].min_x_value, 1); + assert_eq!( + lowered.abilities[0].description.as_deref(), + Some("Draw a card.\nThen draw a card.") + ); +} + /// CR 706.3b: ordinary trigger dispatch retains a die-result table in native /// trigger IR and attaches it to the terminal roll before finalization. #[test] @@ -1512,10 +1568,192 @@ fn follow_the_lumarets() { &["Sorcery"], &[], ); + assert!(matches!(ir.items[0].node, OracleNodeIr::Spell(_))); + assert_eq!( + lowered.abilities.len(), + 1, + "the Dig override must bind through the document relation" + ); + assert!(lowered.abilities[0].else_ability.is_some()); insta::assert_json_snapshot!("follow_the_lumarets_ir", &ir); insta::assert_json_snapshot!("follow_the_lumarets_lowered", &lowered); } +/// CR 614.6 + CR 614.15: an override whose condition cannot lower stays on the +/// `instead_override` floor; it must never become an independent second spell. +#[test] +fn priority_nine_unbindable_conditioned_replacement_stays_honest() { + let (ir, lowered) = parse_two_layer( + "Draw a card.\nMystery — Draw two cards instead if the cracks in this artifact's art are completely covered.", + "Unbindable Override Fixture", + &["Sorcery"], + &[], + ); + assert!(matches!(ir.items[0].node, OracleNodeIr::Spell(_))); + assert!(matches!(ir.items[1].node, OracleNodeIr::Spell(_))); + assert_eq!(lowered.abilities.len(), 2); + assert!(matches!( + lowered.abilities[1].effect.as_ref(), + Effect::Unimplemented { name, .. } if name == "instead_override" + )); + assert!(lowered.abilities[1].condition.is_none()); +} + +fn assert_unbindable_override(def: &crate::types::ability::AbilityDefinition) { + assert!(matches!( + def.effect.as_ref(), + Effect::Unimplemented { name, .. } if name == "instead_override" + )); + assert!(def.sub_ability.is_none()); + assert!(def.else_ability.is_none()); +} + +fn roll_die_result_count(def: &crate::types::ability::AbilityDefinition) -> Option { + match def.effect.as_ref() { + Effect::RollDie { results, .. } => Some(results.len()), + _ => def + .sub_ability + .as_deref() + .and_then(roll_die_result_count) + .or_else(|| def.else_ability.as_deref().and_then(roll_die_result_count)), + } +} + +/// CR 706.3b: recognized contiguous result branches stay with an inline roll +/// even when the ability's paragraph has instructions both before and after it. +fn assert_inline_die_table( + oracle_text: &str, + card_name: &str, + types: &[&str], + expected_recognized_results: usize, +) { + let (_, lowered) = parse_two_layer(oracle_text, card_name, types, &[]); + assert_eq!( + lowered.abilities.len(), + 1, + "{card_name} must retain its result rows on the printed ability" + ); + assert_eq!( + roll_die_result_count(&lowered.abilities[0]), + Some(expected_recognized_results), + "{card_name} must retain its recognized result branches on the inline roll" + ); +} + +#[test] +fn laezels_acrobatics_inline_die_table_is_owned_by_nonterminal_roll() { + assert_inline_die_table( + "Exile all nontoken creatures you control, then roll a d20.\n1—9 | Return those cards to the battlefield under their owner's control at the beginning of the next end step.\n10—20 | Return those cards to the battlefield under their owner's control, then exile them again. Return those cards to the battlefield under their owner's control at the beginning of the next end step.", + "Lae'zel's Acrobatics", + &["Instant"], + 1, + ); +} + +#[test] +fn overwhelming_encounter_inline_die_table_is_owned_by_nonterminal_roll() { + assert_inline_die_table( + "Creatures you control gain vigilance and trample until end of turn. Roll a d20.\n1—9 | Creatures you control get +2/+2 until end of turn.\n10—19 | Put two +1/+1 counters on each creature you control.\n20 | Put four +1/+1 counters on each creature you control.", + "Overwhelming Encounter", + &["Sorcery"], + 2, + ); +} + +#[test] +fn deck_of_many_things_inline_die_table_is_owned_by_modified_roll() { + assert_inline_die_table( + "{2}, {T}: Roll a d20 and subtract the number of cards in your hand. If the result is 0 or less, discard your hand.\n1—9 | Return a card at random from your graveyard to your hand.\n10—19 | Draw two cards.\n20 | Put a creature card from any graveyard onto the battlefield under your control. When that creature dies, its owner loses the game.", + "The Deck of Many Things", + &["Artifact"], + 3, + ); +} + +#[test] +fn wand_of_wonder_inline_die_table_is_owned_by_roll_with_later_instructions() { + assert_inline_die_table( + "{4}, {T}: Roll a d20. Each opponent exiles cards from the top of their library until they exile an instant or sorcery card, then shuffles the rest into their library. You may cast up to X instant and/or sorcery spells from among cards exiled this way without paying their mana costs.\n1—9 | X is one.\n10—19 | X is two.\n20 | X is three.", + "Wand of Wonder", + &["Artifact"], + 3, + ); +} + +#[test] +fn inline_roll_without_immediate_result_row_does_not_consume_following_text() { + let (ir, lowered) = parse_two_layer( + "Draw a card, then roll a d20.\nFlying\n1—20 | Draw two cards.", + "Inline Roll Without Table Fixture", + &["Sorcery"], + &[], + ); + assert!(!ir.items.is_empty()); + assert!(!lowered.abilities.is_empty()); + assert_eq!(roll_die_result_count(&lowered.abilities[0]), Some(0)); +} + +#[test] +fn roll_text_without_typed_roll_die_does_not_consume_result_rows() { + let (ir, lowered) = parse_two_layer( + "Draw a card, then roll a dword.\n1—20 | Draw two cards.", + "Unparsed Roll Text Fixture", + &["Sorcery"], + &[], + ); + assert_eq!(ir.items.len(), 2); + assert_eq!(lowered.abilities.len(), 2); + assert_eq!(roll_die_result_count(&lowered.abilities[0]), None); +} + +/// CR 614.6 + CR 614.15: the native override floor retains the root clause's +/// resolution metadata while making the unsupported replacement explicit. +#[test] +fn caravan_vigil_unbindable_override_retains_optional() { + let (_, lowered) = parse_two_layer( + "Search your library for a basic land card, reveal it, put it into your hand, then shuffle.\nMorbid — You may put that card onto the battlefield instead of putting it into your hand if a creature died this turn.", + "Caravan Vigil", + &["Sorcery"], + &[], + ); + let override_def = &lowered.abilities[1]; + assert_unbindable_override(override_def); + assert!(override_def.optional); +} + +/// CR 614.6 + CR 614.15: partial cross-line replacements preserve a parsed +/// optional root even when the replacement cannot bind safely. +#[test] +fn talent_of_the_telepath_unbindable_override_retains_optional() { + let (_, lowered) = parse_two_layer( + "Target opponent reveals the top seven cards of their library. You may cast an instant or sorcery spell from among them without paying its mana cost. Then that player puts the rest into their graveyard.\nSpell mastery — If there are two or more instant and/or sorcery cards in your graveyard, you may cast up to two instant and/or sorcery spells from among the revealed cards instead of one.", + "Talent of the Telepath", + &["Sorcery"], + &[], + ); + let override_def = &lowered.abilities[1]; + assert_unbindable_override(override_def); + assert!(override_def.optional); +} + +/// CR 614.6 + CR 614.15: an unbindable partial replacement keeps the original +/// root's resolution-time selection stamp instead of inferring it from the floor. +#[test] +fn see_the_unwritten_unbindable_override_retains_target_choice_timing() { + let (_, lowered) = parse_two_layer( + "Reveal the top eight cards of your library. You may put a creature card from among them onto the battlefield. Put the rest into your graveyard.\nFerocious — If you control a creature with power 4 or greater, you may put two creature cards onto the battlefield instead of one.", + "See the Unwritten", + &["Sorcery"], + &[], + ); + let override_def = &lowered.abilities[1]; + assert_unbindable_override(override_def); + assert_eq!( + override_def.target_choice_timing, + TargetChoiceTiming::Resolution + ); +} + // CR 614.1a + CR 608.2c: Instead — the multi-clause Cow-swap. Clause 1 ("gain // control … until end of turn") is the root/swap target; the "… instead" override // carries the `ConditionInstead`, and the TAIL clauses ("Untap that creature. It diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__aangs_journey_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__aangs_journey_ir.snap index 36f2f14129..d5a6462351 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__aangs_journey_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__aangs_journey_ir.snap @@ -1,6 +1,5 @@ --- source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs -assertion_line: 763 expression: "&ir" --- { @@ -85,282 +84,435 @@ expression: "&ir" "fragment": "Search your library for a basic land card. If this spell was kicked, instead search your library for a basic land card and a Shrine card. Reveal those cards, put them into your hand, then shuffle." }, "node": { - "PreLoweredSpell": { - "kind": "Spell", - "effect": { - "type": "SearchLibrary", - "filter": { - "type": "Typed", - "type_filters": [ - "Land" - ], - "controller": null, - "properties": [ - { - "type": "HasSupertype", - "value": "Basic" - } - ] - }, - "count": { - "type": "Fixed", - "value": 1 - }, - "reveal": false - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "SearchLibrary", - "filter": { - "type": "Or", - "filters": [ - { - "type": "Typed", - "type_filters": [ - "Land" - ], - "controller": null, - "properties": [ - { - "type": "HasSupertype", - "value": "Basic" - } - ] + "Spell": { + "source_text": "Search your library for a basic land card. If this spell was kicked, instead search your library for a basic land card and a Shrine card. Reveal those cards, put them into your hand, then shuffle. You gain 2 life.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 41, + "precision": "ChainRelative", + "ordinal_within_span": 0 }, - { - "type": "Typed", - "type_filters": [ - "Enchantment", - { - "Subtype": "Shrine" + "fragment": "Search your library for a basic land card" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": { + "SearchDestination": { + "destination": "Hand", + "enter_tapped": false, + "enters_under": null, + "reveal": false, + "attach_host": null } - ], - "controller": null, - "properties": [] + } } - ] - }, - "count": { - "type": "Fixed", - "value": 2 + }, + "parsed": { + "effect": { + "type": "SearchLibrary", + "filter": { + "type": "Typed", + "type_filters": [ + "Land" + ], + "controller": null, + "properties": [ + { + "type": "HasSupertype", + "value": "Basic" + } + ] + }, + "count": { + "type": "Fixed", + "value": 1 + }, + "reveal": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "reveal": false, - "selection_constraint": { - "type": "MatchEachFilter", - "filters": [ - { - "type": "Typed", - "type_filters": [ - "Land" - ], - "controller": null, - "properties": [ - { - "type": "HasSupertype", - "value": "Basic" - } - ] + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 }, - { - "type": "Typed", - "type_filters": [ - "Enchantment", - { - "Subtype": "Shrine" + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 43, + "end_byte": 136, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "If this spell was kicked, instead search your library for a basic land card and a Shrine card" + }, + "disposition": { + "FoldSearchIntoElse": { + "intrinsic": { + "SearchDestination": { + "destination": "Hand", + "enter_tapped": false, + "enters_under": null, + "reveal": false, + "attach_host": null } - ], - "controller": null, - "properties": [] + } } - ] - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "ChangeZone", - "origin": "Library", - "destination": "Hand", - "target": { - "type": "Any" }, - "owner_library": false, - "enter_transformed": false, - "enter_tapped": false, - "enters_attacking": false + "parsed": { + "effect": { + "type": "SearchLibrary", + "filter": { + "type": "Or", + "filters": [ + { + "type": "Typed", + "type_filters": [ + "Land" + ], + "controller": null, + "properties": [ + { + "type": "HasSupertype", + "value": "Basic" + } + ] + }, + { + "type": "Typed", + "type_filters": [ + "Enchantment", + { + "Subtype": "Shrine" + } + ], + "controller": null, + "properties": [] + } + ] + }, + "count": { + "type": "Fixed", + "value": 2 + }, + "reveal": false, + "selection_constraint": { + "type": "MatchEachFilter", + "filters": [ + { + "type": "Typed", + "type_filters": [ + "Land" + ], + "controller": null, + "properties": [ + { + "type": "HasSupertype", + "value": "Basic" + } + ] + }, + { + "type": "Typed", + "type_filters": [ + "Enchantment", + { + "Subtype": "Shrine" + } + ], + "controller": null, + "properties": [] + } + ] + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": { + "type": "AdditionalCostPaidInstead" + }, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "ChangeZone", - "origin": null, - "destination": "Hand", - "target": { - "type": "ParentTarget" + { + "id": 2, + "source": { + "id": { + "item": 0, + "ordinal": 3 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 138, + "end_byte": 156, + "precision": "ChainRelative", + "ordinal_within_span": 2 }, - "owner_library": false, - "enter_transformed": false, - "enter_tapped": false, - "enters_attacking": false + "fragment": "Reveal those cards" }, - "cost": null, - "sub_ability": { - "kind": "Spell", + "disposition": { + "Continue": { + "continuation": "SearchResultClauseHandled" + } + }, + "parsed": { "effect": { - "type": "Shuffle", + "type": "Reveal", "target": { - "type": "Controller" + "type": "ParentTarget" } }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "GainLife", - "amount": { - "type": "Fixed", - "value": 2 - } - }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "sub_link": "SequentialSibling" - }, "duration": null, - "description": null, - "target_prompt": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, "condition": null, - "optional_targeting": false, "optional": false, - "forward_result": false + "unless_pay": null }, - "duration": null, - "description": null, - "target_prompt": null, + "boundary": "Comma", "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "else_ability": { - "kind": "Spell", - "effect": { - "type": "ChangeZone", - "origin": "Library", - "destination": "Hand", - "target": { - "type": "Any" + { + "id": 3, + "source": { + "id": { + "item": 0, + "ordinal": 4 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 158, + "end_byte": 181, + "precision": "ChainRelative", + "ordinal_within_span": 3 + }, + "fragment": "put them into your hand" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "ChangeZone", + "origin": null, + "destination": "Hand", + "target": { + "type": "ParentTarget" + }, + "owner_library": false, + "enter_transformed": false, + "enter_tapped": false, + "enters_attacking": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null }, - "owner_library": false, - "enter_transformed": false, - "enter_tapped": false, - "enters_attacking": false + "boundary": "Then", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "ChangeZone", - "origin": null, - "destination": "Hand", - "target": { - "type": "ParentTarget" + { + "id": 4, + "source": { + "id": { + "item": 0, + "ordinal": 5 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 188, + "end_byte": 195, + "precision": "ChainRelative", + "ordinal_within_span": 4 }, - "owner_library": false, - "enter_transformed": false, - "enter_tapped": false, - "enters_attacking": false + "fragment": "shuffle" }, - "cost": null, - "sub_ability": { - "kind": "Spell", + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { "effect": { "type": "Shuffle", "target": { "type": "Controller" } }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "GainLife", - "amount": { - "type": "Fixed", - "value": 2 - } - }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "sub_link": "SequentialSibling" - }, "duration": null, - "description": null, - "target_prompt": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, "condition": null, - "optional_targeting": false, "optional": false, - "forward_result": false + "unless_pay": null }, - "duration": null, - "description": null, - "target_prompt": null, + "boundary": "Sentence", "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": { - "type": "AdditionalCostPaidInstead" - }, - "optional_targeting": false, - "optional": false, - "forward_result": false + { + "id": 5, + "source": { + "id": { + "item": 0, + "ordinal": 6 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 197, + "end_byte": 212, + "precision": "ChainRelative", + "ordinal_within_span": 5 + }, + "fragment": "You gain 2 life" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "GainLife", + "amount": { + "type": "Fixed", + "value": 2 + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "duration": null, - "description": "Search your library for a basic land card. If this spell was kicked, instead search your library for a basic land card and a Shrine card. Reveal those cards, put them into your hand, then shuffle.\nYou gain 2 life.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "shell": { + "sub_link": null + }, + "die_results": [], + "root_transforms": [ + { + "SetMinXValue": 0 + }, + { + "SetDescription": "Search your library for a basic land card. If this spell was kicked, instead search your library for a basic land card and a Shrine card. Reveal those cards, put them into your hand, then shuffle.\nYou gain 2 life." + } + ] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__aerial_formation_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__aerial_formation_ir.snap index 87eb6bf2b8..32545901a1 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__aerial_formation_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__aerial_formation_ir.snap @@ -49,61 +49,119 @@ expression: "&ir" "fragment": "Any number of target creatures each get +1/+1 and gain flying until end of turn." }, "node": { - "PreLoweredSpell": { - "kind": "Spell", - "effect": { - "type": "GenericEffect", - "static_abilities": [ + "Spell": { + "source_text": "Any number of target creatures each get +1/+1 and gain flying until end of turn.", + "body": { + "clauses": [ { - "mode": "Continuous", - "affected": { - "type": "ParentTarget" - }, - "modifications": [ - { - "type": "AddPower", - "value": 1 + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 }, - { - "type": "AddToughness", - "value": 1 + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 79, + "precision": "ChainRelative", + "ordinal_within_span": 0 }, - { - "type": "AddKeyword", - "keyword": "Flying" + "fragment": "Any number of target creatures each get +1/+1 and gain flying until end of turn" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null } - ], + }, + "parsed": { + "effect": { + "type": "GenericEffect", + "static_abilities": [ + { + "mode": "Continuous", + "affected": { + "type": "ParentTarget" + }, + "modifications": [ + { + "type": "AddPower", + "value": 1 + }, + { + "type": "AddToughness", + "value": 1 + }, + { + "type": "AddKeyword", + "keyword": "Flying" + } + ], + "condition": null, + "affected_zone": null, + "effect_zone": null, + "active_zones": [], + "characteristic_defining": false, + "description": "get +1/+1 and gain flying" + } + ], + "duration": "UntilEndOfTurn", + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [] + } + }, + "duration": "UntilEndOfTurn", + "sub_ability": null, + "distribute": null, + "multi_target": { + "min": 0, + "max": null + }, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", "condition": null, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "get +1/+1 and gain flying" + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } ], - "duration": "UntilEndOfTurn", - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": null, - "properties": [] - } + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": null, - "sub_ability": null, - "duration": "UntilEndOfTurn", - "description": "Any number of target creatures each get +1/+1 and gain flying until end of turn.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "multi_target": { - "min": 0, - "max": null + "shell": { + "sub_link": null }, - "forward_result": false + "die_results": [], + "root_transforms": [ + { + "SetMinXValue": 0 + }, + { + "SetDescription": "Any number of target creatures each get +1/+1 and gain flying until end of turn." + } + ] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__aetherling_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__aetherling_ir.snap index 25799e6d17..4f538acb4b 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__aetherling_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__aetherling_ir.snap @@ -171,7 +171,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } }, @@ -295,7 +296,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } }, @@ -406,7 +408,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } }, @@ -517,7 +520,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__analyze_the_pollen_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__analyze_the_pollen_ir.snap index 7c3b4a78ad..3145da7db8 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__analyze_the_pollen_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__analyze_the_pollen_ir.snap @@ -1,6 +1,5 @@ --- source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs -assertion_line: 779 expression: "&ir" --- { @@ -52,161 +51,345 @@ expression: "&ir" "fragment": "Search your library for a basic land card. If evidence was collected, instead search your library for a creature or land card. Reveal that card, put it into your hand, then shuffle." }, "node": { - "PreLoweredSpell": { - "kind": "Spell", - "effect": { - "type": "SearchLibrary", - "filter": { - "type": "Typed", - "type_filters": [ - "Land" - ], - "controller": null, - "properties": [ - { - "type": "HasSupertype", - "value": "Basic" - } - ] - }, - "count": { - "type": "Fixed", - "value": 1 - }, - "reveal": true - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "SearchLibrary", - "filter": { - "type": "Or", - "filters": [ - { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": null, - "properties": [] + "Spell": { + "source_text": "Search your library for a basic land card. If evidence was collected, instead search your library for a creature or land card. Reveal that card, put it into your hand, then shuffle.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 41, + "precision": "ChainRelative", + "ordinal_within_span": 0 }, - { - "type": "Typed", - "type_filters": [ - "Land" - ], - "controller": null, - "properties": [] + "fragment": "Search your library for a basic land card" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": { + "SearchDestination": { + "destination": "Hand", + "enter_tapped": false, + "enters_under": null, + "reveal": true, + "attach_host": null + } + } } - ] - }, - "count": { - "type": "Fixed", - "value": 1 + }, + "parsed": { + "effect": { + "type": "SearchLibrary", + "filter": { + "type": "Typed", + "type_filters": [ + "Land" + ], + "controller": null, + "properties": [ + { + "type": "HasSupertype", + "value": "Basic" + } + ] + }, + "count": { + "type": "Fixed", + "value": 1 + }, + "reveal": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "reveal": true - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "ChangeZone", - "origin": "Library", - "destination": "Hand", - "target": { - "type": "Any" - }, - "owner_library": false, - "enter_transformed": false, - "enter_tapped": false, - "enters_attacking": false + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 43, + "end_byte": 125, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "If evidence was collected, instead search your library for a creature or land card" + }, + "disposition": { + "FoldSearchIntoElse": { + "intrinsic": { + "SearchDestination": { + "destination": "Hand", + "enter_tapped": false, + "enters_under": null, + "reveal": true, + "attach_host": null + } + } + } + }, + "parsed": { + "effect": { + "type": "SearchLibrary", + "filter": { + "type": "Or", + "filters": [ + { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [] + }, + { + "type": "Typed", + "type_filters": [ + "Land" + ], + "controller": null, + "properties": [] + } + ] + }, + "count": { + "type": "Fixed", + "value": 1 + }, + "reveal": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": { + "type": "AdditionalCostPaidInstead" + }, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "Shuffle", - "target": { - "type": "Controller" + { + "id": 2, + "source": { + "id": { + "item": 0, + "ordinal": 3 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 127, + "end_byte": 143, + "precision": "ChainRelative", + "ordinal_within_span": 2 + }, + "fragment": "Reveal that card" + }, + "disposition": { + "Continue": { + "continuation": "SearchResultClauseHandled" } }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, + "parsed": { + "effect": { + "type": "Reveal", + "target": { + "type": "ParentTarget" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Comma", "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "else_ability": { - "kind": "Spell", - "effect": { - "type": "ChangeZone", - "origin": "Library", - "destination": "Hand", - "target": { - "type": "Any" - }, - "owner_library": false, - "enter_transformed": false, - "enter_tapped": false, - "enters_attacking": false + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "Shuffle", - "target": { - "type": "Controller" + { + "id": 3, + "source": { + "id": { + "item": 0, + "ordinal": 4 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 145, + "end_byte": 166, + "precision": "ChainRelative", + "ordinal_within_span": 3 + }, + "fragment": "put it into your hand" + }, + "disposition": { + "Continue": { + "continuation": "SearchResultClauseHandled" } }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, + "parsed": { + "effect": { + "type": "ChangeZone", + "origin": null, + "destination": "Hand", + "target": { + "type": "ParentTarget" + }, + "owner_library": false, + "enter_transformed": false, + "enter_tapped": false, + "enters_attacking": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Then", "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": { - "type": "AdditionalCostPaidInstead" - }, - "optional_targeting": false, - "optional": false, - "forward_result": false + { + "id": 4, + "source": { + "id": { + "item": 0, + "ordinal": 5 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 173, + "end_byte": 180, + "precision": "ChainRelative", + "ordinal_within_span": 4 + }, + "fragment": "shuffle" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Shuffle", + "target": { + "type": "Controller" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null }, - "duration": null, - "description": "Search your library for a basic land card. If evidence was collected, instead search your library for a creature or land card. Reveal that card, put it into your hand, then shuffle.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [], + "root_transforms": [ + { + "SetMinXValue": 0 + }, + { + "SetDescription": "Search your library for a basic land card. If evidence was collected, instead search your library for a creature or land card. Reveal that card, put it into your hand, then shuffle." + } + ] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__arni_brokenbrow_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__arni_brokenbrow_ir.snap index 1c3a493e26..ab685354a0 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__arni_brokenbrow_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__arni_brokenbrow_ir.snap @@ -145,7 +145,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__barbarian_ring_activated_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__barbarian_ring_activated_ir.snap index 12d64aae01..56375bf70e 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__barbarian_ring_activated_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__barbarian_ring_activated_ir.snap @@ -156,7 +156,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } }, @@ -304,7 +305,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__batterskull_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__batterskull_ir.snap index a7083c0258..93c7d15be0 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__batterskull_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__batterskull_ir.snap @@ -188,7 +188,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } }, diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__birds_of_paradise_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__birds_of_paradise_ir.snap index aee0efdaf5..a66b2db507 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__birds_of_paradise_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__birds_of_paradise_ir.snap @@ -137,7 +137,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__blunt_the_assault_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__blunt_the_assault_ir.snap index b547b581b5..6fb1770910 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__blunt_the_assault_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__blunt_the_assault_ir.snap @@ -153,7 +153,8 @@ expression: "&ir" "shell": { "sub_link": null }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__bomat_courier_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__bomat_courier_ir.snap index d4b7d6417c..6ff6801a2a 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__bomat_courier_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__bomat_courier_ir.snap @@ -296,7 +296,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__bone_splinters_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__bone_splinters_ir.snap index f275f3a2f4..a6fce99767 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__bone_splinters_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__bone_splinters_ir.snap @@ -57,29 +57,88 @@ expression: "&ir" "fragment": "Destroy target creature." }, "node": { - "PreLoweredSpell": { - "kind": "Spell", - "effect": { - "type": "Destroy", - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": null, - "properties": [] - }, - "cant_regenerate": false + "Spell": { + "source_text": "Destroy target creature.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 23, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Destroy target creature" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Destroy", + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [] + }, + "cant_regenerate": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": "Destroy target creature.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "shell": { + "sub_link": null + }, + "die_results": [], + "root_transforms": [ + { + "SetMinXValue": 0 + }, + { + "SetDescription": "Destroy target creature." + } + ] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__boseiju_who_endures_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__boseiju_who_endures_ir.snap index 8915a6b6c4..e849a994db 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__boseiju_who_endures_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__boseiju_who_endures_ir.snap @@ -101,7 +101,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } }, @@ -501,7 +502,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__browbeat_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__browbeat_ir.snap index d7064b730e..a9d155dde9 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__browbeat_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__browbeat_ir.snap @@ -22,55 +22,157 @@ expression: "&ir" "fragment": "Any player may have ~ deal 5 damage to them. If no one does, target player draws three cards." }, "node": { - "PreLoweredSpell": { - "kind": "Spell", - "effect": { - "type": "DealDamage", - "amount": { - "type": "Fixed", - "value": 5 - }, - "target": { - "type": "Player" - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "Draw", - "count": { - "type": "Fixed", - "value": 3 + "Spell": { + "source_text": "Any player may have ~ deal 5 damage to them. If no one does, target player draws three cards.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 43, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Any player may have ~ deal 5 damage to them" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "DealDamage", + "amount": { + "type": "Fixed", + "value": 5 + }, + "target": { + "type": "Player" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": true, + "opponent_may_scope": "AnyPlayer", + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "target": { - "type": "Player" - } - }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": { - "type": "Not", - "condition": { - "type": "EffectOutcome", - "signal": "OptionalEffectPerformed" + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 45, + "end_byte": 92, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "If no one does, target player draws three cards" + }, + "disposition": { + "BranchOtherwise": { + "else_def": { + "kind": "Spell", + "effect": { + "type": "Draw", + "count": { + "type": "Fixed", + "value": 3 + }, + "target": { + "type": "Player" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "kind": "Bound" + } + }, + "parsed": { + "effect": { + "type": "Unimplemented", + "name": "otherwise_placeholder", + "description": null + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } - }, - "optional_targeting": false, - "optional": false, - "forward_result": false + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null }, - "duration": null, - "description": "Any player may have ~ deal 5 damage to them. If no one does, target player draws three cards.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": true, - "optional_for": "AnyPlayer", - "forward_result": false + "die_results": [], + "root_transforms": [ + { + "SetMinXValue": 0 + }, + { + "SetDescription": "Any player may have ~ deal 5 damage to them. If no one does, target player draws three cards." + } + ] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__carbonize_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__carbonize_ir.snap index afa8562f5b..ddfd7718aa 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__carbonize_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__carbonize_ir.snap @@ -22,145 +22,304 @@ expression: "&ir" "fragment": "~ deals 3 damage to any target. If it's a creature, it can't be regenerated this turn, and if it would die this turn, exile it instead." }, "node": { - "PreLoweredSpell": { - "kind": "Spell", - "effect": { - "type": "DealDamage", - "amount": { - "type": "Fixed", - "value": 3 - }, - "target": { - "type": "Any" - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "GenericEffect", - "static_abilities": [ - { - "mode": "CantBeRegenerated", - "affected": { - "type": "ParentTarget" + "Spell": { + "source_text": "~ deals 3 damage to any target. If it's a creature, it can't be regenerated this turn, and if it would die this turn, exile it instead.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 30, + "precision": "ChainRelative", + "ordinal_within_span": 0 }, - "modifications": [ - { - "type": "AddStaticMode", - "mode": "CantBeRegenerated" + "fragment": "~ deals 3 damage to any target" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "DealDamage", + "amount": { + "type": "Fixed", + "value": 3 + }, + "target": { + "type": "Any" } - ], + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, "condition": null, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": null - } - ], - "duration": "UntilEndOfTurn", - "target": { - "type": "TrackedSet", - "id": 0 - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "AddTargetReplacement", - "replacement": { - "event": "Moved", - "execute": { - "kind": "Spell", - "effect": { - "type": "ChangeZone", - "origin": "Battlefield", - "destination": "Exile", - "target": { - "type": "SelfRef" + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 32, + "end_byte": 134, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "If it's a creature, it can't be regenerated this turn, and if it would die this turn, exile it instead" + }, + "disposition": { + "Absorb": { + "rider": { + "kind": "Spell", + "effect": { + "type": "GenericEffect", + "static_abilities": [ + { + "mode": "CantBeRegenerated", + "affected": { + "type": "ParentTarget" + }, + "modifications": [ + { + "type": "AddStaticMode", + "mode": "CantBeRegenerated" + } + ], + "condition": null, + "affected_zone": null, + "effect_zone": null, + "active_zones": [], + "characteristic_defining": false, + "description": null + } + ], + "duration": "UntilEndOfTurn", + "target": { + "type": "TrackedSet", + "id": 0 + } + }, + "cost": null, + "sub_ability": null, + "duration": "UntilEndOfTurn", + "description": null, + "target_prompt": null, + "condition": { + "type": "TargetMatchesFilter", + "filter": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [] + }, + "use_lki": true }, - "owner_library": false, - "enter_transformed": false, - "enter_tapped": false, - "enters_attacking": false + "optional_targeting": false, + "optional": false, + "forward_result": false }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "kind": "CantBeRegenerated" + } + }, + "parsed": { + "effect": { + "type": "Unimplemented", + "name": "cant_be_regenerated_rider_placeholder", + "description": "" }, - "mode": { - "type": "Mandatory" + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 2, + "source": { + "id": { + "item": 0, + "ordinal": 3 }, - "valid_card": { - "type": "SelfRef" + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 134, + "end_byte": 134, + "precision": "ChainRelative", + "ordinal_within_span": 2 }, - "description": null, - "condition": null, - "destination_zone": "Graveyard", - "consume_on_apply": true, - "expiry": { - "type": "EndOfTurn" + "fragment": "If it's a creature, it can't be regenerated this turn, and if it would die this turn, exile it instead" + }, + "disposition": { + "Absorb": { + "rider": { + "kind": "Spell", + "effect": { + "type": "AddTargetReplacement", + "replacement": { + "event": "Moved", + "execute": { + "kind": "Spell", + "effect": { + "type": "ChangeZone", + "origin": "Battlefield", + "destination": "Exile", + "target": { + "type": "SelfRef" + }, + "owner_library": false, + "enter_transformed": false, + "enter_tapped": false, + "enters_attacking": false + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "mode": { + "type": "Mandatory" + }, + "valid_card": { + "type": "SelfRef" + }, + "description": null, + "condition": null, + "destination_zone": "Graveyard", + "consume_on_apply": true, + "expiry": { + "type": "EndOfTurn" + } + }, + "target": { + "type": "Any" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": { + "type": "TargetMatchesFilter", + "filter": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [] + }, + "use_lki": true + }, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "kind": "DieExile" } }, - "target": { - "type": "Any" - } - }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": { - "type": "TargetMatchesFilter", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": null, - "properties": [] + "parsed": { + "effect": { + "type": "Unimplemented", + "name": "die_exile_rider_placeholder", + "description": "" + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null }, - "use_lki": true - }, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "duration": "UntilEndOfTurn", - "description": null, - "target_prompt": null, - "condition": { - "type": "TargetMatchesFilter", - "filter": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": null, - "properties": [] - }, - "use_lki": true - }, - "optional_targeting": false, - "optional": false, - "forward_result": false + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null }, - "duration": null, - "description": "~ deals 3 damage to any target. If it's a creature, it can't be regenerated this turn, and if it would die this turn, exile it instead.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [], + "root_transforms": [ + { + "SetMinXValue": 0 + }, + { + "SetDescription": "~ deals 3 damage to any target. If it's a creature, it can't be regenerated this turn, and if it would die this turn, exile it instead." + } + ] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__case_of_the_stashed_skeleton_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__case_of_the_stashed_skeleton_ir.snap index 483d01c5b9..2253386b94 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__case_of_the_stashed_skeleton_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__case_of_the_stashed_skeleton_ir.snap @@ -378,7 +378,8 @@ expression: "&ir" ], "description": "Solved — {1}{B}, Sacrifice this ~: Search your library for a card, put it into your hand, then shuffle. Activate only as a sorcery." }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__champions_victory_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__champions_victory_ir.snap index d6403a952e..b401aafe84 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__champions_victory_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__champions_victory_ir.snap @@ -73,33 +73,92 @@ expression: "&ir" "fragment": "Return target attacking creature to its owner's hand." }, "node": { - "PreLoweredSpell": { - "kind": "Spell", - "effect": { - "type": "Bounce", - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": null, - "properties": [ - { - "type": "Attacking" - } - ] - }, - "destination": null + "Spell": { + "source_text": "Return target attacking creature to its owner's hand.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 52, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Return target attacking creature to its owner's hand" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Bounce", + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [ + { + "type": "Attacking" + } + ] + }, + "destination": null + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": "Return target attacking creature to its owner's hand.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [], + "root_transforms": [ + { + "SetMinXValue": 0 + }, + { + "SetDescription": "Return target attacking creature to its owner's hand." + } + ] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__component_pouch_activated_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__component_pouch_activated_ir.snap index 0837a486df..ea0a90f01c 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__component_pouch_activated_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__component_pouch_activated_ir.snap @@ -155,7 +155,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } }, @@ -332,7 +333,8 @@ expression: "&ir" "shell": { "sub_link": null }, - "die_results": [] + "die_results": [], + "root_transforms": [] } }, { @@ -410,10 +412,12 @@ expression: "&ir" "shell": { "sub_link": null }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } - ] + ], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__deadly_rollick_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__deadly_rollick_ir.snap index 737ca7c641..ba7308ea9d 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__deadly_rollick_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__deadly_rollick_ir.snap @@ -69,34 +69,93 @@ expression: "&ir" "fragment": "Exile target creature." }, "node": { - "PreLoweredSpell": { - "kind": "Spell", - "effect": { - "type": "ChangeZone", - "origin": null, - "destination": "Exile", - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": null, - "properties": [] - }, - "owner_library": false, - "enter_transformed": false, - "enter_tapped": false, - "enters_attacking": false + "Spell": { + "source_text": "Exile target creature.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 21, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Exile target creature" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "ChangeZone", + "origin": null, + "destination": "Exile", + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [] + }, + "owner_library": false, + "enter_transformed": false, + "enter_tapped": false, + "enters_attacking": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": "Exile target creature.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [], + "root_transforms": [ + { + "SetMinXValue": 0 + }, + { + "SetDescription": "Exile target creature." + } + ] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__dismember_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__dismember_ir.snap index 00b3806e72..ea95338158 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__dismember_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__dismember_ir.snap @@ -22,36 +22,95 @@ expression: "&ir" "fragment": "Target creature gets -5/-5 until end of turn." }, "node": { - "PreLoweredSpell": { - "kind": "Spell", - "effect": { - "type": "Pump", - "power": { - "type": "Fixed", - "value": -5 - }, - "toughness": { - "type": "Fixed", - "value": -5 + "Spell": { + "source_text": "Target creature gets -5/-5 until end of turn.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 44, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Target creature gets -5/-5 until end of turn" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Pump", + "power": { + "type": "Fixed", + "value": -5 + }, + "toughness": { + "type": "Fixed", + "value": -5 + }, + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [] + } + }, + "duration": "UntilEndOfTurn", + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null + }, + "die_results": [], + "root_transforms": [ + { + "SetMinXValue": 0 }, - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": null, - "properties": [] + { + "SetDescription": "Target creature gets -5/-5 until end of turn." } - }, - "cost": null, - "sub_ability": null, - "duration": "UntilEndOfTurn", - "description": "Target creature gets -5/-5 until end of turn.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + ] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__evils_thrall_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__evils_thrall_ir.snap index 60ed3210b1..5263aa9563 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__evils_thrall_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__evils_thrall_ir.snap @@ -22,156 +22,332 @@ expression: "&ir" "fragment": "Gain control of target creature until end of turn. If you control a Villain with greater mana value than that creature, gain control of that creature until the end of your next turn instead. Untap that creature. It gains haste until end of turn." }, "node": { - "PreLoweredSpell": { - "kind": "Spell", - "effect": { - "type": "GainControl", - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": null, - "properties": [] - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "GainControl", - "target": { - "type": "ParentTarget" - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "SetTapState", - "target": { - "type": "ParentTarget" + "Spell": { + "source_text": "Gain control of target creature until end of turn. If you control a Villain with greater mana value than that creature, gain control of that creature until the end of your next turn instead. Untap that creature. It gains haste until end of turn.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 49, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Gain control of target creature until end of turn" }, - "scope": { - "type": "Single" + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } }, - "state": { - "type": "Untap" - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "GenericEffect", - "static_abilities": [ - { - "mode": "Continuous", - "affected": { - "type": "SelfRef" - }, - "modifications": [ - { - "type": "AddKeyword", - "keyword": "Haste" - } + "parsed": { + "effect": { + "type": "GainControl", + "target": { + "type": "Typed", + "type_filters": [ + "Creature" ], - "condition": null, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "gain haste" + "controller": null, + "properties": [] } - ], + }, "duration": "UntilEndOfTurn", - "target": null + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null }, - "cost": null, - "sub_ability": null, - "duration": "UntilEndOfTurn", - "description": null, - "target_prompt": null, + "boundary": "Sentence", "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "sub_link": "SequentialSibling" + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "sub_link": "SequentialSibling" - }, - "duration": { - "UntilEndOfNextTurnOf": { - "player": { - "type": "Controller" - } - } - }, - "description": null, - "target_prompt": null, - "condition": { - "type": "ConditionInstead", - "inner": { - "type": "QuantityCheck", - "lhs": { - "type": "Ref", - "qty": { - "type": "ObjectCount", - "filter": { - "type": "Typed", - "type_filters": [ - { - "Subtype": "Villain" - } - ], - "controller": "You", - "properties": [ - { - "type": "Cmc", - "comparator": "GT", - "value": { - "type": "Ref", - "qty": { - "type": "ObjectManaValue", - "scope": { - "type": "Target" + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 51, + "end_byte": 189, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "If you control a Villain with greater mana value than that creature, gain control of that creature until the end of your next turn instead" + }, + "disposition": { + "ReplaceMeaning": { + "kind": { + "Instead": { + "kind": "Spell", + "effect": { + "type": "GainControl", + "target": { + "type": "ParentTarget" + } + }, + "cost": null, + "sub_ability": null, + "duration": { + "UntilEndOfNextTurnOf": { + "player": { + "type": "Controller" + } + } + }, + "description": null, + "target_prompt": null, + "condition": { + "type": "ConditionInstead", + "inner": { + "type": "QuantityCheck", + "lhs": { + "type": "Ref", + "qty": { + "type": "ObjectCount", + "filter": { + "type": "Typed", + "type_filters": [ + { + "Subtype": "Villain" + } + ], + "controller": "You", + "properties": [ + { + "type": "Cmc", + "comparator": "GT", + "value": { + "type": "Ref", + "qty": { + "type": "ObjectManaValue", + "scope": { + "type": "Target" + } + } + } + }, + { + "type": "InZone", + "zone": "Battlefield" + } + ] + } } + }, + "comparator": "GE", + "rhs": { + "type": "Fixed", + "value": 1 } } }, - { - "type": "InZone", - "zone": "Battlefield" - } - ] + "optional_targeting": false, + "optional": false, + "forward_result": false + } + } + } + }, + "parsed": { + "effect": { + "type": "Unimplemented", + "name": "instead_clause_placeholder", + "description": null + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 2, + "source": { + "id": { + "item": 0, + "ordinal": 3 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 191, + "end_byte": 210, + "precision": "ChainRelative", + "ordinal_within_span": 2 + }, + "fragment": "Untap that creature" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "SetTapState", + "target": { + "type": "ParentTarget" + }, + "scope": { + "type": "Single" + }, + "state": { + "type": "Untap" } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 3, + "source": { + "id": { + "item": 0, + "ordinal": 4 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 212, + "end_byte": 244, + "precision": "ChainRelative", + "ordinal_within_span": 3 + }, + "fragment": "It gains haste until end of turn" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null } }, - "comparator": "GE", - "rhs": { - "type": "Fixed", - "value": 1 - } + "parsed": { + "effect": { + "type": "GenericEffect", + "static_abilities": [ + { + "mode": "Continuous", + "affected": { + "type": "SelfRef" + }, + "modifications": [ + { + "type": "AddKeyword", + "keyword": "Haste" + } + ], + "condition": null, + "affected_zone": null, + "effect_zone": null, + "active_zones": [], + "characteristic_defining": false, + "description": "gain haste" + } + ], + "duration": "UntilEndOfTurn", + "target": null + }, + "duration": "UntilEndOfTurn", + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } - }, - "optional_targeting": false, - "optional": false, - "forward_result": false + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null }, - "duration": "UntilEndOfTurn", - "description": "Gain control of target creature until end of turn. If you control a Villain with greater mana value than that creature, gain control of that creature until the end of your next turn instead. Untap that creature. It gains haste until end of turn.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [], + "root_transforms": [ + { + "SetMinXValue": 0 + }, + { + "SetDescription": "Gain control of target creature until end of turn. If you control a Villain with greater mana value than that creature, gain control of that creature until the end of your next turn instead. Untap that creature. It gains haste until end of turn." + } + ] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__experiment_one_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__experiment_one_ir.snap index 8f8f6adcff..70ecf1851b 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__experiment_one_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__experiment_one_ir.snap @@ -133,7 +133,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__figure_of_destiny_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__figure_of_destiny_ir.snap index 8894357035..16ecbc8e02 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__figure_of_destiny_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__figure_of_destiny_ir.snap @@ -144,7 +144,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } }, @@ -306,7 +307,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } }, @@ -479,7 +481,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__fog_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__fog_ir.snap index 98d8b99be0..249cb22b74 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__fog_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__fog_ir.snap @@ -91,7 +91,8 @@ expression: "&ir" "shell": { "sub_link": null }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_ir.snap index 781b0ce877..d261e879a2 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__follow_the_lumarets_ir.snap @@ -22,119 +22,394 @@ expression: "&ir" "fragment": "Infusion — Look at the top four cards of your library. You may reveal a creature or land card from among them and put it into your hand. If you gained life this turn, you may instead reveal two creature and/or land cards from among them and put them into your hand. Put the rest on the bottom of your library in a random order." }, "node": { - "PreLoweredSpell": { - "kind": "Spell", - "effect": { - "type": "Dig", - "player": { - "type": "Controller" - }, - "count": { - "type": "Fixed", - "value": 4 - }, - "destination": "Hand", - "keep_count": 2, - "up_to": false, - "filter": { - "type": "Or", - "filters": [ - { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": null, - "properties": [] + "Spell": { + "source_text": "Look at the top four cards of your library. You may reveal a creature or land card from among them and put it into your hand. If you gained life this turn, you may instead reveal two creature and/or land cards from among them and put them into your hand. Put the rest on the bottom of your library in a random order.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 42, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Look at the top four cards of your library" }, - { - "type": "Typed", - "type_filters": [ - "Land" - ], - "controller": null, - "properties": [] - } - ] - }, - "rest_destination": "Library", - "reveal": false, - "enter_tapped": false - }, - "cost": null, - "sub_ability": null, - "else_ability": { - "kind": "Spell", - "effect": { - "type": "Dig", - "player": { - "type": "Controller" + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Dig", + "player": { + "type": "Controller" + }, + "count": { + "type": "Fixed", + "value": 4 + }, + "destination": null, + "keep_count": null, + "up_to": false, + "filter": { + "type": "Any" + }, + "rest_destination": null, + "reveal": false, + "enter_tapped": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "count": { - "type": "Fixed", - "value": 4 + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 44, + "end_byte": 124, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "You may reveal a creature or land card from among them and put it into your hand" + }, + "disposition": { + "Continue": { + "continuation": { + "DigFromAmong": { + "quantity": { + "Up": { + "type": "Fixed", + "value": 1 + } + }, + "filter": { + "type": "Or", + "filters": [ + { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [] + }, + { + "type": "Typed", + "type_filters": [ + "Land" + ], + "controller": null, + "properties": [] + } + ] + }, + "destination": "Hand", + "rest_destination": null, + "enter_tapped": false, + "reveal_verb": true + } + } + } + }, + "parsed": { + "effect": { + "type": "RevealHand", + "target": { + "type": "Any" + }, + "card_filter": { + "type": "None" + }, + "count": null, + "reveal": true + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": true, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "destination": "Hand", - "keep_count": 1, - "up_to": true, - "filter": { - "type": "Or", - "filters": [ - { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": null, - "properties": [] + { + "id": 2, + "source": { + "id": { + "item": 0, + "ordinal": 3 }, - { - "type": "Typed", - "type_filters": [ - "Land" - ], - "controller": null, - "properties": [] + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 126, + "end_byte": 253, + "precision": "ChainRelative", + "ordinal_within_span": 2 + }, + "fragment": "If you gained life this turn, you may instead reveal two creature and/or land cards from among them and put them into your hand" + }, + "disposition": { + "ReplaceMeaning": { + "kind": { + "DigAlt": { + "kind": "Spell", + "effect": { + "type": "Dig", + "player": { + "type": "Controller" + }, + "count": { + "type": "Fixed", + "value": 4 + }, + "destination": "Hand", + "keep_count": 2, + "up_to": false, + "filter": { + "type": "Or", + "filters": [ + { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [] + }, + { + "type": "Typed", + "type_filters": [ + "Land" + ], + "controller": null, + "properties": [] + } + ] + }, + "rest_destination": null, + "reveal": false, + "enter_tapped": false + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": { + "type": "QuantityCheck", + "lhs": { + "type": "Ref", + "qty": { + "type": "LifeGainedThisTurn", + "player": { + "type": "Controller" + } + } + }, + "comparator": "GE", + "rhs": { + "type": "Fixed", + "value": 1 + } + }, + "optional_targeting": false, + "optional": false, + "forward_result": false + } + } } - ] + }, + "parsed": { + "effect": { + "type": "Dig", + "player": { + "type": "Controller" + }, + "count": { + "type": "Fixed", + "value": 4 + }, + "destination": "Hand", + "keep_count": 2, + "up_to": false, + "filter": { + "type": "Or", + "filters": [ + { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [] + }, + { + "type": "Typed", + "type_filters": [ + "Land" + ], + "controller": null, + "properties": [] + } + ] + }, + "rest_destination": null, + "reveal": false, + "enter_tapped": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "rest_destination": "Library", - "reveal": true, - "enter_tapped": false - }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false - }, - "duration": null, - "description": "Infusion — Look at the top four cards of your library. You may reveal a creature or land card from among them and put it into your hand. If you gained life this turn, you may instead reveal two creature and/or land cards from among them and put them into your hand. Put the rest on the bottom of your library in a random order.", - "target_prompt": null, - "condition": { - "type": "QuantityCheck", - "lhs": { - "type": "Ref", - "qty": { - "type": "LifeGainedThisTurn", - "player": { - "type": "Controller" - } + { + "id": 3, + "source": { + "id": { + "item": 0, + "ordinal": 4 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 255, + "end_byte": 315, + "precision": "ChainRelative", + "ordinal_within_span": 3 + }, + "fragment": "Put the rest on the bottom of your library in a random order" + }, + "disposition": { + "Continue": { + "continuation": { + "PutRest": { + "destination": "Library", + "reorder_all": false + } + } + } + }, + "parsed": { + "effect": { + "type": "PutAtLibraryPosition", + "target": { + "type": "TrackedSet", + "id": 0 + }, + "count": { + "type": "Fixed", + "value": 1 + }, + "position": { + "type": "Bottom" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null + }, + "die_results": [], + "root_transforms": [ + { + "SetMinXValue": 0 }, - "comparator": "GE", - "rhs": { - "type": "Fixed", - "value": 1 + { + "SetDescription": "Infusion — Look at the top four cards of your library. You may reveal a creature or land card from among them and put it into your hand. If you gained life this turn, you may instead reveal two creature and/or land cards from among them and put them into your hand. Put the rest on the bottom of your library in a random order." } - }, - "optional_targeting": false, - "optional": false, - "forward_result": false + ] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__ghost_lit_stalker_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__ghost_lit_stalker_ir.snap index 8707e46188..ecde05ee8f 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__ghost_lit_stalker_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__ghost_lit_stalker_ir.snap @@ -122,7 +122,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } }, @@ -252,7 +253,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__govern_the_guildless_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__govern_the_guildless_ir.snap index c870d88101..7c8adfb537 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__govern_the_guildless_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__govern_the_guildless_ir.snap @@ -22,34 +22,93 @@ expression: "&ir" "fragment": "Gain control of target monocolored creature." }, "node": { - "PreLoweredSpell": { - "kind": "Spell", - "effect": { - "type": "GainControl", - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": null, - "properties": [ - { - "type": "ColorCount", - "comparator": "EQ", - "count": 1 - } - ] - } + "Spell": { + "source_text": "Gain control of target monocolored creature.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 43, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Gain control of target monocolored creature" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "GainControl", + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [ + { + "type": "ColorCount", + "comparator": "EQ", + "count": 1 + } + ] + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": "Gain control of target monocolored creature.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "shell": { + "sub_link": null + }, + "die_results": [], + "root_transforms": [ + { + "SetMinXValue": 0 + }, + { + "SetDescription": "Gain control of target monocolored creature." + } + ] } } }, @@ -213,7 +272,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__guul_draz_assassin_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__guul_draz_assassin_ir.snap index b1c6cca93a..0f63e8d44b 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__guul_draz_assassin_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__guul_draz_assassin_ir.snap @@ -217,7 +217,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } }, @@ -403,7 +404,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__incinerate_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__incinerate_ir.snap index 818f3b09b1..c00e5c92c4 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__incinerate_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__incinerate_ir.snap @@ -22,66 +22,175 @@ expression: "&ir" "fragment": "~ deals 3 damage to any target. A creature dealt damage this way can't be regenerated this turn." }, "node": { - "PreLoweredSpell": { - "kind": "Spell", - "effect": { - "type": "DealDamage", - "amount": { - "type": "Fixed", - "value": 3 - }, - "target": { - "type": "Any" - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "GenericEffect", - "static_abilities": [ - { - "mode": "CantBeRegenerated", - "affected": { - "type": "ParentTarget" + "Spell": { + "source_text": "~ deals 3 damage to any target. A creature dealt damage this way can't be regenerated this turn.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 }, - "modifications": [ - { - "type": "AddStaticMode", - "mode": "CantBeRegenerated" + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 30, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "~ deals 3 damage to any target" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "DealDamage", + "amount": { + "type": "Fixed", + "value": 3 + }, + "target": { + "type": "Any" } - ], + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, "condition": null, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": null - } - ], - "duration": "UntilEndOfTurn", - "target": { - "type": "TrackedSet", - "id": 0 + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 32, + "end_byte": 95, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "A creature dealt damage this way can't be regenerated this turn" + }, + "disposition": { + "Absorb": { + "rider": { + "kind": "Spell", + "effect": { + "type": "GenericEffect", + "static_abilities": [ + { + "mode": "CantBeRegenerated", + "affected": { + "type": "ParentTarget" + }, + "modifications": [ + { + "type": "AddStaticMode", + "mode": "CantBeRegenerated" + } + ], + "condition": null, + "affected_zone": null, + "effect_zone": null, + "active_zones": [], + "characteristic_defining": false, + "description": null + } + ], + "duration": "UntilEndOfTurn", + "target": { + "type": "TrackedSet", + "id": 0 + } + }, + "cost": null, + "sub_ability": null, + "duration": "UntilEndOfTurn", + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "kind": "CantBeRegenerated" + } + }, + "parsed": { + "effect": { + "type": "Unimplemented", + "name": "cant_be_regenerated_rider_placeholder", + "description": "" + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } - }, - "cost": null, - "sub_ability": null, - "duration": "UntilEndOfTurn", - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "duration": null, - "description": "~ deals 3 damage to any target. A creature dealt damage this way can't be regenerated this turn.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "shell": { + "sub_link": null + }, + "die_results": [], + "root_transforms": [ + { + "SetMinXValue": 0 + }, + { + "SetDescription": "~ deals 3 damage to any target. A creature dealt damage this way can't be regenerated this turn." + } + ] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jade_mage_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jade_mage_ir.snap index b71d0b63eb..374f6587e7 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jade_mage_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jade_mage_ir.snap @@ -128,7 +128,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__joraga_treespeaker_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__joraga_treespeaker_ir.snap index 62a9a44d42..a51c8ed0ca 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__joraga_treespeaker_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__joraga_treespeaker_ir.snap @@ -193,7 +193,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } }, diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__liliana_the_repentant_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__liliana_the_repentant_ir.snap index 1728343bb2..f8ae6edf02 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__liliana_the_repentant_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__liliana_the_repentant_ir.snap @@ -395,7 +395,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__llanowar_elves_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__llanowar_elves_ir.snap index 1fcecc4582..cc2a2455b7 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__llanowar_elves_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__llanowar_elves_ir.snap @@ -101,7 +101,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__manamorphose_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__manamorphose_ir.snap index a2f5b7277c..cbcfb5d75f 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__manamorphose_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__manamorphose_ir.snap @@ -22,57 +22,148 @@ expression: "&ir" "fragment": "Add two mana in any combination of colors." }, "node": { - "PreLoweredSpell": { - "kind": "Spell", - "effect": { - "type": "Mana", - "produced": { - "type": "AnyCombination", - "count": { - "type": "Fixed", - "value": 2 + "Spell": { + "source_text": "Add two mana in any combination of colors. Draw a card.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 41, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Add two mana in any combination of colors" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Mana", + "produced": { + "type": "AnyCombination", + "count": { + "type": "Fixed", + "value": 2 + }, + "color_options": [ + "White", + "Blue", + "Black", + "Red", + "Green" + ] + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "color_options": [ - "White", - "Blue", - "Black", - "Red", - "Green" - ] - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "Draw", - "count": { - "type": "Fixed", - "value": 1 - }, - "target": { - "type": "Controller" + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 43, + "end_byte": 54, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "Draw a card" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Draw", + "count": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "Controller" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } - }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "sub_link": "SequentialSibling" + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null }, - "duration": null, - "description": "Add two mana in any combination of colors.\nDraw a card.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "is_mana_ability": true + "die_results": [], + "root_transforms": [ + { + "SetMinXValue": 0 + }, + { + "SetDescription": "Add two mana in any combination of colors.\nDraw a card." + } + ] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__mother_of_runes_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__mother_of_runes_ir.snap index 3123375c0d..5355063beb 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__mother_of_runes_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__mother_of_runes_ir.snap @@ -126,7 +126,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__questing_beast_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__questing_beast_ir.snap index f57223e754..aa0597ddaa 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__questing_beast_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__questing_beast_ir.snap @@ -184,7 +184,8 @@ expression: "&ir" "shell": { "sub_link": null }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } }, diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__repeat_offender_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__repeat_offender_ir.snap index dffb307397..024110e887 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__repeat_offender_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__repeat_offender_ir.snap @@ -192,7 +192,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__stoneforge_mystic_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__stoneforge_mystic_ir.snap index d6cfc9f3fc..fb505c0aae 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__stoneforge_mystic_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__stoneforge_mystic_ir.snap @@ -329,7 +329,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__swords_to_plowshares_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__swords_to_plowshares_ir.snap index 74a21a849b..0b3b484076 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__swords_to_plowshares_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__swords_to_plowshares_ir.snap @@ -22,61 +22,153 @@ expression: "&ir" "fragment": "Exile target creature. Its controller gains life equal to its power." }, "node": { - "PreLoweredSpell": { - "kind": "Spell", - "effect": { - "type": "ChangeZone", - "origin": null, - "destination": "Exile", - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": null, - "properties": [] - }, - "owner_library": false, - "enter_transformed": false, - "enter_tapped": false, - "enters_attacking": false - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "GainLife", - "amount": { - "type": "Ref", - "qty": { - "type": "Power", - "scope": { - "type": "Target" + "Spell": { + "source_text": "Exile target creature. Its controller gains life equal to its power.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 21, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Exile target creature" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null } - } + }, + "parsed": { + "effect": { + "type": "ChangeZone", + "origin": null, + "destination": "Exile", + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [] + }, + "owner_library": false, + "enter_transformed": false, + "enter_tapped": false, + "enters_attacking": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "player": { - "type": "ParentTargetController" + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 23, + "end_byte": 67, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "Its controller gains life equal to its power" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "GainLife", + "amount": { + "type": "Ref", + "qty": { + "type": "Power", + "scope": { + "type": "Target" + } + } + }, + "player": { + "type": "ParentTargetController" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } - }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "sub_link": "SequentialSibling" + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "duration": null, - "description": "Exile target creature. Its controller gains life equal to its power.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "shell": { + "sub_link": null + }, + "die_results": [], + "root_transforms": [ + { + "SetMinXValue": 0 + }, + { + "SetDescription": "Exile target creature. Its controller gains life equal to its power." + } + ] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__sylvan_safekeeper_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__sylvan_safekeeper_ir.snap index 0e95cb8c92..a54c3ee371 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__sylvan_safekeeper_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__sylvan_safekeeper_ir.snap @@ -133,7 +133,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__thespians_stage_generic_activated_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__thespians_stage_generic_activated_ir.snap index ccebc7b4cb..72b07f4e79 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__thespians_stage_generic_activated_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__thespians_stage_generic_activated_ir.snap @@ -102,7 +102,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } }, @@ -225,7 +226,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__touch_of_the_void_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__touch_of_the_void_ir.snap index 4972c1d3c1..83d0ee2d9b 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__touch_of_the_void_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__touch_of_the_void_ir.snap @@ -43,84 +43,193 @@ expression: "&ir" "fragment": "~ deals 3 damage to any target. If a creature dealt damage this way would die this turn, exile it instead." }, "node": { - "PreLoweredSpell": { - "kind": "Spell", - "effect": { - "type": "DealDamage", - "amount": { - "type": "Fixed", - "value": 3 - }, - "target": { - "type": "Any" - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "AddTargetReplacement", - "replacement": { - "event": "Moved", - "execute": { - "kind": "Spell", + "Spell": { + "source_text": "~ deals 3 damage to any target. If a creature dealt damage this way would die this turn, exile it instead.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 30, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "~ deals 3 damage to any target" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { "effect": { - "type": "ChangeZone", - "origin": "Battlefield", - "destination": "Exile", - "target": { - "type": "SelfRef" + "type": "DealDamage", + "amount": { + "type": "Fixed", + "value": 3 }, - "owner_library": false, - "enter_transformed": false, - "enter_tapped": false, - "enters_attacking": false + "target": { + "type": "Any" + } }, - "cost": null, - "sub_ability": null, "duration": null, - "description": null, - "target_prompt": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, "condition": null, - "optional_targeting": false, "optional": false, - "forward_result": false + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 32, + "end_byte": 105, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "If a creature dealt damage this way would die this turn, exile it instead" }, - "mode": { - "type": "Mandatory" + "disposition": { + "Absorb": { + "rider": { + "kind": "Spell", + "effect": { + "type": "AddTargetReplacement", + "replacement": { + "event": "Moved", + "execute": { + "kind": "Spell", + "effect": { + "type": "ChangeZone", + "origin": "Battlefield", + "destination": "Exile", + "target": { + "type": "SelfRef" + }, + "owner_library": false, + "enter_transformed": false, + "enter_tapped": false, + "enters_attacking": false + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "mode": { + "type": "Mandatory" + }, + "valid_card": { + "type": "SelfRef" + }, + "description": null, + "condition": null, + "destination_zone": "Graveyard", + "consume_on_apply": true, + "expiry": { + "type": "EndOfTurn" + } + }, + "target": { + "type": "Any" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "kind": "DieExile" + } }, - "valid_card": { - "type": "SelfRef" + "parsed": { + "effect": { + "type": "Unimplemented", + "name": "die_exile_rider_placeholder", + "description": null + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null }, - "description": null, + "boundary": "Sentence", "condition": null, - "destination_zone": "Graveyard", - "consume_on_apply": true, - "expiry": { - "type": "EndOfTurn" - } - }, - "target": { - "type": "Any" + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } - }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "duration": null, - "description": "~ deals 3 damage to any target. If a creature dealt damage this way would die this turn, exile it instead.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "shell": { + "sub_link": null + }, + "die_results": [], + "root_transforms": [ + { + "SetMinXValue": 0 + }, + { + "SetDescription": "~ deals 3 damage to any target. If a creature dealt damage this way would die this turn, exile it instead." + } + ] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__village_rites_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__village_rites_ir.snap index 9a4f4600cb..7da7dac837 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__village_rites_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__village_rites_ir.snap @@ -57,27 +57,86 @@ expression: "&ir" "fragment": "Draw two cards." }, "node": { - "PreLoweredSpell": { - "kind": "Spell", - "effect": { - "type": "Draw", - "count": { - "type": "Fixed", - "value": 2 + "Spell": { + "source_text": "Draw two cards.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 14, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Draw two cards" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Draw", + "count": { + "type": "Fixed", + "value": 2 + }, + "target": { + "type": "Controller" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null + }, + "die_results": [], + "root_transforms": [ + { + "SetMinXValue": 0 }, - "target": { - "type": "Controller" + { + "SetDescription": "Draw two cards." } - }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": "Draw two cards.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + ] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__vines_of_vastwood_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__vines_of_vastwood_ir.snap index b307f337f9..25f783a085 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__vines_of_vastwood_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__vines_of_vastwood_ir.snap @@ -88,77 +88,169 @@ expression: "&ir" "fragment": "Target creature can't be the target of spells or abilities your opponents control this turn. If this spell was kicked, that creature gets +4/+4 until end of turn." }, "node": { - "PreLoweredSpell": { - "kind": "Spell", - "effect": { - "type": "GenericEffect", - "static_abilities": [ + "Spell": { + "source_text": "Target creature can't be the target of spells or abilities your opponents control this turn. If this spell was kicked, that creature gets +4/+4 until end of turn.", + "body": { + "clauses": [ { - "mode": "Continuous", - "affected": { - "type": "ParentTarget" + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 91, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Target creature can't be the target of spells or abilities your opponents control this turn" }, - "modifications": [ - { - "type": "AddKeyword", - "keyword": "Hexproof" + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null } - ], + }, + "parsed": { + "effect": { + "type": "GenericEffect", + "static_abilities": [ + { + "mode": "Continuous", + "affected": { + "type": "ParentTarget" + }, + "modifications": [ + { + "type": "AddKeyword", + "keyword": "Hexproof" + } + ], + "condition": null, + "affected_zone": null, + "effect_zone": null, + "active_zones": [], + "characteristic_defining": false, + "description": "can't be the target of spells or abilities your opponents control" + } + ], + "duration": "UntilEndOfTurn", + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [] + } + }, + "duration": "UntilEndOfTurn", + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", "condition": null, - "affected_zone": null, - "effect_zone": null, - "active_zones": [], - "characteristic_defining": false, - "description": "can't be the target of spells or abilities your opponents control" + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + }, + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 93, + "end_byte": 161, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "If this spell was kicked, that creature gets +4/+4 until end of turn" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Pump", + "power": { + "type": "Fixed", + "value": 4 + }, + "toughness": { + "type": "Fixed", + "value": 4 + }, + "target": { + "type": "ParentTarget" + } + }, + "duration": "UntilEndOfTurn", + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": { + "type": "AdditionalCostPaid" + }, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } ], - "duration": "UntilEndOfTurn", - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": null, - "properties": [] - } - }, - "cost": null, - "sub_ability": { "kind": "Spell", - "effect": { - "type": "Pump", - "power": { - "type": "Fixed", - "value": 4 - }, - "toughness": { - "type": "Fixed", - "value": 4 - }, - "target": { - "type": "ParentTarget" - } - }, - "cost": null, - "sub_ability": null, - "duration": "UntilEndOfTurn", - "description": null, - "target_prompt": null, - "condition": { - "type": "AdditionalCostPaid" - }, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "sub_link": "SequentialSibling" + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null }, - "duration": "UntilEndOfTurn", - "description": "Target creature can't be the target of spells or abilities your opponents control this turn. If this spell was kicked, that creature gets +4/+4 until end of turn.", - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [], + "root_transforms": [ + { + "SetMinXValue": 0 + }, + { + "SetDescription": "Target creature can't be the target of spells or abilities your opponents control this turn. If this spell was kicked, that creature gets +4/+4 until end of turn." + } + ] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__walking_ballista_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__walking_ballista_ir.snap index fbfbf7e5ce..8aa5bb01ba 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__walking_ballista_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__walking_ballista_ir.snap @@ -169,7 +169,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } }, @@ -278,7 +279,8 @@ expression: "&ir" "ExtractManaSpendTrigger" ] }, - "die_results": [] + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/trigger.rs b/crates/engine/src/parser/oracle_ir/trigger.rs index 1207baec44..981cdf0cf7 100644 --- a/crates/engine/src/parser/oracle_ir/trigger.rs +++ b/crates/engine/src/parser/oracle_ir/trigger.rs @@ -208,8 +208,7 @@ impl VoteIr { ) } - /// Compatibility lowering for non-trigger callers that have not yet moved - /// to trigger-body IR. Trigger parsing uses [`Self::effect_chain`] instead. + /// Compatibility lowering for callers outside the native spell router. pub(crate) fn into_ability(self, kind: AbilityKind) -> AbilityDefinition { let vote = AbilityDefinition::new(kind, self.vote); match self.pre_vote_choose { @@ -274,12 +273,6 @@ impl PileIr { self.in_trigger, ) } - - /// Compatibility lowering for non-trigger callers that still consume a - /// lowered definition. Trigger parsing uses [`Self::effect_chain`] instead. - pub(crate) fn into_ability(self, kind: AbilityKind) -> AbilityDefinition { - AbilityDefinition::new(kind, self.effect) - } } /// Modifier fields extracted during IR production. diff --git a/crates/engine/src/parser/oracle_separate_piles.rs b/crates/engine/src/parser/oracle_separate_piles.rs index e28a0a6ca5..c64d1533c7 100644 --- a/crates/engine/src/parser/oracle_separate_piles.rs +++ b/crates/engine/src/parser/oracle_separate_piles.rs @@ -107,17 +107,6 @@ pub(crate) fn parse_separate_into_piles_ir( Some(PileIr::new(effect).with_source(text).with_context(ctx)) } -/// Compatibility entry point for non-trigger callers that still consume a -/// lowered definition. Trigger parsing uses [`parse_separate_into_piles_ir`] -/// so the root flows through ordinary trigger lowering. -pub(crate) fn parse_separate_into_piles( - text: &str, - kind: AbilityKind, -) -> Option { - parse_separate_into_piles_ir(text, kind, &ParseContext::default()) - .map(|pile| pile.into_ability(kind)) -} - /// CR 700.3 + CR 700.3a: Consume the "Each opponent separates the creatures /// they control into two piles." opener. Returns the remainder and the /// `VoterScope` for the partitioning subject. Currently supports the @@ -515,9 +504,10 @@ mod tests { let text = "Each opponent separates the creatures they control into two piles. \ For each opponent, you choose one of their piles. \ Each opponent sacrifices the creatures in their chosen pile."; - let def = parse_separate_into_piles(text, AbilityKind::Spell) + let pile = parse_separate_into_piles_ir(text, AbilityKind::Spell, &ParseContext::default()) .expect("Make an Example body parses"); - match &*def.effect { + let chain = pile.effect_chain(AbilityKind::Spell); + match &chain.clauses[0].parsed.effect { Effect::SeparateIntoPiles { partition_subject, chooser, @@ -545,9 +535,10 @@ mod tests { let text = "Reveal the top five cards of your library. \ An opponent separates those cards into two piles. \ Put one pile into your hand and the other into your graveyard."; - let def = parse_separate_into_piles(text, AbilityKind::Spell) + let pile = parse_separate_into_piles_ir(text, AbilityKind::Spell, &ParseContext::default()) .expect("Fact or Fiction body parses"); - match &*def.effect { + let chain = pile.effect_chain(AbilityKind::Spell); + match &chain.clauses[0].parsed.effect { Effect::SeparateIntoPiles { partition_subject, chooser, @@ -623,7 +614,10 @@ mod tests { #[test] fn rejects_non_pile_body() { let text = "Destroy target creature. Draw a card."; - assert!(parse_separate_into_piles(text, AbilityKind::Spell).is_none()); + assert!( + parse_separate_into_piles_ir(text, AbilityKind::Spell, &ParseContext::default()) + .is_none() + ); } /// CR 700.3 + CR 608.2c: Boneyard Parley mid-chain shape parses to diff --git a/crates/engine/src/parser/oracle_special.rs b/crates/engine/src/parser/oracle_special.rs index bfe1f0f827..895377a3ee 100644 --- a/crates/engine/src/parser/oracle_special.rs +++ b/crates/engine/src/parser/oracle_special.rs @@ -8,8 +8,8 @@ use nom::sequence::delimited; use nom::Parser; use crate::types::ability::{ - AbilityCost, AbilityDefinition, AbilityKind, Comparator, DieResultBranch, Effect, - SolveCondition, StaticDefinition, TargetFilter, TypedFilter, + AbilityCost, AbilityDefinition, AbilityKind, Comparator, Effect, SolveCondition, + StaticDefinition, TargetFilter, TypedFilter, }; use crate::types::keywords::{EscapeCost, Keyword}; use crate::types::mana::{ManaColor, ManaCost, ManaCostShard}; @@ -17,9 +17,7 @@ use crate::types::statics::StaticMode; use super::oracle_cost::{parse_or_separated_mana_costs, parse_single_cost}; use super::oracle_effect::imperative::try_parse_die_result_line; -use super::oracle_effect::{ - capitalize, lower_ability_ir, parse_ability_ir_standalone, parse_effect_chain, -}; +use super::oracle_effect::{capitalize, lower_ability_ir, parse_ability_ir_standalone}; use super::oracle_ir::ast::parsed_clause; use super::oracle_ir::effect_chain::{AbilityIr, AbilityShellIr, DieResultBranchIr, EffectChainIr}; use super::oracle_nom::bridge::nom_on_lower; @@ -211,62 +209,25 @@ pub(crate) fn normalize_self_refs_for_static(text: &str, card_name: &str) -> Str normalize_card_name_refs(text, card_name) } -/// CR 706: Walk the sub_ability chain of a parsed trigger/ability to find the -/// terminal `RollDie { results: [] }` node and attach die result branches -/// from subsequent oracle text lines. -pub(super) fn attach_die_result_branches_to_chain( - def: &mut AbilityDefinition, - lines: &[&str], - start_line: usize, -) -> usize { - let roll_die = find_terminal_roll_die(def); - let roll_die = match roll_die { - Some(roll_die) => roll_die, - None => return start_line, - }; - - let mut branches = Vec::new(); - let mut j = start_line; - while j < lines.len() { - let table_line = strip_reminder_text(lines[j].trim()); - if table_line.is_empty() { - j += 1; - continue; - } - if let Some((min, max, effect_text)) = try_parse_die_result_line(&table_line) { - let effect_text = strip_die_table_flavor_label(effect_text); - let branch_def = parse_effect_chain(effect_text, AbilityKind::Spell); - branches.push(DieResultBranch { - min, - max, - effect: Box::new(branch_def), - }); - j += 1; - } else { - break; - } +pub(crate) fn find_terminal_roll_die(def: &mut AbilityDefinition) -> Option<&mut Effect> { + if let Some(ref mut sub) = def.sub_ability { + return find_terminal_roll_die(sub); } - - if !branches.is_empty() { - if let Effect::RollDie { - ref mut results, .. - } = roll_die - { - *results = branches; - } + if matches!(&*def.effect, Effect::RollDie { results, .. } if results.is_empty()) { + return Some(&mut *def.effect); } - - j + None } -pub(crate) fn find_terminal_roll_die(def: &mut AbilityDefinition) -> Option<&mut Effect> { +/// CR 706.3b: A results table belongs to the first die-roll instruction in its +/// paragraph even when later instructions remain in that ability's chain. +pub(crate) fn find_result_table_roll_die(def: &mut AbilityDefinition) -> Option<&mut Effect> { if matches!(&*def.effect, Effect::RollDie { results, .. } if results.is_empty()) { return Some(&mut *def.effect); } - if let Some(ref mut sub) = def.sub_ability { - return find_terminal_roll_die(sub); - } - None + def.sub_ability + .as_deref_mut() + .and_then(find_result_table_roll_die) } /// CR 706.3b: Parse contiguous result-table rows into typed branch IR. @@ -340,6 +301,7 @@ pub(super) fn try_parse_die_roll_table( ..AbilityShellIr::default() }, die_results: branches, + root_transforms: vec![], }; Some((lower_ability_ir(&ir), next_line)) } diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index bb818f7732..826db7e2fa 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -9,7 +9,7 @@ use nom::Parser; use super::oracle_effect::conditions::source_saddled_filter; use super::oracle_effect::{ - attach_die_result_branches_before_finalization, condition_text_is_rehomeable, + attach_terminal_die_result_branches_before_finalization, condition_text_is_rehomeable, lower_effect_chain_ir, parse_effect_chain_ir, try_parse_reanimator_aura_etb_effect_ir, try_parse_reanimator_aura_grant_etb_effect_ir, }; @@ -1540,7 +1540,7 @@ fn lower_trigger_effect_chain( die_results: &[DieResultBranchIr], ) -> AbilityDefinition { let mut ability = lower_effect_chain_ir(chain_ir); - attach_die_result_branches_before_finalization(&mut ability, die_results); + attach_terminal_die_result_branches_before_finalization(&mut ability, die_results); crate::parser::oracle_effect::finalize_effect_chain(&mut ability); if effect_adds_mana_to_triggering_player(&modifiers.effect_lower) && matches!( From bc83a1e2a1fe3ed36b99a6e8f3c0964991ac5d79 Mon Sep 17 00:00:00 2001 From: Full Stack Developer <30417830+jsdevninja@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:23:08 -0500 Subject: [PATCH 27/63] test(engine): add coverage for Undying Malice granted trigger + edict sacrifice (#5942) (#6788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(engine): add coverage for Undying Malice granted trigger + edict sacrifice (#5942) Adds runtime coverage for a GenericEffect/Continuous/GrantTrigger static ability (Undying Malice) whose recipient later leaves the battlefield via a resolving edict-style Sacrifice effect, across every shape that seam had zero prior coverage for: single-target player choice, single-target mandatory fast path, casting the grant in response to an edict already on the stack, and a multiplayer simultaneous player_scope::All edict. Investigation of #5942 could not reproduce the reported "doesn't trigger with edict effects" defect on current main across any of these shapes — all four pass as written, exercising the CR 603.6d/603.10a leaves-the- battlefield LKI snapshot path. Added as durable coverage for a previously untested engine seam. * test(engine): correct Undying Malice edict fixture --------- Co-authored-by: matthewevans --- crates/engine/tests/integration/main.rs | 1 + .../undying_malice_edict_sacrifice_5942.rs | 324 ++++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 crates/engine/tests/integration/undying_malice_edict_sacrifice_5942.rs diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index be89baba81..446cbd2097 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1192,6 +1192,7 @@ mod triple_triad_owned_plus_lesser_mv_impulse; mod triumphant_chomp; mod tromokratis; mod umbra_stalker_graveyard_chroma_4066; +mod undying_malice_edict_sacrifice_5942; mod unless_pay_routes_through_authority; mod valakut_fireboar_switch_pt_on_attack; mod vanille_meld_optional_cost; diff --git a/crates/engine/tests/integration/undying_malice_edict_sacrifice_5942.rs b/crates/engine/tests/integration/undying_malice_edict_sacrifice_5942.rs new file mode 100644 index 0000000000..f173e707a4 --- /dev/null +++ b/crates/engine/tests/integration/undying_malice_edict_sacrifice_5942.rs @@ -0,0 +1,324 @@ +//! Coverage for issue #5942 ("Undying Malice effect doesn't trigger with +//! edict effects"): a creature granted a "dies" trigger by a +//! `GenericEffect`/`Continuous`/`GrantTrigger` static ability (Undying +//! Malice) must still have that trigger fire when the creature leaves the +//! battlefield via an "edict" sacrifice (a resolving `Effect::Sacrifice` +//! chosen by the affected player), not just a directly-targeted/known +//! sacrifice. +//! +//! CARD TEXT (verified against `client/public/card-data.json`): +//! Undying Malice — "Until end of turn, target creature gains \"When this +//! creature dies, return it to the battlefield tapped under its owner's +//! control with a +1/+1 counter on it.\"" +//! Diabolic Edict — "Target player sacrifices a creature of their choice." +//! Innocent Blood — "Each player sacrifices a creature of their choice." +//! +//! CR 603.6d + CR 603.10a: a leaves-the-battlefield triggered ability looks +//! back in time to the object's existence immediately before it left, +//! including abilities it had at that time due to a continuous effect — the +//! grant does not need to still be "live" after the object is gone. +//! +//! NOTE: this `GenericEffect`/`ParentTarget`/`GrantTrigger` seam had zero +//! direct test coverage before this file. Investigation of #5942 could not +//! reproduce the reported defect on current `main` across every plausible +//! edict shape (single-target player choice, single-target mandatory +//! fast-path, casting Malice in response to the edict already on the stack, +//! and a multiplayer simultaneous `player_scope: All` edict) — all four +//! below pass. These are added as durable coverage for a previously-untested +//! path, not as a regression test that failed before a fix; see the issue +//! comment for the full investigation. + +use engine::game::scenario::{GameRunner, GameScenario}; +use engine::types::counter::CounterType; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +const P0: PlayerId = PlayerId(0); +const P1: PlayerId = PlayerId(1); + +const UNDYING_MALICE_ORACLE: &str = "Until end of turn, target creature gains \"When this \ +creature dies, return it to the battlefield tapped under its owner's control with a +1/+1 \ +counter on it.\""; + +const DIABOLIC_EDICT_ORACLE: &str = "Target player sacrifices a creature of their choice."; + +const INNOCENT_BLOOD_ORACLE: &str = "Each player sacrifices a creature of their choice."; + +/// Add `count` units of `ty` mana to `player`'s pool — deterministic payment +/// without modelling lands (mirrors `chord_of_calling.rs::add_mana`). +fn add_mana(runner: &mut GameRunner, player: PlayerId, ty: ManaType, count: usize) { + let unit_source = ObjectId(0); + let target = runner + .state_mut() + .players + .iter_mut() + .find(|p| p.id == player) + .expect("player exists"); + for _ in 0..count { + target + .mana_pool + .add(ManaUnit::new(ty, unit_source, false, vec![])); + } +} + +/// RUNTIME — CR 603.6d + CR 603.10a. Undying Malice grants a +/// dies-trigger to a creature; a second player then Diabolic-Edicts the +/// controller, who has TWO eligible creatures and so must choose one via the +/// interactive `EffectZoneChoice` pool-choice path (not the mandatory-sac +/// fast path). Choosing the Undying-Malice creature must still return it to +/// the battlefield tapped with a +1/+1 counter. +#[test] +fn undying_malice_returns_creature_sacrificed_to_edict() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let victim = scenario.add_creature(P0, "Doomed Traveler", 1, 1).id(); + let _decoy = scenario.add_creature(P0, "Decoy Bear", 2, 2).id(); + + let malice = scenario + .add_spell_to_hand_from_oracle(P0, "Undying Malice", true, UNDYING_MALICE_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Black], + generic: 0, + }) + .id(); + + let edict = scenario + .add_spell_to_hand_from_oracle(P1, "Diabolic Edict", true, DIABOLIC_EDICT_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Black], + generic: 1, + }) + .id(); + + let mut runner = scenario.build(); + add_mana(&mut runner, P0, ManaType::Black, 1); + add_mana(&mut runner, P1, ManaType::Black, 2); + + // P0 casts Undying Malice targeting their own creature. + runner.cast(malice).target_object(victim).resolve(); + + // Hand priority to P1 so they may cast Diabolic Edict (CR 117.3c). + // `waiting_for` is the engine's authority for whose action is legal — a + // cast is rejected with `NotYourPriority` unless it agrees. + runner.state_mut().priority_player = P1; + runner.state_mut().waiting_for = WaitingFor::Priority { player: P1 }; + + // P1 casts Diabolic Edict targeting P0; P0 has two eligible creatures, so + // the sacrifice pauses at `EffectZoneChoice` and P0 chooses `victim`. + let outcome = runner + .cast(edict) + .target_player(P0) + .effect_zone(&[victim]) + .resolve(); + + outcome.assert_zone(&[victim], Zone::Battlefield); + + let obj = &outcome.state().objects[&victim]; + assert!(obj.tapped, "Undying Malice returns the creature tapped"); + assert_eq!( + obj.counters.get(&CounterType::Plus1Plus1).copied(), + Some(1), + "Undying Malice returns the creature with exactly one +1/+1 counter" + ); +} + +/// RUNTIME (mandatory fast path) — same as above, but `victim` is P0's ONLY +/// creature, so `resolve_sacrifice_scope`'s `eligible.len() <= count` branch +/// takes the synchronous fast path (no `EffectZoneChoice` pause) rather than +/// the interactive pool-choice path exercised above. +#[test] +fn undying_malice_returns_creature_sacrificed_to_mandatory_edict() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let victim = scenario.add_creature(P0, "Doomed Traveler", 1, 1).id(); + + let malice = scenario + .add_spell_to_hand_from_oracle(P0, "Undying Malice", true, UNDYING_MALICE_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Black], + generic: 0, + }) + .id(); + + let edict = scenario + .add_spell_to_hand_from_oracle(P1, "Diabolic Edict", true, DIABOLIC_EDICT_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Black], + generic: 1, + }) + .id(); + + let mut runner = scenario.build(); + add_mana(&mut runner, P0, ManaType::Black, 1); + add_mana(&mut runner, P1, ManaType::Black, 2); + + runner.cast(malice).target_object(victim).resolve(); + + runner.state_mut().priority_player = P1; + runner.state_mut().waiting_for = WaitingFor::Priority { player: P1 }; + + let outcome = runner.cast(edict).target_player(P0).resolve(); + + outcome.assert_zone(&[victim], Zone::Battlefield); + + let obj = &outcome.state().objects[&victim]; + assert!(obj.tapped, "Undying Malice returns the creature tapped"); + assert_eq!( + obj.counters.get(&CounterType::Plus1Plus1).copied(), + Some(1), + "Undying Malice returns the creature with exactly one +1/+1 counter" + ); +} + +/// RUNTIME (`player_scope: All` — "each player sacrifices") — Innocent +/// Blood's `player_scope`-looped sacrifice moves TWO creatures from TWO +/// different controllers "simultaneously" (CR 101.4 / CR 603.3b), a +/// structurally different path (`player_scope_sacrifice_step` + +/// `mark_simultaneous_departures`) from the single-target Diabolic Edict +/// tests above. P0's creature carries the Undying Malice grant; P1's does +/// not. Only P0's creature should return. +#[test] +fn undying_malice_returns_creature_sacrificed_to_each_player_edict() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let victim = scenario.add_creature(P0, "Doomed Traveler", 1, 1).id(); + let opponent_creature = scenario.add_creature(P1, "Bear", 2, 2).id(); + + let malice = scenario + .add_spell_to_hand_from_oracle(P0, "Undying Malice", true, UNDYING_MALICE_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Black], + generic: 0, + }) + .id(); + + let blood = scenario + .add_spell_to_hand_from_oracle(P1, "Innocent Blood", false, INNOCENT_BLOOD_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Black], + generic: 0, + }) + .id(); + + let mut runner = scenario.build(); + // Innocent Blood is a sorcery, castable only by the active player (CR + // 307.1) — P1 is active so they may cast it on their own main phase. + runner.state_mut().active_player = P1; + add_mana(&mut runner, P0, ManaType::Black, 1); + add_mana(&mut runner, P1, ManaType::Black, 1); + + // P0 casts the instant Undying Malice on P1's turn (CR 117.1b). + runner.state_mut().priority_player = P0; + runner.state_mut().waiting_for = WaitingFor::Priority { player: P0 }; + runner.cast(malice).target_object(victim).resolve(); + + runner.state_mut().priority_player = P1; + runner.state_mut().waiting_for = WaitingFor::Priority { player: P1 }; + + let outcome = runner.cast(blood).resolve(); + + outcome.assert_zone(&[victim], Zone::Battlefield); + outcome.assert_zone(&[opponent_creature], Zone::Graveyard); + + let obj = &outcome.state().objects[&victim]; + assert!(obj.tapped, "Undying Malice returns the creature tapped"); + assert_eq!( + obj.counters.get(&CounterType::Plus1Plus1).copied(), + Some(1), + "Undying Malice returns the creature with exactly one +1/+1 counter" + ); +} + +/// RUNTIME (respond-to-the-edict-on-the-stack) — the canonical real-game +/// pattern: an opponent's edict is ALREADY on the stack targeting the +/// controller, who casts Undying Malice in response on their only creature to +/// protect it, then passes. Malice resolves first (LIFO), granting the dies +/// trigger; the edict then resolves and forces the mandatory sacrifice. +#[test] +fn undying_malice_cast_in_response_to_edict_on_stack() { + use engine::game::zones::create_object; + use engine::parser::oracle::parse_oracle_text; + use engine::types::ability::{ResolvedAbility, TargetRef}; + use engine::types::card_type::CoreType; + use engine::types::game_state::{CastingVariant, StackEntry, StackEntryKind}; + use engine::types::identifiers::CardId; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let victim = scenario.add_creature(P0, "Doomed Traveler", 1, 1).id(); + + let malice = scenario + .add_spell_to_hand_from_oracle(P0, "Undying Malice", true, UNDYING_MALICE_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Black], + generic: 0, + }) + .id(); + + let mut runner = scenario.build(); + add_mana(&mut runner, P0, ManaType::Black, 1); + + // P1's Diabolic Edict already on the stack, targeting P0. + let edict_parsed = parse_oracle_text( + DIABOLIC_EDICT_ORACLE, + "Diabolic Edict", + &[], + &["Instant".to_string()], + &[], + ); + let edict_id = create_object( + runner.state_mut(), + CardId(9001), + P1, + "Diabolic Edict".to_string(), + Zone::Stack, + ); + let edict_ability = ResolvedAbility::new( + edict_parsed.abilities[0].effect.as_ref().clone(), + vec![TargetRef::Player(P0)], + edict_id, + P1, + ); + { + let edict_obj = runner.state_mut().objects.get_mut(&edict_id).unwrap(); + edict_obj.card_types.core_types = vec![CoreType::Instant]; + } + runner.state_mut().stack.push_back(StackEntry { + id: edict_id, + source_id: edict_id, + controller: P1, + kind: StackEntryKind::Spell { + card_id: CardId(9001), + ability: Some(Box::new(edict_ability)), + casting_variant: CastingVariant::Normal, + actual_mana_spent: 0, + }, + }); + + // Hand P0 the priority window to respond. + runner.state_mut().priority_player = P0; + runner.state_mut().waiting_for = WaitingFor::Priority { player: P0 }; + + // P0 casts Undying Malice in response, targeting their only creature. + // `resolve()` drives the whole remaining stack: Malice resolves first + // (LIFO), then the Edict resolves and forces the mandatory sacrifice. + let outcome = runner.cast(malice).target_object(victim).resolve(); + + outcome.assert_zone(&[victim], Zone::Battlefield); + + let obj = &outcome.state().objects[&victim]; + assert!(obj.tapped, "Undying Malice returns the creature tapped"); + assert_eq!( + obj.counters.get(&CounterType::Plus1Plus1).copied(), + Some(1), + "Undying Malice returns the creature with exactly one +1/+1 counter" + ); +} From 3f276a584a6aa3d207504d810119a501b40bb2e6 Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:55:56 -0700 Subject: [PATCH 28/63] fix(engine): restore CR-correct repeated optional payments (#6800) * fix(engine): restore sequential repeated optional payments - create reflexive triggers only after successful payments\n- remove one-dialog modal payment plumbing and UI cap\n- retain positional Spree mode-cost parsing * fix(engine): remove stale repeated-payment mode cleanup Normal triggered modal completion does not own the sequential payment frame. * fix(engine): remove stale repeated-payment plumbing Drop the unused event argument and obsolete mode-handler import. * fix(engine): reject tagged batched payment snapshots Detect the tagged ResolutionFrame wire payload before deserializing it. * fix(engine): restore modal activation mana import ManaCost::NoCost remains required by activated modal flow. * test(engine): fix repeated-payment test borrows --------- Co-authored-by: matthewevans --- client/src/adapter/types.ts | 6 - .../src/components/modal/ModeChoiceModal.tsx | 70 +- .../modal/__tests__/ModeChoiceModal.test.tsx | 109 +--- client/src/i18n/locales/de/game.json | 2 - client/src/i18n/locales/en/game.json | 2 - client/src/i18n/locales/es/game.json | 2 - client/src/i18n/locales/fr/game.json | 2 - client/src/i18n/locales/it/game.json | 2 - client/src/i18n/locales/pl/game.json | 2 - client/src/i18n/locales/pt/game.json | 2 - crates/engine/src/ai_support/candidates.rs | 28 - crates/engine/src/game/ability_rw.rs | 1 - crates/engine/src/game/ability_scan.rs | 1 - crates/engine/src/game/effects/mod.rs | 229 +------ crates/engine/src/game/engine_modes.rs | 57 -- crates/engine/src/game/marksman_tests.rs | 608 ++++-------------- crates/engine/src/game/triggers.rs | 1 - .../triggers_push_first_contract_tests.rs | 2 - crates/engine/src/parser/oracle_modal.rs | 1 - crates/engine/src/types/ability.rs | 7 - crates/engine/src/types/resolution.rs | 67 +- crates/mtgish-import/src/convert/mod.rs | 3 - crates/server-core/src/filter.rs | 1 - 23 files changed, 192 insertions(+), 1013 deletions(-) diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 7cdfac4cc3..ec938837a8 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1554,12 +1554,6 @@ export interface ModalChoice { allow_repeat_modes: boolean; /** Per-mode additional mana costs (Spree). Empty/absent for standard modal spells. */ mode_costs?: ManaCost[]; - /** - * CR 118.1 + CR 702.172a: When `mode_costs` is present, the engine-probed - * maximum number of mode selections affordable with current mana. The UI must - * use this — not mana-pool size or pip counts — to gate selection. - */ - max_affordable_selections?: number; /** * CR 700.2i: Per-mode pawprint weights for points-budget modals ("up to N {P} * worth of modes"). Empty/absent for non-pawprint modals. When present, diff --git a/client/src/components/modal/ModeChoiceModal.tsx b/client/src/components/modal/ModeChoiceModal.tsx index 356832b7c3..eea6aeee70 100644 --- a/client/src/components/modal/ModeChoiceModal.tsx +++ b/client/src/components/modal/ModeChoiceModal.tsx @@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next"; import type { ModalChoice } from "../../adapter/types.ts"; import { useCanActForWaitingState } from "../../hooks/usePlayerId.ts"; import { useGameStore } from "../../stores/gameStore.ts"; -import { ManaCostPips } from "../mana/ManaCostPips.tsx"; import { DialogShell } from "./DialogShell.tsx"; export function ModeChoiceModal() { @@ -36,19 +35,10 @@ export function ModeChoiceModal() { // (Σ of chosen mode weights ≤ budget), NOT a mode count. The engine remains // the legality authority; this is pure display arithmetic over engine data. const pawprints = useMemo(() => modal?.mode_pawprints ?? [], [modal]); - const modeCosts = useMemo(() => modal?.mode_costs ?? [], [modal]); const isBudget = pawprints.length > 0; - const isModeCost = !isBudget && modeCosts.length > 0; const budget = modal?.max_choices ?? 0; - // CR 118.1 + CR 702.172a: per-mode payment caps come from the engine probe, - // bounded by the modal's legal selection cap (`max_choices`). - const maxAffordableSelections = Math.min( - modal?.max_affordable_selections ?? modal?.max_choices ?? 0, - modal?.max_choices ?? 0, - ); const spent = useMemo( - () => - isBudget ? selected.reduce((sum, i) => sum + (pawprints[i] ?? 0), 0) : 0, + () => (isBudget ? selected.reduce((sum, i) => sum + (pawprints[i] ?? 0), 0) : 0), [isBudget, selected, pawprints], ); @@ -64,10 +54,6 @@ export function ModeChoiceModal() { if (spentPrev + (pawprints[index] ?? 0) > budget) { return prev; } - } else if (isModeCost) { - if (prev.length >= maxAffordableSelections) { - return prev; - } } else if (prev.length >= modal.max_choices) { return prev; } @@ -82,17 +68,13 @@ export function ModeChoiceModal() { if (spentPrev + (pawprints[index] ?? 0) > budget) { return prev; } - } else if (isModeCost) { - if (prev.length >= maxAffordableSelections) { - return prev; - } } else if (prev.length >= modal.max_choices) { return prev; } return [...prev, index].sort((a, b) => a - b); }); }, - [modal, unavailableModes, isBudget, isModeCost, pawprints, budget, maxAffordableSelections], + [modal, unavailableModes, isBudget, pawprints, budget], ); const handleConfirm = useCallback(() => { @@ -102,14 +84,12 @@ export function ModeChoiceModal() { if (isBudget) { const spentSum = indices.reduce((sum, i) => sum + (pawprints[i] ?? 0), 0); if (spentSum > modal.max_choices) return; - } else if (isModeCost) { - if (indices.length > maxAffordableSelections) return; } else if (indices.length > modal.max_choices) { return; } dispatch({ type: "SelectModes", data: { indices } }); setSelected([]); - }, [modal, selected, dispatch, isBudget, isModeCost, pawprints, maxAffordableSelections]); + }, [modal, selected, dispatch, isBudget, pawprints]); const handleCancel = useCallback(() => { dispatch({ type: "CancelCast" }); @@ -120,24 +100,11 @@ export function ModeChoiceModal() { const canConfirm = selected.length >= modal.min_choices && - (isBudget - ? spent <= budget - : isModeCost - ? selected.length <= maxAffordableSelections - : selected.length <= modal.max_choices); + (isBudget ? spent <= budget : selected.length <= modal.max_choices); // A pawprint budget modal (budget 5, min weight 1) is never a single-choice // dispatch; only a genuine 1-of-1 count modal collapses to immediate dispatch. - const isSingleChoice = - !isBudget && !isModeCost && modal.min_choices === 1 && modal.max_choices === 1; - const canAdd = (index: number) => { - if (isBudget) { - return spent + (pawprints[index] ?? 0) <= budget; - } - if (isModeCost) { - return selected.length < maxAffordableSelections; - } - return selected.length < modal.max_choices; - }; + const isSingleChoice = !isBudget && modal.min_choices === 1 && modal.max_choices === 1; + const canAdd = (index: number) => !isBudget || spent + (pawprints[index] ?? 0) <= budget; // CR 602.2b vs CR 603.3c: only an ACTIVATED modal ability is cancellable at the // mode-choice step; a triggered one must resolve its mode. Read engine-provided @@ -166,12 +133,7 @@ export function ModeChoiceModal() { > {isBudget ? t("modeChoice.confirmBudget", { spent, budget }) - : isModeCost - ? t("modeChoice.confirmModeCost", { - selected: selected.length, - max: maxAffordableSelections, - }) - : t("modeChoice.confirm", { selected: selected.length, count: modal.max_choices })} + : t("modeChoice.confirm", { selected: selected.length, count: modal.max_choices })} )} {!isSingleChoice && selected.length > 0 && ( @@ -197,14 +159,7 @@ export function ModeChoiceModal() { )} - {isModeCost && modeCosts[index] && ( - - - - )} {desc} {isUnavailable && ( {t("modeChoice.alreadyChosen")} diff --git a/client/src/components/modal/__tests__/ModeChoiceModal.test.tsx b/client/src/components/modal/__tests__/ModeChoiceModal.test.tsx index f607dab7ce..d13fbd82b0 100644 --- a/client/src/components/modal/__tests__/ModeChoiceModal.test.tsx +++ b/client/src/components/modal/__tests__/ModeChoiceModal.test.tsx @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ModalChoice, WaitingFor } from "../../../adapter/types.ts"; import { useGameStore } from "../../../stores/gameStore.ts"; -import { buildGameState, buildPendingCast, buildPlayers } from "../../../test/factories/gameStateFactory.ts"; +import { buildGameState, buildPendingCast } from "../../../test/factories/gameStateFactory.ts"; import { ModeChoiceModal } from "../ModeChoiceModal.tsx"; const dispatchMock = vi.fn(); @@ -98,111 +98,4 @@ describe("ModeChoiceModal", () => { expect(dispatchMock).toHaveBeenCalledWith({ type: "CancelCast" }); }); - it("caps per-mode payment selection using engine max_affordable_selections, not pool size", () => { - const modal: ModalChoice = { - min_choices: 0, - max_choices: 3, - max_affordable_selections: 1, - mode_count: 3, - mode_descriptions: ["Mode A", "Mode B", "Mode C"], - allow_repeat_modes: false, - mode_costs: [ - { type: "Cost", generic: 1, shards: ["R"] }, - { type: "Cost", generic: 1, shards: ["R"] }, - { type: "Cost", generic: 1, shards: ["R"] }, - ], - }; - const gameState = buildGameState({ - objects: {}, - priority_player: 0, - players: buildPlayers([ - { - id: 0, - mana_pool: { - mana: [ - { color: "Red", source_id: 1, pip_id: 1, snow: false, restrictions: [] }, - { color: "Red", source_id: 2, pip_id: 2, snow: false, restrictions: [] }, - { color: "Red", source_id: 3, pip_id: 3, snow: false, restrictions: [] }, - ], - }, - }, - 1, - ]), - waiting_for: { - type: "AbilityModeChoice", - data: { - player: 0, - modal, - source_id: 90, - mode_abilities: [], - is_activated: false, - }, - }, - }); - - useGameStore.setState({ - gameState, - waitingFor: gameState.waiting_for, - dispatch: dispatchMock, - }); - - render(); - - fireEvent.click(screen.getByText("Mode A")); - fireEvent.click(screen.getByText("Mode B")); - expect(screen.getByRole("button", { name: "Confirm (1/1 modes)" })).not.toBeDisabled(); - fireEvent.click(screen.getByRole("button", { name: "Confirm (1/1 modes)" })); - expect(dispatchMock).toHaveBeenCalledWith({ - type: "SelectModes", - data: { indices: [0] }, - }); - }); - - it("caps repeatable mode-cost selection at modal.max_choices", () => { - const modal: ModalChoice = { - min_choices: 0, - max_choices: 2, - max_affordable_selections: 3, - mode_count: 2, - mode_descriptions: ["Mode A", "Mode B"], - allow_repeat_modes: true, - mode_costs: [ - { type: "Cost", generic: 1, shards: [] }, - { type: "Cost", generic: 1, shards: [] }, - ], - }; - const gameState = buildGameState({ - objects: {}, - priority_player: 0, - players: buildPlayers([1, 1]), - waiting_for: { - type: "AbilityModeChoice", - data: { - player: 0, - modal, - source_id: 90, - mode_abilities: [], - is_activated: false, - }, - }, - }); - - useGameStore.setState({ - gameState, - waitingFor: gameState.waiting_for, - dispatch: dispatchMock, - }); - - render(); - - fireEvent.click(screen.getByText("Mode A")); - fireEvent.click(screen.getByText("Mode A")); - fireEvent.click(screen.getByText("Mode B")); - expect(screen.getByRole("button", { name: "Confirm (2/2 modes)" })).not.toBeDisabled(); - fireEvent.click(screen.getByRole("button", { name: "Confirm (2/2 modes)" })); - expect(dispatchMock).toHaveBeenCalledWith({ - type: "SelectModes", - data: { indices: [0, 0] }, - }); - }); }); diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index ff65f578dd..fb7f3e5988 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -1948,12 +1948,10 @@ "eyebrowAbility": "Fähigkeitsmodi", "eyebrowSpell": "Zauberspruchmodi", "subtitle": "Wähle den oder die anzuwendenden Modi.", - "subtitleModeCost": "Gewählte Modi: {{selected}} / {{max}} bezahlbar.", "chooseExact": "Wähle {{count}}", "chooseRange": "Wähle {{min}} bis {{max}}", "confirm": "Bestätigen ({{selected}}/{{count}})", "confirmBudget": "Bestätigen ({{spent}}/{{budget}} {P})", - "confirmModeCost": "Bestätigen ({{selected}}/{{max}} Modi)", "clear": "Leeren", "alreadyChosen": "(bereits gewählt)" }, diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index 6054647b76..2276c376b6 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -1985,12 +1985,10 @@ "eyebrowAbility": "Ability Modes", "eyebrowSpell": "Spell Modes", "subtitle": "Select the mode or modes to apply.", - "subtitleModeCost": "Selected modes: {{selected}} / {{max}} affordable.", "chooseExact": "Choose {{count}}", "chooseRange": "Choose {{min}} to {{max}}", "confirm": "Confirm ({{selected}}/{{count}})", "confirmBudget": "Confirm ({{spent}}/{{budget}} {P})", - "confirmModeCost": "Confirm ({{selected}}/{{max}} modes)", "clear": "Clear", "alreadyChosen": "(already chosen)" }, diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index c23d5554b8..3d15c692c8 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -1948,12 +1948,10 @@ "eyebrowAbility": "Modos de habilidad", "eyebrowSpell": "Modos de hechizo", "subtitle": "Selecciona el modo o los modos a aplicar.", - "subtitleModeCost": "Modos seleccionados: {{selected}} / {{max}} asequibles.", "chooseExact": "Elige {{count}}", "chooseRange": "Elige de {{min}} a {{max}}", "confirm": "Confirmar ({{selected}}/{{count}})", "confirmBudget": "Confirmar ({{spent}}/{{budget}} {P})", - "confirmModeCost": "Confirmar ({{selected}}/{{max}} modos)", "clear": "Limpiar", "alreadyChosen": "(ya elegido)" }, diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index ee146a5ec8..4435e8d67d 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -1948,12 +1948,10 @@ "eyebrowAbility": "Modes de capacité", "eyebrowSpell": "Modes de sort", "subtitle": "Sélectionnez le ou les modes à appliquer.", - "subtitleModeCost": "Modes sélectionnés : {{selected}} / {{max}} abordables.", "chooseExact": "Choisissez {{count}}", "chooseRange": "Choisissez de {{min}} à {{max}}", "confirm": "Confirmer ({{selected}}/{{count}})", "confirmBudget": "Confirmer ({{spent}}/{{budget}} {P})", - "confirmModeCost": "Confirmer ({{selected}}/{{max}} modes)", "clear": "Effacer", "alreadyChosen": "(déjà choisi)" }, diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index c158c8729e..30a3c768c1 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -1948,12 +1948,10 @@ "eyebrowAbility": "Modalità abilità", "eyebrowSpell": "Modalità magia", "subtitle": "Seleziona la modalità o le modalità da applicare.", - "subtitleModeCost": "Modalità selezionate: {{selected}} / {{max}} pagabili.", "chooseExact": "Scegli {{count}}", "chooseRange": "Scegli da {{min}} a {{max}}", "confirm": "Conferma ({{selected}}/{{count}})", "confirmBudget": "Conferma ({{spent}}/{{budget}} {P})", - "confirmModeCost": "Conferma ({{selected}}/{{max}} modalità)", "clear": "Cancella", "alreadyChosen": "(già scelta)" }, diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index 2078599d1f..c7acb281b3 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -1948,12 +1948,10 @@ "eyebrowAbility": "Tryby zdolności", "eyebrowSpell": "Tryby czaru", "subtitle": "Wybierz tryb lub tryby do zastosowania.", - "subtitleModeCost": "Wybrane tryby: {{selected}} / {{max}} do opłacenia.", "chooseExact": "Wybierz {{count}}", "chooseRange": "Wybierz od {{min}} do {{max}}", "confirm": "Potwierdź ({{selected}}/{{count}})", "confirmBudget": "Potwierdź ({{spent}}/{{budget}} {P})", - "confirmModeCost": "Potwierdź ({{selected}}/{{max}} trybów)", "clear": "Wyczyść", "alreadyChosen": "(już wybrane)" }, diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index 6355bbcc6c..be12235a48 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -1948,12 +1948,10 @@ "eyebrowAbility": "Modos da Habilidade", "eyebrowSpell": "Modos da Mágica", "subtitle": "Selecione o modo ou modos a aplicar.", - "subtitleModeCost": "Modos selecionados: {{selected}} / {{max}} pagáveis.", "chooseExact": "Escolha {{count}}", "chooseRange": "Escolha de {{min}} a {{max}}", "confirm": "Confirmar ({{selected}}/{{count}})", "confirmBudget": "Confirmar ({{spent}}/{{budget}} {P})", - "confirmModeCost": "Confirmar ({{selected}}/{{max}} modos)", "clear": "Limpar", "alreadyChosen": "(já escolhido)" }, diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index f88d83710f..a7b7e7a653 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -1798,7 +1798,6 @@ pub fn candidate_actions_broad_with_probe( modal, unavailable_modes, is_activated, - source_id, .. } => { let available: Vec = (0..modal.mode_count) @@ -1862,33 +1861,6 @@ pub fn candidate_actions_broad_with_probe( Some(*player), )); } - // CR 702.172a: Batched repeated-optional modals (Hawkeye) and Spree- - // shaped ability modals carry per-mode mana costs. Prune combinations - // the player cannot afford; the engine already capped max_choices. - if modal.mode_pawprints.is_empty() && !modal.mode_costs.is_empty() { - let local_probe = casting::PriorityCastProbe::new(state, *player); - actions.retain(|ca| match &ca.action { - GameAction::SelectModes { indices } => { - let total = indices.iter().fold( - crate::types::mana::ManaCost::zero(), - |acc, &idx| { - crate::game::restrictions::add_mana_cost( - &acc, - &modal.mode_costs[idx], - ) - }, - ); - casting::can_pay_cost_after_auto_tap_with_probe( - local_probe.state(), - *player, - *source_id, - &total, - Some(&local_probe), - ) - } - _ => true, - }); - } actions } WaitingFor::ConniveDiscard { diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index 015af4ed1e..4db0b41672 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -3929,7 +3929,6 @@ fn rw_modal_choice(m: &ModalChoice) -> RwProfile { allow_repeat_modes: _, constraints: _, mode_costs: _, - max_affordable_selections: _, mode_pawprints: _, entwine_cost: _, selection: _, diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index ce03d20a36..deb416008d 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -371,7 +371,6 @@ fn scan_modal_choice(m: &ModalChoice, mode: ScanMode) -> Axes { allow_repeat_modes: _, constraints: _, // cast-time modal-cap predicates (announcement-time, not resolution) mode_costs: _, - max_affordable_selections: _, mode_pawprints: _, entwine_cost: _, selection: _, diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index e460ef4aa2..9961801baf 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -5788,14 +5788,12 @@ fn is_synchronous_mana_pay_cost(effect: &Effect) -> bool { } /// CR 603.12a: Fire the repeated-optional-payment process and stash the -/// continuation. Modal reflexives (Hawkeye, Tranquil Frillback) batch payment -/// and mode choice into one `AbilityModeChoice` (CR 702.172a Spree-shaped -/// `mode_costs`). Non-modal reflexives keep the legacy per-iteration -/// `OptionalEffectChoice` driver. +/// continuation. Each payment is offered through `OptionalEffectChoice`; once +/// payment has completed, the reflexive trigger follows the normal triggered +/// modal placement and targeting flow. fn drive_repeated_optional_payment( state: &mut GameState, ability: &ResolvedAbility, - events: &mut Vec, ) -> Result<(), EffectError> { // CR 700.2d: the "up to N" payment budget. let n = match &ability.repeat_for { @@ -5811,144 +5809,11 @@ fn drive_repeated_optional_payment( return Ok(()); } - if repeated_optional_reflexive_is_modal(reflexive) { - return drive_batched_repeated_optional_modal_payment(state, ability, reflexive, n, events); - } - drive_sequential_repeated_optional_payment(state, ability, reflexive, n) } -fn repeated_optional_reflexive_is_modal(reflexive: &ResolvedAbility) -> bool { - reflexive.modal.is_some() && !reflexive.mode_abilities.is_empty() -} - -/// CR 702.172a + CR 603.12a: Batch "pay up to N × {cost}" with "choose up to -/// that many modes" into a single `AbilityModeChoice`. Affordability is probed -/// once at prompt time; `mode_costs` carries the per-mode payment unit for -/// display and `max_affordable_selections` stamps the engine-authoritative cap. -fn drive_batched_repeated_optional_modal_payment( - state: &mut GameState, - ability: &ResolvedAbility, - reflexive: &ResolvedAbility, - n: i32, - events: &mut Vec, -) -> Result<(), EffectError> { - let Effect::PayCost { - cost: AbilityCost::Mana { cost: payment_mana }, - .. - } = &ability.effect - else { - return Err(EffectError::InvalidParam( - "batched repeated optional payment requires a synchronous mana PayCost".to_string(), - )); - }; - - let player = ability.controller; - let source_id = ability.source_id; - let n_u32 = u32::try_from(n).unwrap_or(0); - let max_affordable = - max_affordable_mana_payments(state, player, source_id, payment_mana, n_u32); - let modal_template = reflexive - .modal - .as_ref() - .expect("modal reflexive checked by caller"); - let mode_count = modal_template.mode_count; - let max_choices = (n as usize).min(max_affordable as usize).min(mode_count); - - let mut payment_unit = ability.clone(); - payment_unit.repeat_for = None; - payment_unit.sub_ability = None; - payment_unit.optional = false; - payment_unit.condition = None; - - let mut modal = modal_template.clone(); - modal.mode_costs = vec![payment_mana.clone(); mode_count]; - modal.min_choices = 0; - modal.max_choices = max_choices; - modal.max_affordable_selections = Some(max_affordable.min(max_choices as u32)); - modal.dynamic_max_choices = None; - - let mut reflexive_prepared = reflexive.clone(); - apply_parent_chain_context(&mut reflexive_prepared, ability, None, state); - reflexive_prepared.modal = Some(modal.clone()); - - let trigger_description = reflexive_prepared - .description - .clone() - .or_else(|| ability.description.clone()); - let pending = crate::game::triggers::PendingTrigger { - source_id, - controller: player, - condition: None, - ability: Box::new(reflexive_prepared), - timestamp: state.turn_number, - target_constraints: reflexive.target_constraints.clone(), - distribute: None, - trigger_event: state.current_trigger_event.clone(), - modal: Some(modal), - mode_abilities: reflexive.mode_abilities.clone(), - description: trigger_description, - may_trigger_origin: None, - subject_match_count: freeze_reflexive_event_count(state, player, source_id), - die_result: state.die_result_this_resolution, - }; - let trigger_events = crate::game::triggers::take_pending_trigger_event_batch(state, &pending); - let pending_for_state = pending.clone(); - let entry_id = crate::game::triggers::push_pending_trigger_to_stack_with_event_batch( - state, - pending, - trigger_events, - events, - ); - state.pending_trigger = Some(Box::new(pending_for_state)); - state.pending_trigger_entry = Some(entry_id); - - state.push_repeated_optional_payment_frame(RepeatedOptionalPaymentFrame { - pending: Some(Box::new(PendingRepeatedOptionalPayment { - payment_unit: Box::new(payment_unit), - reflexive: Box::new(reflexive.clone()), - remaining: 0, - batched: true, - })), - optional_cost_payments_this_resolution: 0, - }); - - match crate::game::engine::begin_pending_trigger_target_selection(state) - .map_err(|e| EffectError::InvalidParam(e.to_string()))? - { - Some(waiting_for) => { - state.waiting_for = waiting_for; - Ok(()) - } - None => { - state - .take_active_repeated_optional_payment_frame() - .map_err(|error| EffectError::InvalidParam(error.to_string()))?; - Ok(()) - } - } -} - -/// CR 118.1: How many times `unit` can be paid from the current board/pool, -/// capped at `max_n`. Probed synchronously via auto-tap planning. -fn max_affordable_mana_payments( - state: &GameState, - player: PlayerId, - source_id: ObjectId, - unit: &ManaCost, - max_n: u32, -) -> u32 { - (0..=max_n) - .rev() - .find(|×| { - let total = pay::scale_mana_cost(unit, times); - crate::game::casting::can_pay_cost_after_auto_tap(state, player, source_id, &total) - }) - .unwrap_or(0) -} - -/// CR 603.12a: Legacy per-iteration `OptionalEffectChoice` driver for -/// repeated-optional processes whose reflexive is not a mode-choice modal. +/// CR 603.12a: Per-iteration `OptionalEffectChoice` driver for repeated +/// optional-payment processes. fn drive_sequential_repeated_optional_payment( state: &mut GameState, ability: &ResolvedAbility, @@ -5973,7 +5838,6 @@ fn drive_sequential_repeated_optional_payment( payment_unit: Box::new(payment_unit), reflexive: Box::new(reflexive.clone()), remaining: (n - 1) as u32, - batched: false, })), optional_cost_payments_this_resolution: 0, }); @@ -5986,85 +5850,6 @@ fn drive_sequential_repeated_optional_payment( Ok(()) } -/// CR 603.12a + CR 702.172a: Settle a batched repeated-optional payment when the -/// player submits `SelectModes`. Empty indices decline without paying; non-empty -/// indices pay `len × unit` via the single PayCost authority, then mode -/// resolution proceeds with K = len. -/// -/// `pending` is only cleared after a successful payment (or on empty decline). -/// A failed payment leaves the frame intact so the prompt remains retryable. -pub(super) fn settle_batched_repeated_optional_payment_on_mode_choice( - state: &mut GameState, - indices: &[usize], - events: &mut Vec, -) -> Result { - let is_batched = state - .active_repeated_optional_payment_frame() - .and_then(|frame| frame.pending.as_ref()) - .is_some_and(|pending| pending.batched); - if !is_batched { - return Ok(false); - } - - if indices.is_empty() { - let _ = state - .active_repeated_optional_payment_frame_mut() - .and_then(|frame| frame.pending.take()); - state - .take_active_repeated_optional_payment_frame() - .map_err(|error| EffectError::InvalidParam(error.to_string()))?; - crate::game::engine::drop_mid_construction_pending_trigger(state); - return Ok(true); - } - - let payment_count = indices.len(); - let mut payment = { - let frame = state - .active_repeated_optional_payment_frame() - .ok_or_else(|| { - EffectError::InvalidParam( - "batched repeated optional payment frame missing during settle".to_string(), - ) - })?; - let pending = frame.pending.as_ref().ok_or_else(|| { - EffectError::InvalidParam( - "batched repeated optional payment pending missing during settle".to_string(), - ) - })?; - (*pending.payment_unit).clone() - }; - if let Effect::PayCost { scale, .. } = &mut payment.effect { - *scale = Some(QuantityExpr::Fixed { - value: i32::try_from(payment_count).unwrap_or(i32::MAX), - }); - } else { - return Err(EffectError::InvalidParam( - "batched repeated optional payment unit must be PayCost".to_string(), - )); - } - - state.cost_payment_failed_flag = false; - resolve_ability_chain(state, &payment, events, 1)?; - if state.cost_payment_failed_flag { - // Leave `pending` in place — AbilityModeChoice stays live for retry/decline. - return Err(EffectError::InvalidParam( - "Cannot pay batched repeated optional cost".to_string(), - )); - } - - let frame = state - .active_repeated_optional_payment_frame_mut() - .ok_or_else(|| { - EffectError::InvalidParam( - "repeated optional-payment frame must remain active during batched payment" - .to_string(), - ) - })?; - frame.optional_cost_payments_this_resolution = u32::try_from(payment_count).unwrap_or(u32::MAX); - frame.pending = None; - Ok(true) -} - /// CR 603.12a + CR 608.2c: Resume a repeated-optional-payment process for one /// `DecideOptionalEffect`. On accept, pay the per-iteration cost (resolution- /// time mana payment is synchronous — auto-tap, never pauses) and count it @@ -6086,7 +5871,6 @@ pub(super) fn resolve_repeated_optional_payment_choice( payment_unit, reflexive, remaining, - batched: _, } = *pending; if accept { @@ -6130,7 +5914,6 @@ pub(super) fn resolve_repeated_optional_payment_choice( payment_unit, reflexive, remaining: remaining - 1, - batched: false, })), optional_cost_payments_this_resolution, }) @@ -9045,7 +8828,7 @@ fn resolve_chain_body( // generic `repeat_for` loop (which would re-resolve the reflexive per // payment). if is_repeated_optional_payment(ability) { - return drive_repeated_optional_payment(state, ability, events); + return drive_repeated_optional_payment(state, ability); } // CR 118.12 + CR 118.12a: "Effect unless [player] pays {cost}" — diff --git a/crates/engine/src/game/engine_modes.rs b/crates/engine/src/game/engine_modes.rs index 2efbd1a335..3ec919464a 100644 --- a/crates/engine/src/game/engine_modes.rs +++ b/crates/engine/src/game/engine_modes.rs @@ -10,7 +10,6 @@ use super::ability_utils::{ record_modal_mode_choices, selected_mode_labels, target_constraints_from_modal, validate_modal_indices, }; -use super::effects; use super::engine::EngineError; use super::engine_stack; use super::triggers; @@ -40,40 +39,6 @@ pub(super) fn handle_ability_mode_choice( validate_modal_indices(&modal, &indices, &unavailable_modes)?; - // Batched repeated-optional prompts snapshot `max_choices` and - // `max_affordable_selections` at offer time. Re-probe affordability on submit - // so a stale selection cannot take the payment path after the pool/board - // changed (CR 118.1 / CR 702.172a). - if !indices.is_empty() - && !modal.mode_costs.is_empty() - && state - .active_repeated_optional_payment_frame() - .and_then(|frame| frame.pending.as_ref()) - .is_some_and(|pending| pending.batched) - { - let total = indices.iter().fold(ManaCost::zero(), |acc, &idx| { - let mode_cost = modal - .mode_costs - .get(idx) - .cloned() - .unwrap_or_else(ManaCost::zero); - crate::game::restrictions::add_mana_cost(&acc, &mode_cost) - }); - if !casting::can_pay_cost_after_auto_tap(state, player, source_id, &total) { - return Err(EngineError::InvalidAction( - "Cannot afford selected ability modes".to_string(), - )); - } - } - - if effects::settle_batched_repeated_optional_payment_on_mode_choice(state, &indices, events) - .map_err(|e| EngineError::InvalidAction(e.to_string()))? - && indices.is_empty() - { - priority::clear_priority_passes(state); - return Ok(WaitingFor::Priority { player }); - } - record_modal_mode_choices(state, source_id, &modal, &indices); let mut resolved = @@ -110,30 +75,9 @@ pub(super) fn handle_ability_mode_choice( ) }?; - if !is_activated { - settle_completed_repeated_optional_payment_frame(state)?; - } - Ok(waiting_for) } -/// CR 603.12a: A repeated-payment frame retains K only until its reflexive -/// modal has consumed the dynamic cap. The frame is then an AfterChild owner -/// with no driver, so selection completion is its exact action boundary. -fn settle_completed_repeated_optional_payment_frame( - state: &mut GameState, -) -> Result<(), EngineError> { - if state - .active_repeated_optional_payment_frame() - .is_some_and(|frame| frame.pending.is_none()) - { - state - .take_active_repeated_optional_payment_frame() - .map_err(|error| EngineError::InvalidAction(error.to_string()))?; - } - Ok(()) -} - struct ActivatedModeChoice { player: crate::types::player::PlayerId, source_id: ObjectId, @@ -369,7 +313,6 @@ pub(super) fn resolve_random_modal_trigger( }, events, )?; - settle_completed_repeated_optional_payment_frame(state)?; Ok(Some(waiting_for)) } diff --git a/crates/engine/src/game/marksman_tests.rs b/crates/engine/src/game/marksman_tests.rs index 54408f5711..d68d0819c6 100644 --- a/crates/engine/src/game/marksman_tests.rs +++ b/crates/engine/src/game/marksman_tests.rs @@ -1,13 +1,4 @@ -//! Runtime + parser tests for the repeated-optional-payment + reflexive-modal -//! driver (Hawkeye, Master Marksman — "Trick Arrows"). CR 603.12a / CR 700.2d. -//! -//! These drive the real production seams: -//! - the parser (`parse_oracle_text`) for the modal AST (B4); -//! - the production trigger-resolution function (`resolve_ability_chain` at -//! depth 0, exactly as the engine resolves a stack trigger) to reach the -//! batched `WaitingFor::AbilityModeChoice`; -//! - the real action handler (`apply`) for `SelectModes`, so the changed -//! `WaitingFor`/`GameAction` route is exercised. +//! Production-path tests for repeated optional payments and their reflexive modal. #![cfg(test)] @@ -15,9 +6,7 @@ use crate::game::ability_utils::build_resolved_from_def; use crate::game::effects::resolve_ability_chain; use crate::game::scenario::GameScenario; use crate::parser::oracle::{parse_oracle_text, ParsedAbilities}; -use crate::types::ability::{ - AbilityCondition, Effect, ModalChoice, QuantityExpr, QuantityRef, TargetRef, -}; +use crate::types::ability::{AbilityCondition, Effect, QuantityExpr, QuantityRef, TargetRef}; use crate::types::actions::GameAction; use crate::types::game_state::{GameState, WaitingFor}; use crate::types::mana::{ManaType, ManaUnit}; @@ -31,19 +20,11 @@ const HAWKEYE_ORACLE: &str = "First strike, reach\n\ • Net — Target creature can't block this turn.\n\ • Explosive — Hawkeye deals 2 damage to target player.\n\ • Boomerang — Discard a card, then draw a card."; - const FRILLBACK_ORACLE: &str = "When this creature enters, you may pay {G} up to three times. \ When you pay this cost one or more times, choose up to that many —\n\ • Destroy target artifact or enchantment.\n\ • Exile target player's graveyard.\n\ • You gain 4 life."; - -const TWO_MODE_REPEAT_ORACLE: &str = - "When this creature enters, you may pay {1} up to three times. \ - When you pay this cost one or more times, choose up to that many —\n\ - • Deal 1 damage to any target.\n\ - • You gain 1 life."; - const P0: PlayerId = PlayerId(0); const P1: PlayerId = PlayerId(1); @@ -57,121 +38,11 @@ fn parse_hawkeye() -> ParsedAbilities { ) } -/// The reflexive sub-ability's modal (`when you do, choose up to that many`). -fn hawkeye_reflexive_modal(parsed: &ParsedAbilities) -> ModalChoice { - let taps = parsed - .triggers - .iter() - .find(|t| t.mode == TriggerMode::Taps) - .expect("Hawkeye has a Taps trigger"); - let execute = taps.execute.as_ref().expect("Taps trigger has an execute"); - let reflexive = execute - .sub_ability - .as_ref() - .expect("PayCost has a reflexive sub-ability"); - reflexive - .modal - .clone() - .expect("reflexive sub carries a modal") -} - -// ── B4 parser: the modal carries the resolution-local dynamic cap ──────── - -/// Revert discriminator: dropping the `"choose up to that many"` parser arm (or -/// the `ModalCountSpec::Dynamic { qty }` wiring) leaves the modal at the fixed -/// `(1, 1)` default with `dynamic_max_choices == None`, failing every assertion. -#[test] -fn hawkeye_modal_parses_with_resolution_local_dynamic_cap() { - let parsed = parse_hawkeye(); - let modal = hawkeye_reflexive_modal(&parsed); - - assert_eq!(modal.min_choices, 0, "min is 0 — may choose no modes"); - assert_eq!(modal.mode_count, 3, "Net / Explosive / Boomerang"); - assert_eq!( - modal.dynamic_max_choices, - Some(QuantityExpr::Ref { - qty: QuantityRef::TimesCostPaidThisResolution - }), - "the cap binds to the resolution-local repeated-payment count" - ); -} - -#[test] -fn tranquil_frillback_parses_as_repeated_green_payment_modal() { - let parsed = parse_oracle_text( - FRILLBACK_ORACLE, - "Tranquil Frillback", - &[], - &["Creature".to_string()], - &["Dinosaur".to_string()], - ); - let execute = parsed.triggers[0].execute.as_deref().expect("ETB execute"); - assert!(matches!(execute.effect.as_ref(), Effect::PayCost { .. })); - assert!(execute.optional); - assert_eq!(execute.repeat_for, Some(QuantityExpr::Fixed { value: 3 })); - let modal = execute.sub_ability.as_deref().expect("reflexive modal"); - assert_eq!(modal.condition, Some(AbilityCondition::WhenYouDo)); - assert_eq!( - modal - .modal - .as_ref() - .and_then(|modal| modal.dynamic_max_choices.clone()), - Some(QuantityExpr::Ref { - qty: QuantityRef::TimesCostPaidThisResolution - }) - ); -} - -#[test] -fn tranquil_frillback_paying_once_and_choosing_life_gains_four() { - let mut scenario = GameScenario::new(); - scenario.with_life(P0, 20); - let frillback = scenario - .add_creature_from_oracle(P0, "Tranquil Frillback", 3, 3, FRILLBACK_ORACLE) - .id(); - scenario.with_mana_pool( - P0, - vec![ManaUnit::new( - ManaType::Green, - crate::types::identifiers::ObjectId(9_999), - false, - vec![], - )], - ); - let mut runner = scenario.build(); - let parsed = parse_oracle_text( - FRILLBACK_ORACLE, - "Tranquil Frillback", - &[], - &["Creature".to_string()], - &["Dinosaur".to_string()], - ); - let execute = parsed.triggers[0].execute.as_deref().expect("ETB execute"); - let resolved = build_resolved_from_def(execute, frillback, P0); - resolve_ability_chain(runner.state_mut(), &resolved, &mut Vec::new(), 0) - .expect("resolve Frillback ETB"); - - assert!(matches!( - runner.state().waiting_for, - WaitingFor::AbilityModeChoice { .. } - )); - runner - .act(GameAction::SelectModes { indices: vec![2] }) - .expect("select Frillback life mode"); - runner.advance_until_stack_empty(); - assert_eq!(runner.state().players[0].life, 24); -} - -// ── Runtime harness ───────────────────────────────────────────────────── - -/// Hawkeye on P0's battlefield with `mana` colorless mana in pool, the Taps -/// trigger resolved through the production resolver (depth 0). Leaves the engine -/// at a batched `WaitingFor::AbilityModeChoice`. -fn hawkeye_runtime(mana: usize, p0_hand: &[&str]) -> crate::game::scenario::GameRunner { +fn hawkeye_runtime(mana: usize, hand: &[&str]) -> crate::game::scenario::GameRunner { let mut scenario = GameScenario::new(); scenario.with_life(P1, 20); - if !p0_hand.is_empty() { - scenario.with_cards_in_hand(P0, p0_hand); + if !hand.is_empty() { + scenario.with_cards_in_hand(P0, hand); } let hawkeye = scenario .add_creature_from_oracle(P0, "Hawkeye, Master Marksman", 3, 3, HAWKEYE_ORACLE) @@ -191,78 +62,31 @@ fn hawkeye_runtime(mana: usize, p0_hand: &[&str]) -> crate::game::scenario::Game ); } let mut runner = scenario.build(); - let parsed = parse_hawkeye(); - let taps = parsed + let execute = parsed .triggers .iter() - .find(|t| t.mode == TriggerMode::Taps) - .unwrap(); - let execute = taps.execute.as_ref().unwrap(); + .find(|trigger| trigger.mode == TriggerMode::Taps) + .and_then(|trigger| trigger.execute.as_ref()) + .expect("Hawkeye Taps trigger has an execute"); let resolved = build_resolved_from_def(execute, hawkeye, P0); - - let mut events = Vec::new(); - resolve_ability_chain(runner.state_mut(), &resolved, &mut events, 0) - .expect("trigger resolution"); - let _ = hawkeye; + resolve_ability_chain(runner.state_mut(), &resolved, &mut Vec::new(), 0) + .expect("resolve Hawkeye trigger"); runner } -fn two_mode_repeat_runtime(mana: usize) -> crate::game::scenario::GameRunner { - let mut scenario = GameScenario::new(); - let source = scenario - .add_creature_from_oracle(P0, "Two Mode Repeat", 2, 2, TWO_MODE_REPEAT_ORACLE) - .id(); - if mana > 0 { - scenario.with_mana_pool( - P0, - vec![ - ManaUnit::new( - ManaType::Colorless, - crate::types::identifiers::ObjectId(9_999), - false, - vec![], - ); - mana - ], - ); - } - let mut runner = scenario.build(); - let parsed = parse_oracle_text( - TWO_MODE_REPEAT_ORACLE, - "Two Mode Repeat", - &[], - &["Creature".to_string()], - &[], - ); - let execute = parsed.triggers[0].execute.as_deref().expect("ETB execute"); - let resolved = build_resolved_from_def(execute, source, P0); - resolve_ability_chain(runner.state_mut(), &resolved, &mut Vec::new(), 0) - .expect("trigger resolution"); +fn decide(runner: &mut crate::game::scenario::GameRunner, accept: bool) { runner + .act(GameAction::DecideOptionalEffect { accept }) + .expect("optional-payment choice accepted"); } -fn k_count(state: &GameState) -> u32 { +fn payment_count(state: &GameState) -> u32 { state .active_repeated_optional_payment_frame() .map_or(0, |frame| frame.optional_cost_payments_this_resolution) } -fn p0_pool_total(state: &GameState) -> usize { - state - .players - .iter() - .find(|p| p.id == P0) - .map(|p| p.mana_pool.total()) - .unwrap_or(0) -} - -fn repeated_payment_driver_is_pending(state: &GameState) -> bool { - state - .active_repeated_optional_payment_frame() - .is_some_and(|frame| frame.pending.is_some()) -} - fn modal_cap(state: &GameState) -> Option<(usize, usize)> { match &state.waiting_for { WaitingFor::AbilityModeChoice { modal, .. } => Some((modal.min_choices, modal.max_choices)), @@ -270,333 +94,149 @@ fn modal_cap(state: &GameState) -> Option<(usize, usize)> { } } -fn max_affordable_mode_selections(state: &GameState) -> Option { - match &state.waiting_for { - WaitingFor::AbilityModeChoice { modal, .. } => modal.max_affordable_selections, - _ => None, - } -} - -fn mode_cost_count(state: &GameState) -> Option { - match &state.waiting_for { - WaitingFor::AbilityModeChoice { modal, .. } => Some(modal.mode_costs.len()), - _ => None, - } -} - -// ── Batched payment + modal cap ─────────────────────────────────────────── - -#[test] -fn batched_modal_offered_once_with_static_cap_from_affordability() { - let runner = hawkeye_runtime(3, &[]); - assert!( - matches!( - runner.state().waiting_for, - WaitingFor::AbilityModeChoice { .. } - ), - "batched path emits one AbilityModeChoice, not sequential OptionalEffectChoice" - ); - assert_eq!(modal_cap(runner.state()), Some((0, 3))); - assert_eq!(max_affordable_mode_selections(runner.state()), Some(3)); - assert_eq!(mode_cost_count(runner.state()), Some(3)); - assert!( - repeated_payment_driver_is_pending(runner.state()), - "payment settles on SelectModes, not before the modal" - ); -} - -#[test] -fn afford_zero_caps_modal_at_zero_and_accepts_empty_decline() { - let mut runner = hawkeye_runtime(0, &[]); - assert_eq!(modal_cap(runner.state()), Some((0, 0))); - assert_eq!(max_affordable_mode_selections(runner.state()), Some(0)); - runner - .act(GameAction::SelectModes { indices: vec![] }) - .expect("decline with empty selection"); - assert_eq!(k_count(runner.state()), 0); - assert!( - runner - .state() - .active_repeated_optional_payment_frame() - .is_none(), - "decline clears the payment frame" - ); - assert!(matches!( - runner.state().waiting_for, - WaitingFor::Priority { .. } - )); -} - #[test] -fn afford_two_of_three_caps_modal_at_two() { - let runner = hawkeye_runtime(2, &[]); - assert_eq!(modal_cap(runner.state()), Some((0, 2))); - assert_eq!(max_affordable_mode_selections(runner.state()), Some(2)); -} - -#[test] -fn max_affordable_selections_is_capped_by_modal_max_choices() { - let runner = two_mode_repeat_runtime(3); - assert_eq!(modal_cap(runner.state()), Some((0, 2))); +fn parser_preserves_the_dynamic_reflexive_modal_cap() { + let hawkeye = parse_hawkeye(); + let trigger = hawkeye + .triggers + .iter() + .find(|trigger| trigger.mode == TriggerMode::Taps) + .expect("Hawkeye Taps trigger"); + let modal = trigger + .execute + .as_ref() + .and_then(|execute| execute.sub_ability.as_ref()) + .and_then(|reflexive| reflexive.modal.as_ref()) + .expect("reflexive modal"); + assert_eq!(modal.min_choices, 0); + assert_eq!(modal.mode_count, 3); assert_eq!( - max_affordable_mode_selections(runner.state()), - Some(2), - "affordability probe may exceed mode_count; serialized cap must not" + modal.dynamic_max_choices, + Some(QuantityExpr::Ref { + qty: QuantityRef::TimesCostPaidThisResolution + }) ); } #[test] -fn over_cap_submit_with_intact_pool_rejects_without_payment() { - let mut runner = hawkeye_runtime(2, &[]); - assert_eq!(p0_pool_total(runner.state()), 2); - let err = runner.act(GameAction::SelectModes { - indices: vec![0, 1, 2], - }); - assert!( - err.is_err(), - "three modes must be rejected when only two are affordable" - ); - assert_eq!( - p0_pool_total(runner.state()), - 2, - "no mana consumed on rejected over-cap submit" - ); - assert!( - matches!( - runner.state().waiting_for, - WaitingFor::AbilityModeChoice { .. } - ), - "batched prompt remains live" +fn tranquil_frillback_uses_the_sequential_payment_flow() { + let mut scenario = GameScenario::new(); + scenario.with_life(P0, 20); + let frillback = scenario + .add_creature_from_oracle(P0, "Tranquil Frillback", 3, 3, FRILLBACK_ORACLE) + .id(); + scenario.with_mana_pool( + P0, + vec![ManaUnit::new( + ManaType::Green, + crate::types::identifiers::ObjectId(9_999), + false, + vec![], + )], ); - assert!( - repeated_payment_driver_is_pending(runner.state()), - "repeated-payment frame survives rejected over-cap submit" + let mut runner = scenario.build(); + let parsed = parse_oracle_text( + FRILLBACK_ORACLE, + "Tranquil Frillback", + &[], + &["Creature".to_string()], + &["Dinosaur".to_string()], ); -} - -#[test] -fn select_three_modes_pays_three_mana() { - let mut runner = hawkeye_runtime(3, &[]); - runner - .act(GameAction::SelectModes { - indices: vec![0, 1, 2], - }) - .expect("select three modes"); + let execute = parsed.triggers[0].execute.as_deref().expect("ETB execute"); + assert!(matches!(execute.effect.as_ref(), Effect::PayCost { .. })); + assert!(execute.optional); + assert_eq!(execute.repeat_for, Some(QuantityExpr::Fixed { value: 3 })); assert_eq!( - p0_pool_total(runner.state()), + execute + .sub_ability + .as_deref() + .and_then(|reflexive| reflexive.condition.as_ref()), + Some(&AbilityCondition::WhenYouDo) + ); + resolve_ability_chain( + runner.state_mut(), + &build_resolved_from_def(execute, frillback, P0), + &mut Vec::new(), 0, - "three {{1}} payments were made in one step" - ); - assert!( - runner - .state() - .active_repeated_optional_payment_frame() - .is_none(), - "payment frame settles after batched mode resolution" - ); -} - -#[test] -fn select_one_mode_pays_one_mana() { - let mut runner = hawkeye_runtime(3, &[]); - assert_eq!(p0_pool_total(runner.state()), 3); + ) + .expect("resolve Frillback ETB"); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::OptionalEffectChoice { .. } + )); + decide(&mut runner, true); + decide(&mut runner, false); + assert_eq!(modal_cap(runner.state()), Some((0, 1))); runner - .act(GameAction::SelectModes { indices: vec![1] }) - .expect("select Explosive only"); - assert_eq!( - p0_pool_total(runner.state()), - 2, - "issue #6175: pay only for selected modes (1 of 3)" - ); - assert!( - matches!( - runner.state().waiting_for, - WaitingFor::TriggerTargetSelection { .. } - ), - "Explosive still needs a target after the single {{1}} payment" - ); -} - -#[test] -fn unaffordable_submit_after_pool_drain_keeps_batched_prompt() { - let mut runner = hawkeye_runtime(3, &[]); - for player in runner.state_mut().players.iter_mut() { - if player.id == P0 { - player.mana_pool.mana.clear(); - } - } - let err = runner.act(GameAction::SelectModes { indices: vec![0] }); - assert!( - err.is_err(), - "stale SelectModes must fail when the pool can no longer pay" - ); - assert!( - matches!( - runner.state().waiting_for, - WaitingFor::AbilityModeChoice { .. } - ), - "prompt remains live after rejected submit" - ); - assert!( - repeated_payment_driver_is_pending(runner.state()), - "batched pending must survive failed payment / affordability reject" - ); + .act(GameAction::SelectModes { indices: vec![2] }) + .expect("select life mode"); + runner.advance_until_stack_empty(); + assert_eq!(runner.state().players[0].life, 24); } #[test] -fn decline_empty_selection_skips_reflexive_at_k_zero() { +fn payments_are_offered_before_the_reflexive_modal_and_k_caps_it() { let mut runner = hawkeye_runtime(3, &[]); - runner - .act(GameAction::SelectModes { indices: vec![] }) - .expect("decline"); - - assert_eq!(k_count(runner.state()), 0); - assert!( - !matches!( - runner.state().waiting_for, - WaitingFor::AbilityModeChoice { .. } - ), - "no second modal after decline" - ); - assert!(runner - .state() - .active_repeated_optional_payment_frame() - .is_none()); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::OptionalEffectChoice { .. } + )); + decide(&mut runner, true); + decide(&mut runner, true); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::OptionalEffectChoice { .. } + )); + decide(&mut runner, true); + assert_eq!(payment_count(runner.state()), 3); + assert_eq!(modal_cap(runner.state()), Some((0, 3))); } #[test] -fn afford_one_caps_modal_at_one_not_three() { - let runner = hawkeye_runtime(1, &[]); +fn failed_later_payment_preserves_earlier_successes_and_triggers_once() { + let mut runner = hawkeye_runtime(1, &[]); + decide(&mut runner, true); + decide(&mut runner, true); + assert_eq!(payment_count(runner.state()), 1); assert_eq!(modal_cap(runner.state()), Some((0, 1))); } -// ── Mode resolution through apply ───────────────────────────────────────── - #[test] -fn boomerang_mode_resolves_once_through_apply() { - let mut runner = hawkeye_runtime(3, &["Mountain", "Forest"]); - let hand_before = p0_hand_size(runner.state()); - assert_eq!(p0_pool_total(runner.state()), 3); - +fn successful_payments_then_normal_trigger_targeting_resolve_once() { + let mut runner = hawkeye_runtime(2, &[]); + decide(&mut runner, true); + decide(&mut runner, true); + decide(&mut runner, false); + assert_eq!(modal_cap(runner.state()), Some((0, 2))); runner - .act(GameAction::SelectModes { indices: vec![2] }) - .expect("SelectModes accepted"); - - assert_eq!( - p0_pool_total(runner.state()), - 2, - "Boomerang alone costs one {{1}}" - ); - assert!( - !matches!( - runner.state().waiting_for, - WaitingFor::AbilityModeChoice { .. } - ), - "no second modal after the single batched resolve" - ); - assert_eq!( - p0_hand_size(runner.state()), - hand_before, - "Boomerang discards one then draws one (net 0)" - ); - assert!(runner - .state() - .active_repeated_optional_payment_frame() - .is_none()); + .act(GameAction::SelectModes { indices: vec![1] }) + .expect("choose Explosive after its reflexive trigger is on the stack"); assert!(matches!( runner.state().waiting_for, - WaitingFor::Priority { .. } + WaitingFor::TriggerTargetSelection { .. } )); -} - -#[test] -fn explosive_targeted_mode_resolves_once_through_apply() { - let mut runner = hawkeye_runtime(3, &[]); - assert_eq!(p1_life(runner.state()), 20); - assert_eq!(p0_pool_total(runner.state()), 3); - - runner - .act(GameAction::SelectModes { indices: vec![1] }) - .expect("SelectModes accepted"); - - assert_eq!( - p0_pool_total(runner.state()), - 2, - "Explosive alone costs one {{1}}" - ); - assert!( - matches!( - runner.state().waiting_for, - WaitingFor::TriggerTargetSelection { .. } - ), - "Explosive needs a target player, got {:?}", - runner.state().waiting_for - ); - runner .act(GameAction::ChooseTarget { target: Some(TargetRef::Player(P1)), }) - .expect("ChooseTarget accepted"); - + .expect("choose Explosive target"); runner.advance_until_stack_empty(); - - assert_eq!( - p1_life(runner.state()), - 18, - "Explosive deals exactly 2 damage once" - ); + assert_eq!(runner.state().players[1].life, 18); } #[test] -fn ability_mode_choice_with_mode_costs_survives_serde_roundtrip() { +fn sequential_payment_snapshot_round_trips_mid_loop() { let mut runner = hawkeye_runtime(3, &[]); - assert_eq!(modal_cap(runner.state()), Some((0, 3))); - assert_eq!(max_affordable_mode_selections(runner.state()), Some(3)); - assert_eq!(mode_cost_count(runner.state()), Some(3)); - - let v2 = serde_json::to_value(ResolutionStateWire::from_game_state(runner.state().clone())) - .expect("batched AbilityModeChoice serializes as v2"); - assert_eq!(v2["resolution_state_version"], 2); - let restored: GameState = serde_json::from_value::(v2) - .expect("batched AbilityModeChoice round-trips through the v2 wire") + decide(&mut runner, true); + decide(&mut runner, true); + let wire = serde_json::to_value(ResolutionStateWire::from_game_state(runner.state().clone())) + .expect("sequential payment prompt serializes"); + let restored = serde_json::from_value::(wire) + .expect("sequential payment prompt restores") .into_game_state(); - - assert_eq!(modal_cap(&restored), Some((0, 3))); - assert_eq!(max_affordable_mode_selections(&restored), Some(3)); - assert_eq!(mode_cost_count(&restored), Some(3)); - assert!( - repeated_payment_driver_is_pending(&restored), - "batched pending payment survives serde" - ); - - *runner.state_mut() = restored; - runner - .act(GameAction::SelectModes { - indices: vec![0, 1], - }) - .expect("resume after roundtrip"); - assert_eq!( - p0_pool_total(runner.state()), - 1, - "two {{1}} payments survived serde resume" - ); -} - -fn p0_hand_size(state: &GameState) -> usize { - state - .players - .iter() - .find(|p| p.id == P0) - .map(|p| p.hand.len()) - .unwrap_or(0) -} - -fn p1_life(state: &GameState) -> i32 { - state - .players - .iter() - .find(|p| p.id == P1) - .map(|p| p.life) - .unwrap_or(0) + assert_eq!(payment_count(&restored), 2); + assert!(matches!( + restored.waiting_for, + WaitingFor::OptionalEffectChoice { .. } + )); } diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index d9ce3a24b4..44d1f58fd9 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -31222,7 +31222,6 @@ pub mod tests { allow_repeat_modes: false, constraints: vec![], mode_costs: vec![], - max_affordable_selections: None, mode_pawprints: vec![], entwine_cost: None, chooser: PlayerFilter::Controller, diff --git a/crates/engine/src/game/triggers_push_first_contract_tests.rs b/crates/engine/src/game/triggers_push_first_contract_tests.rs index dd3e432c35..60f94276d3 100644 --- a/crates/engine/src/game/triggers_push_first_contract_tests.rs +++ b/crates/engine/src/game/triggers_push_first_contract_tests.rs @@ -381,7 +381,6 @@ fn push_first_no_legal_modes_modal_trigger_dropped_silently() { allow_repeat_modes: false, constraints: vec![], mode_costs: vec![], - max_affordable_selections: None, mode_pawprints: vec![], entwine_cost: None, chooser: PlayerFilter::Controller, @@ -496,7 +495,6 @@ fn random_modal_trigger_resolves_without_prompting() { allow_repeat_modes: false, constraints: vec![], mode_costs: vec![], - max_affordable_selections: None, mode_pawprints: vec![], entwine_cost: None, chooser: PlayerFilter::Controller, diff --git a/crates/engine/src/parser/oracle_modal.rs b/crates/engine/src/parser/oracle_modal.rs index c354dbc95e..3db1c78179 100644 --- a/crates/engine/src/parser/oracle_modal.rs +++ b/crates/engine/src/parser/oracle_modal.rs @@ -1417,7 +1417,6 @@ fn build_modal_choice(header: &ModalHeaderAst, modes: &[ModeAst]) -> ModalChoice !mode_pawprints.is_empty(), ), mode_costs: build_mode_costs(modes), - max_affordable_selections: None, mode_pawprints, entwine_cost: None, // CR 700.2e: the player who chooses the mode(s). diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 88c157030a..0785d07ea6 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -16981,13 +16981,6 @@ pub struct ModalChoice { /// CR 702.172a: Chosen mode costs are additional costs, not part of the base mana cost. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub mode_costs: Vec, - /// CR 118.1 + CR 702.172a: When `mode_costs` is non-empty, the engine probes - /// how many mode selections the controller can pay for with current mana - /// sources (including feasible auto-tap). The UI must cap interactive - /// selection to this value and must not infer affordability from mana-pool - /// entry count or pip arithmetic. Omitted when `mode_costs` is empty. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_affordable_selections: Option, /// CR 700.2i: Per-mode pawprint weight for points-budget modals ("up to N {P} /// worth of modes"). Empty for all non-pawprint modals. When non-empty, the /// modal is a pawprint budget modal and `max_choices` is reinterpreted as the diff --git a/crates/engine/src/types/resolution.rs b/crates/engine/src/types/resolution.rs index dfb57122dc..b096b706c9 100644 --- a/crates/engine/src/types/resolution.rs +++ b/crates/engine/src/types/resolution.rs @@ -49,11 +49,6 @@ pub struct PendingRepeatedOptionalPayment { pub payment_unit: Box, pub reflexive: Box, pub remaining: u32, - /// CR 603.12a + CR 702.172a: When true, payment and mode choice are batched - /// into one `AbilityModeChoice` (Hawkeye / Tranquil Frillback class). When - /// false, the legacy per-iteration `OptionalEffectChoice` driver applies. - #[serde(default)] - pub batched: bool, } /// The complete parked repeated optional-payment authority. @@ -295,10 +290,6 @@ impl ResolutionFrame { /// the concrete `WaitingFor` variant by the structural API. pub const fn gate(&self) -> FrameGate { match self { - Self::RepeatedOptionalPayment(RepeatedOptionalPaymentFrame { - pending: Some(pending), - .. - }) if pending.batched => FrameGate::DirectChoice(DirectChoiceGate::AbilityModeChoice), Self::RepeatedOptionalPayment(RepeatedOptionalPaymentFrame { pending: Some(_), .. @@ -345,7 +336,6 @@ pub enum FrameGate { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum DirectChoiceGate { OptionalEffect, - AbilityModeChoice, CoinFlipKeep, Proliferate, MutateMerge, @@ -359,10 +349,6 @@ impl DirectChoiceGate { Self::OptionalEffect, WaitingFor::OptionalEffectChoice { .. } ) | (Self::OptionalEffect, WaitingFor::OpponentMayChoice { .. }) - | ( - Self::AbilityModeChoice, - WaitingFor::AbilityModeChoice { .. } - ) | (Self::CoinFlipKeep, WaitingFor::CoinFlipKeepChoice { .. }) | (Self::Proliferate, WaitingFor::ProliferateChoice { .. }) | (Self::MutateMerge, WaitingFor::MutateMergeChoice { .. }) @@ -2651,6 +2637,12 @@ impl ResolutionStateWire { let frames_value = object .get("resolution_frames") .ok_or_else(|| "v2 resolution state is missing resolution_frames".to_string())?; + if has_removed_batched_repeated_optional_payment(frames_value) { + return Err( + "v2 repeated optional-payment snapshot uses removed batched:true flow; restart the game from a current save" + .to_string(), + ); + } let mut frames: ResolutionStack = serde_json::from_value(frames_value.clone()) .map_err(|error| error.to_string())?; frames.recover_draw_sequence_allocator(); @@ -2705,6 +2697,35 @@ impl<'de> Deserialize<'de> for ResolutionStateWire { } } +/// The one-dialog repeated-payment experiment serialized a `batched` marker +/// inside its frame. It created a reflexive trigger before payment succeeded, +/// so treating such a snapshot as the sequential flow would change game state +/// mid-resolution. Reject only that obsolete persisted frame explicitly. +fn has_removed_batched_repeated_optional_payment(value: &Value) -> bool { + match value { + Value::Array(values) => values + .iter() + .any(has_removed_batched_repeated_optional_payment), + Value::Object(values) => { + values + .get("type") + .and_then(Value::as_str) + .filter(|kind| *kind == "RepeatedOptionalPayment") + .and_then(|_| values.get("data")) + .and_then(Value::as_object) + .and_then(|frame| frame.get("pending")) + .and_then(Value::as_object) + .and_then(|pending| pending.get("batched")) + .and_then(Value::as_bool) + .is_some_and(|batched| batched) + || values + .values() + .any(has_removed_batched_repeated_optional_payment) + } + _ => false, + } +} + /// Checks the Phase-3 runtime invariants after a restore and after every /// public action. Migrated families are authoritative in `ResolutionStack`; /// canonicalization combines those with unmigrated legacy families for the @@ -3495,6 +3516,7 @@ mod tests { PendingSpellResolution, PendingVoteBallotIteration, PostReplacementDrain, ResidentDrainPolicy, ZoneDeliveryExileTracking, }; + use crate::types::identifiers::{CardId, LogicalZoneChangeGroupId, ObjectId}; use crate::types::player::PlayerId; use crate::types::proposed_event::{ProposedEvent, ReplacementId}; @@ -3502,6 +3524,22 @@ mod tests { use crate::types::zones::{EtbTapState, Zone}; use std::collections::VecDeque; + #[test] + fn removed_batched_repeated_payment_snapshot_is_detected() { + assert!(has_removed_batched_repeated_optional_payment( + &serde_json::json!([{ + "type": "RepeatedOptionalPayment", + "data": { "pending": { "batched": true } } + }]) + )); + assert!(!has_removed_batched_repeated_optional_payment( + &serde_json::json!([{ + "type": "RepeatedOptionalPayment", + "data": { "pending": { "batched": false } } + }]) + )); + } + fn resolved_draw(source_id: u64) -> ResolvedAbility { ResolvedAbility::new( Effect::Draw { @@ -4519,7 +4557,6 @@ mod tests { payment_unit: Box::new(resolved_draw(100)), reflexive: Box::new(resolved_draw(101)), remaining: 0, - batched: false, })), optional_cost_payments_this_resolution: 0, }, diff --git a/crates/mtgish-import/src/convert/mod.rs b/crates/mtgish-import/src/convert/mod.rs index 3c27f76e72..06e12ce268 100644 --- a/crates/mtgish-import/src/convert/mod.rs +++ b/crates/mtgish-import/src/convert/mod.rs @@ -1322,9 +1322,6 @@ pub(crate) fn build_ability_from_actions( allow_repeat_modes, constraints, mode_costs: Vec::new(), - max_affordable_selections: None, - // Mechanical compile-keep-alive for the shared engine ModalChoice - // field add; mtgish does not (yet) author pawprint modals. mode_pawprints: Vec::new(), entwine_cost, // CR 700.2a: mtgish modal blocks are controller-chosen. diff --git a/crates/server-core/src/filter.rs b/crates/server-core/src/filter.rs index f58da3472a..05aac9ebd0 100644 --- a/crates/server-core/src/filter.rs +++ b/crates/server-core/src/filter.rs @@ -478,7 +478,6 @@ mod tests { allow_repeat_modes: false, constraints: Vec::new(), mode_costs: Vec::new(), - max_affordable_selections: None, mode_pawprints: Vec::new(), entwine_cost: None, chooser: PlayerFilter::Controller, From 062b3ec484fe5a53fd9ccda0a1aaef9b5f3b5616 Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:31:10 -0700 Subject: [PATCH 29/63] fix(scryfall): support JSON Lines bulk downloads (#6803) Co-authored-by: matthewevans --- scripts/lib/scryfall-fetch.sh | 53 +++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/scripts/lib/scryfall-fetch.sh b/scripts/lib/scryfall-fetch.sh index 5fa0f0c9d7..b1220f5536 100644 --- a/scripts/lib/scryfall-fetch.sh +++ b/scripts/lib/scryfall-fetch.sh @@ -77,16 +77,51 @@ def js_downcase: | implode; ' -# scryfall_fetch_bulk TYPE FILE — resolve a bulk-data download_uri by type -# (e.g. oracle_cards, default_cards) and download it to FILE. +# scryfall_download_jsonl_gzip URL FILE — download Scryfall's gzip-compressed +# JSON Lines bulk format, validate its card-object records, and stream it into +# the JSON array the generators consume. The temp+rename has the same atomicity +# guarantee as scryfall_download. +scryfall_download_jsonl_gzip() { + local url="$1" file="$2" archive tmp + archive=$(mktemp "${file}.jsonl.gz.XXXXXX") + tmp=$(mktemp "${file}.XXXXXX") + if ! "${SCRYFALL_CURL[@]}" -o "$archive" "$url"; then + rm -f "$archive" "$tmp" + return 1 + fi + if ! gzip -dc "$archive" \ + | jq -ce 'if .object == "card" then . else error("Scryfall JSONL bulk data must contain card objects") end' \ + | awk 'BEGIN { print "[" } NR > 1 { printf "," } { print } END { print "]" }' > "$tmp"; then + echo "scryfall: download of $url is not valid gzip-compressed JSON Lines" >&2 + rm -f "$archive" "$tmp" + return 1 + fi + rm -f "$archive" + mv -f "$tmp" "$file" +} + +# scryfall_fetch_bulk TYPE FILE — resolve and download a bulk-data export by +# type (e.g. oracle_cards, default_cards). Prefer Scryfall's legacy JSON array +# download when present; otherwise normalize its JSON Lines export to that +# same array shape for existing generators. scryfall_fetch_bulk() { - local type="$1" file="$2" uri - uri=$("${SCRYFALL_CURL[@]}" "https://api.scryfall.com/bulk-data" \ - | jq -r --arg t "$type" '.data[] | select(.type == $t) | .download_uri') \ + local type="$1" file="$2" metadata uri jsonl_uri + metadata=$("${SCRYFALL_CURL[@]}" "https://api.scryfall.com/bulk-data" \ + | jq -cer --arg t "$type" '.data[] | select(.type == $t) | { + download_uri, + jsonl_download_uri + }') \ || return 1 - if [ -z "$uri" ] || [ "$uri" = "null" ]; then - echo "scryfall: no download_uri for bulk-data type '$type'" >&2 - return 1 + uri=$(jq -r '.download_uri // empty' <<< "$metadata") + if [ -n "$uri" ]; then + scryfall_download "$uri" "$file" + return + fi + jsonl_uri=$(jq -r '.jsonl_download_uri // empty' <<< "$metadata") + if [ -n "$jsonl_uri" ]; then + scryfall_download_jsonl_gzip "$jsonl_uri" "$file" + return fi - scryfall_download "$uri" "$file" + echo "scryfall: no bulk download URI for type '$type'" >&2 + return 1 } From ce8d7800ba6339cbda0dd10902b3a7e955524355 Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:31:30 -0700 Subject: [PATCH 30/63] =?UTF-8?q?chore(changelog):=20publish=20entry=20#18?= =?UTF-8?q?3=20=E2=80=94=20repeated=20payments=20resolve=20correctly=20(#6?= =?UTF-8?q?802)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: matthewevans --- client/public/changelog-meta.json | 2 +- client/public/changelog.json | 14 ++++++++++++++ scripts/changelog/state.json | 4 ++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/client/public/changelog-meta.json b/client/public/changelog-meta.json index 1ead35b727..b336364c8a 100644 --- a/client/public/changelog-meta.json +++ b/client/public/changelog-meta.json @@ -1,3 +1,3 @@ { - "latestId": 182 + "latestId": 183 } diff --git a/client/public/changelog.json b/client/public/changelog.json index f61f9230e3..b4d12a8718 100644 --- a/client/public/changelog.json +++ b/client/public/changelog.json @@ -1,5 +1,19 @@ { "entries": [ + { + "id": 183, + "date": "2026-07-29", + "title": "Repeated payments resolve correctly", + "tags": [ + "new-cards", + "card-fixes", + "gameplay", + "interface", + "ai" + ], + "body": "✨ New Cards & Mechanics\n• Deathbringer Regent joins the supported pool, while Ozai’s conditional mana ability and Runadi, Behemoth Caller’s counter-and-haste package now work as printed\n\n🛠️ Cards That Now Work Right\n• Repeated optional payments now happen one at a time, with each resulting trigger appearing only after the payment succeeds\n• Elenda and Azor’s X payment now charges its colored mana as well as X; Mosswort Bridge correctly lets you play its hidden card\n• Fight Rigging and Collector’s Cage keep their counter target separate from their hidden card, while Culling Scales correctly finds the lowest-mana-value nonland permanent\n• Captain America’s Shield and similar Equipment attack triggers can affect creatures the defending player controls; Mockingbird correctly uses the mana spent to cast it for its copy limit\n• Activated abilities now choose targets before paying their costs, correctly handle independent repeated player choices, and keep required player targets distinct\n\n⚔️ Combat & Gameplay\n• Damage prevention now applies to the intended recipient instead of broadening to every possible target\n• Turn-control effects no longer lose queued payment choices, so controlled players can pay costs such as Lava Dart’s flashback sacrifice\n\n🖥️ Interface\n• Attachments can now be selected reliably from their interaction fan\n\n🤖 AI\n• AI better recognizes Vehicle, discard-matters, and cost-reduction decks, and makes more reliable land, mana-payment, and sacrifice decisions", + "discordUrl": "https://discord.com/channels/1485498006781427802/1486432040332296424/1532224460164301061" + }, { "id": 182, "date": "2026-07-28", diff --git a/scripts/changelog/state.json b/scripts/changelog/state.json index 4e3b739465..8f836e1ef3 100644 --- a/scripts/changelog/state.json +++ b/scripts/changelog/state.json @@ -1,4 +1,4 @@ { - "lastTip": "2b3510ef43d54a8a152bd66cf6c59da4638fd969", - "lastPostedId": 182 + "lastTip": "3f276a584a6aa3d207504d810119a501b40bb2e6", + "lastPostedId": 183 } From 703eb552b72c40c998074c1c8f972eadae3e50c4 Mon Sep 17 00:00:00 2001 From: Clayton <118192227+claytonlin1110@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:25:22 -0500 Subject: [PATCH 31/63] fix(engine): Gemstone Mine-class conditional sacrifice binds to source, not an unbound ParentTarget (#6797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(engine): Gemstone Mine-class conditional sacrifice binds to source, not an unbound ParentTarget An anaphoric-target rewrite in the chain parser unconditionally bound a following clause's bare "it"/"that creature" to ParentTarget whenever any prior clause existed in the chain, even when that prior clause established no target at all (e.g. a mana ability's untargeted "Add one mana of any color"). For Gemstone Mine's "{T}, Remove a mining counter: Add one mana of any color. If there are no mining counters on this land, sacrifice it.", this clobbered the sacrifice's target into a ParentTarget that resolves to nothing at runtime, so the land was never sacrificed after its last counter was removed (#6507). Gate the rewrite on the prior clause actually exposing a target concept, and rebind a sacrifice imperative's unbound ParentTarget default to the ability's own source when no such antecedent exists — covering the whole class of targetless activated abilities with a trailing conditional self-sacrifice, not just this card. Co-Authored-By: Claude Sonnet 5 * fix(engine): scope the ParentTarget-antecedent guard to exclude known non-target referents; add integration coverage Review follow-up on #6797: - HIGH: the new "no antecedent" fallback compared only `target_filter().is_none()` on the prior clause, which also fires for a targetless clause that DOES establish a real referent through a different channel — a token/copy publisher (Effect::Populate has no target field but publishes a created-token referent via chain_prior_referent_is_created_token) or a GenericEffect grant surfaced only through its static's `affected` field (if_you_do_object_anchor). Excluding both from the guard prevents the sacrifice-target rewrite from firing on those chains, so a conditional "sacrifice it" after Populate keeps binding to the populated token (LastCreated) instead of being misrouted to the ability's own source. Added a regression (populate_conditional_sacrifice_keeps_created_token_not_self_ref) that fails against the prior guard and passes against this one. - MED: added a runtime integration test pair (gemstone_mine_survives_activation_with_counters_remaining, gemstone_mine_sacrifices_itself_on_last_counter_removed) that activates the real mana+conditional-sacrifice ability shape through resolve_mana_ability/resolve_mana_ability_sub_chain — proving mana production and the SelfRef sacrifice resolution path both work end-to-end, not just that the parser emits the right AST node. Co-Authored-By: Claude Sonnet 5 * fix(engine): scope the sacrifice-antecedent fixup to Effect::Sacrifice only, revert the shared Kicker-clause gate Review follow-up on #6797 (MED: blast-radius concern). Narrowing the shared "Kicker clause" rewrite's gate (used by `replace_target_with_parent` for every targetable effect kind, not just Sacrifice) was unnecessary and wrong: a full coverage-parse-diff against main's fd53e6af9 showed it flipped 59 cards across 49 signatures, including a genuine regression in Feather, the Redeemed (its ExileResolvingSpell return-at/return-to rider dropped entirely) and target-binding churn across PutCounter, Pump, grant-Flying/DoubleStrike/FirstStrike/Trample/Haste/ Indestructible, ChangeZone, DealDamage, Destroy, and GainControl on unrelated cards (Managorger Phoenix, Skizzik, Searing Blaze, Savage Punch, ED-E, and more) — none of which are the reported bug's class. The Kicker-clause gate is restored to its original `!builder.is_empty()`. The actual fix is the standalone Sacrifice-specific fixup added below it, which only rebinds `Effect::Sacrifice`'s unbound `ParentTarget` default to `SelfRef` — that alone was already sufficient to fix Gemstone Mine; touching the shared gate never contributed to the fix and only caused collateral damage. Re-verified with the same parse-diff pipeline: this narrowed change now touches exactly 6 cards / 1 signature (`ability/Sacrifice`, target `parent target` → `self`) — Gemstone Mine and the five genuine Homelands depletion lands (Hickory Woodlot, Peat Bog, Remote Farm, Sandstone Needle, Saprazzan Skerry), all sharing the identical "{T}, Remove a depletion counter: Add mana. If there are no depletion counters on this land, sacrifice it." shape. Zero unrelated cards changed; the full engine test suite (18015 tests) still passes. Co-Authored-By: Claude Sonnet 5 * fix(engine): sacrifice antecedent fallback defers to parent_target_available; drop mismatched CR citation Review round 2 follow-up on #6797. - HIGH: the no-antecedent guard checked only the immediately-preceding clause's target_filter(), which cannot see a referent established BEFORE a paired conditional when the current clause is inside a recursively-parsed `Otherwise` else-branch (its own `clauses` start empty). Now defers first to `parent_target_available` (already computed above, and already carrying `ctx.parent_target_available` across exactly this recursive-else-branch boundary for the same reason — see the "Otherwise" handler's own comment). Added conditional_sacrifice_defers_to_seeded_parent_target_available, which drives parse_effect_chain_ir directly with parent_target_available seeded (mirroring what a real outer Otherwise referent seeds) and fails against the old guard. - MED: the lookback now uses the nearest non-`ClauseDisposition::Continue` clause instead of a bare `.last()`, matching the existing lookback idiom `if_you_do_object_anchor` already uses for the same reason — an absorbed rider carries no independent referent and must not be mistaken for a targetless antecedent. - MED: dropped the CR 122.1 citation from the Sacrifice-fixup comment. CR 122.1 defines counters; it does not support pronoun/antecedent-binding behavior. CR 608.2c (order-of-instructions + English-rules anaphora) is the citation this code actually implements, and is now the sole one. - MED: regenerated the parse-diff against this exact head (oracle-gen + coverage-report --all + coverage-parse-diff, same base fd53e6af9c07 used throughout this PR's review). Unchanged: 6 cards / 1 signature (ability/Sacrifice target parent target -> self — Gemstone Mine and the five genuine Homelands depletion lands). The two code fixes above are purely defensive additions gated on conditions none of those six cards' parse trees trigger, so the verified scope is stable. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- crates/engine/src/game/mana_abilities.rs | 164 ++++++++++++++++++ crates/engine/src/parser/oracle_effect/mod.rs | 58 +++++++ .../engine/src/parser/oracle_effect/tests.rs | 75 ++++++++ crates/engine/src/parser/oracle_tests.rs | 44 +++++ 4 files changed, 341 insertions(+) diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index 97fb7f57a2..5795d85d37 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -9947,6 +9947,170 @@ mod tests { ); } + // Issue #6507 integration follow-up: `make_gemstone_mine` above omits the + // trailing "If there are no mining counters on this land, sacrifice it." + // sub-ability entirely, so it never exercised the reported bug or its fix + // through the real activation pipeline — only the parser-level AST shape + // is covered by `oracle::tests::gemstone_mine_conditional_sacrifice_binds_to_self_ref`. + // This fixture mirrors the actual end-to-end parsed shape (mana effect + + // conditional `Sacrifice { target: SelfRef }` sub-ability gated on zero + // mining counters) so activation itself proves the fix: mana is produced + // regardless, and the land is sacrificed only on the activation that + // removes its LAST counter. + fn make_gemstone_mine_with_sacrifice_sub_ability( + state: &mut GameState, + player: PlayerId, + initial_mining_counters: u32, + ) -> ObjectId { + let land = create_object( + state, + CardId(8003), + player, + "Gemstone Mine".to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&land).unwrap(); + obj.card_types + .core_types + .push(crate::types::card_type::CoreType::Land); + let mining_key = crate::types::counter::parse_counter_type("MINING"); + obj.counters.insert(mining_key, initial_mining_counters); + + let sacrifice_if_depleted = AbilityDefinition::new( + AbilityKind::Spell, + Effect::Sacrifice { + target: TargetFilter::SelfRef, + count: QuantityExpr::Fixed { value: 1 }, + min_count: 0, + }, + ) + .condition(AbilityCondition::QuantityCheck { + lhs: QuantityExpr::Ref { + qty: QuantityRef::CountersOn { + scope: ObjectScope::Source, + counter_type: Some(CounterType::Generic("mining".to_string())), + }, + }, + comparator: Comparator::EQ, + rhs: QuantityExpr::Fixed { value: 0 }, + }); + + let ability = AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::AnyOneColor { + count: QuantityExpr::Fixed { value: 1 }, + color_options: vec![ + ManaColor::White, + ManaColor::Blue, + ManaColor::Black, + ManaColor::Red, + ManaColor::Green, + ], + contribution: ManaContribution::Base, + }, + restrictions: Vec::new(), + grants: Vec::new(), + expiry: None, + target: None, + }, + ) + .cost(AbilityCost::Composite { + costs: vec![ + AbilityCost::Tap, + AbilityCost::RemoveCounter { + count: 1, + counter_type: CounterMatch::OfType(CounterType::Generic("mining".to_string())), + target: None, + selection: crate::types::ability::CounterCostSelection::SingleObject, + }, + ], + }) + .sub_ability(sacrifice_if_depleted); + Arc::make_mut(&mut obj.abilities).push(ability); + land + } + + #[test] + fn gemstone_mine_survives_activation_with_counters_remaining() { + let mut state = GameState::new_two_player(42); + let player = PlayerId(0); + let land = make_gemstone_mine_with_sacrifice_sub_ability(&mut state, player, 2); + + let def = state + .objects + .get(&land) + .unwrap() + .abilities + .first() + .cloned() + .unwrap(); + let mut events = Vec::new(); + resolve_mana_ability( + &mut state, + land, + player, + &def, + &mut events, + Some(ProductionOverride::SingleColor(ManaType::Green)), + ) + .expect("Gemstone Mine activation must not fail with counters present"); + + assert_eq!( + state.players[player.0 as usize] + .mana_pool + .count_color(ManaType::Green), + 1, + "mana must be produced regardless of the trailing conditional sacrifice" + ); + assert!( + state.battlefield.contains(&land), + "Gemstone Mine must remain on the battlefield while a mining counter remains after activation" + ); + } + + #[test] + fn gemstone_mine_sacrifices_itself_on_last_counter_removed() { + let mut state = GameState::new_two_player(42); + let player = PlayerId(0); + let land = make_gemstone_mine_with_sacrifice_sub_ability(&mut state, player, 1); + + let def = state + .objects + .get(&land) + .unwrap() + .abilities + .first() + .cloned() + .unwrap(); + let mut events = Vec::new(); + resolve_mana_ability( + &mut state, + land, + player, + &def, + &mut events, + Some(ProductionOverride::SingleColor(ManaType::Green)), + ) + .expect("Gemstone Mine activation must not fail on its last counter"); + + assert_eq!( + state.players[player.0 as usize] + .mana_pool + .count_color(ManaType::Green), + 1, + "mana must still be produced on the activation that empties the last counter" + ); + assert!( + !state.battlefield.contains(&land), + "Gemstone Mine must be sacrificed once its last mining counter is removed (issue #6507)" + ); + assert!( + state.players[player.0 as usize].graveyard.contains(&land), + "the sacrificed Gemstone Mine must land in its controller's graveyard" + ); + } + #[test] fn cabal_coffers_pays_generic_taps_and_counts_swamps() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 6bf403efc0..c374d359c8 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -30745,6 +30745,14 @@ pub(crate) fn parse_effect_chain_ir( // which is exactly why a head-only rewrite corrupts the chain. let is_distributed_chunk = text_is_compound_subject_distribution(&text); // Kicker clauses referencing "that creature"/"it" inherit the parent's target. + // CR 608.2c: untouched — its `!builder.is_empty()` gate is intentionally broad + // (many antecedent shapes reach this without a `target_filter()`, e.g. a typed + // trigger subject rebind chain), and narrowing it here regressed dozens of + // unrelated cards (Feather, the Redeemed's exile-return rider; Managorger + // Phoenix; dropped/added parent-target bindings across PutCounter/Pump/grant/ + // ChangeZone/DealDamage/Destroy/GainControl) that a full parse-diff against + // main surfaced. See the Sacrifice-specific fixup immediately below instead, + // which is scoped to exactly the reported bug class. if condition.is_some() && !is_distributed_chunk && !condition.as_ref().is_some_and(condition_refs_source_object) @@ -30760,6 +30768,56 @@ pub(crate) fn parse_effect_chain_ir( { replace_target_with_parent(&mut clause.effect); } + // CR 608.2c (issue #6507): the sacrifice imperative parser + // (`parse_targeted_action_ast`) defaults a bare "it"/"them" pronoun with no + // trigger subject to `ParentTarget`, on the assumption that SOME earlier + // clause chose a real target for it to inherit — the common case + // (`bare_it_without_trigger_subject_preserves_parent_target`). That assumption + // is false, and the fallback must not fire, when ANY of the established + // antecedent authorities finds a referent: + // - `parent_target_available` (computed above) — a chosen typed target + // anywhere in this chain, an `if you do` object anchor, OR an outer + // chain's referent inherited into a recursively-parsed `Otherwise` + // else-branch via `ctx.parent_target_available` (Brilliance Unleashed's + // class). Omitting this last case would let an else-branch whose own + // internal clauses are targetless rebind a valid OUTER `ParentTarget` to + // `SelfRef`, sacrificing the ability's source instead of the object + // selected before the paired conditional. + // - `chain_prior_referent_is_created_token` — a token/copy publisher + // (`Effect::Populate` has no `target` field but DOES publish a + // created-token referent; `Token`/`CopyTokenOf` are covered by + // `parent_target_available`'s typed-referent scan for free since both DO + // have a `target_filter()`). + // - the nearest non-`Continue` clause (an absorbed rider carries no + // independent referent, so it must not be mistaken for a targetless + // antecedent — mirrors `if_you_do_object_anchor`'s same lookback) + // exposing SOME target concept (`target_filter().is_some()`). + // With none of those present, there is nothing for `ParentTarget` to resolve + // to, so a conditional "sacrifice it" tail (Gemstone Mine's "{T}, Remove a + // mining counter: Add one mana of any color. If there are no mining counters + // on this land, sacrifice it.") silently never sacrifices anything. Rebind it + // to the ability's own source, the only object "it" can plausibly name here. + // + // Deliberately scoped to `Effect::Sacrifice` only (not folded into the + // Kicker-clause rewrite's gate above): that gate is shared by every + // `replace_target_with_parent`-eligible effect kind, and narrowing it there + // regressed unrelated cards — see the comment above. + let nearest_semantic_clause = builder + .clauses() + .iter() + .rev() + .find(|clause| !matches!(clause.disposition, ClauseDisposition::Continue { .. })); + let prior_clause_has_no_target_concept = !parent_target_available + && !chain_prior_referent_is_created_token(builder.clauses()) + && nearest_semantic_clause + .is_some_and(|prev| prev.parsed.effect.target_filter().is_none()); + if condition.is_some() && !is_distributed_chunk && prior_clause_has_no_target_concept { + if let Effect::Sacrifice { target, .. } = &mut clause.effect { + if matches!(target, TargetFilter::ParentTarget) { + *target = TargetFilter::SelfRef; + } + } + } // CR 608.2c: Pronoun clause following a conditional targeted effect. if condition.is_none() && !is_distributed_chunk diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 76a8e8b96d..f76c015079 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -18715,6 +18715,81 @@ fn kicker_instead_chain_produces_correct_condition() { )); } +/// Issue #6507 review follow-up: `Effect::Populate` has no `target` field +/// (`target_filter()` is `None`, just like a mana ability's untargeted `Add`), +/// but it DOES publish a created-token referent via +/// `chain_prior_referent_is_created_token` — the same "populate anaphor +/// chain" class documented at the sacrifice imperative's bare-"it" binding. +/// A conditional "sacrifice it" tail after Populate must keep inheriting the +/// populated token (`ParentTarget`, rewritten to `LastCreated` by +/// `rewrite_parent_target_to_last_created`) — NOT get rebound to `SelfRef`, +/// which would sacrifice the ability's source instead of the populated copy. +#[test] +fn populate_conditional_sacrifice_keeps_created_token_not_self_ref() { + let ability = parse_effect_chain( + "Populate. If it was kicked, sacrifice it.", + AbilityKind::Spell, + ); + assert!(matches!(&*ability.effect, Effect::Populate)); + let sub = ability.sub_ability.as_ref().expect("expected sub_ability"); + assert!( + sub.condition.is_some(), + "expected the 'if it was kicked' gate to survive as a condition" + ); + let Effect::Sacrifice { target, .. } = &*sub.effect else { + panic!("expected Effect::Sacrifice, got {:?}", sub.effect); + }; + assert_eq!( + *target, + TargetFilter::LastCreated, + "sacrifice target must bind to the populated token (LastCreated), \ + not fall back to SelfRef (the ability's own source)" + ); +} + +/// Issue #6507 review follow-up (HIGH): an `Otherwise` else-branch is parsed +/// as its own recursive `parse_effect_chain_ir` call whose own `clauses` start +/// empty (see `parse_effect_chain_ir`'s "Otherwise" handler) — so a naive +/// "does the immediately-preceding INTERNAL clause expose a target?" check +/// can never see an outer referent established BEFORE the paired conditional +/// (Brilliance Unleashed's class). That outer referent is exactly what +/// `ctx.parent_target_available` carries across the recursive call for. Drive +/// the recursive parser directly with `parent_target_available: true` seeded +/// (mirroring what the "Otherwise" handler seeds from a real outer typed +/// target) to prove the no-antecedent Sacrifice fallback defers to it: a +/// targetless inner clause (`create an emblem` has no `target` field, so it +/// looks exactly like Gemstone Mine's untargeted mana ability in isolation) +/// followed by a conditional "sacrifice it" must still resolve to +/// `ParentTarget` — inheriting the outer referent — not get misrouted to +/// `SelfRef` (the ability's own source). +#[test] +fn conditional_sacrifice_defers_to_seeded_parent_target_available() { + let mut ctx = ParseContext { + parent_target_available: true, + ..Default::default() + }; + let ability = parse_effect_chain_with_context( + "create an emblem. If it wasn't kicked, sacrifice it.", + AbilityKind::Spell, + &mut ctx, + ); + assert!(matches!(&*ability.effect, Effect::Unimplemented { .. })); + let sac = ability + .sub_ability + .as_ref() + .expect("expected the conditional sacrifice chained after the targetless clause"); + let Effect::Sacrifice { target, .. } = &*sac.effect else { + panic!("expected Effect::Sacrifice, got {:?}", sac.effect); + }; + assert_eq!( + *target, + TargetFilter::ParentTarget, + "with parent_target_available seeded (simulating an inherited outer \ + referent from an enclosing Otherwise chain), sacrifice must keep \ + ParentTarget, not fall back to SelfRef" + ); +} + #[test] fn kicker_leading_instead_produces_correct_condition() { // CR 608.2c: "if kicked, instead [effect]" — leading "instead" variant. diff --git a/crates/engine/src/parser/oracle_tests.rs b/crates/engine/src/parser/oracle_tests.rs index 8e38f075b1..6625dbd16e 100644 --- a/crates/engine/src/parser/oracle_tests.rs +++ b/crates/engine/src/parser/oracle_tests.rs @@ -8290,6 +8290,50 @@ fn mox_pearl_mana_ability() { assert_eq!(r.abilities[0].kind, AbilityKind::Activated); } +/// Issue #6507: Gemstone Mine's mana ability is targetless ("Add one mana of +/// any color" chooses no target), so the trailing "If there are no mining +/// counters on this land, sacrifice it" conditional has no parent target to +/// inherit. The bare "it" pronoun must bind to the ability's own source +/// (`TargetFilter::SelfRef`), not `ParentTarget` — `ParentTarget` resolves to +/// nothing here and the land was never sacrificed. This is the +/// depletion-land class, not a one-off: any targetless activated ability +/// whose trailing conditional says "sacrifice it" shares the exposure. +#[test] +fn gemstone_mine_conditional_sacrifice_binds_to_self_ref() { + let r = parse( + "This land enters with three mining counters on it.\n\ + {T}, Remove a mining counter from this land: Add one mana of any color. \ + If there are no mining counters on this land, sacrifice it.", + "Gemstone Mine", + &[], + &["Land"], + &[], + ); + assert_eq!(r.abilities.len(), 1); + let ability = &r.abilities[0]; + assert!( + matches!(*ability.effect, Effect::Mana { .. }), + "expected Effect::Mana, got {:?}", + ability.effect + ); + let sub_ability = ability + .sub_ability + .as_ref() + .expect("expected a conditional sub_ability for the trailing sacrifice sentence"); + assert!( + sub_ability.condition.is_some(), + "expected the 'if there are no mining counters' gate to survive as a condition" + ); + let Effect::Sacrifice { target, .. } = &*sub_ability.effect else { + panic!("expected Effect::Sacrifice, got {:?}", sub_ability.effect); + }; + assert_eq!( + *target, + TargetFilter::SelfRef, + "sacrifice target must bind to the source land, not an unestablished ParentTarget" + ); +} + #[test] fn parses_return_forest_cost_untap_activated_ability() { let r = parse( From 4aa7882ba04d1d495c38ab5a28b09b565aeca050 Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:29:22 -0700 Subject: [PATCH 32/63] refactor(parser): emit equip abilities as native ir (#6804) * refactor(parser): emit equip abilities as native ir * test(parser): update native equip ir snapshots --------- Co-authored-by: matthewevans --- crates/engine/src/game/effects/token.rs | 8 +- crates/engine/src/parser/oracle.rs | 62 +++++---- .../engine/src/parser/oracle_effect/token.rs | 2 +- .../src/parser/oracle_ir/snapshot_tests.rs | 12 ++ ...napshot_tests__abraxas_named_equip_ir.snap | 123 ++++++++++++++++++ ...ot_tests__abraxas_named_equip_lowered.snap | 51 ++++++++ ...le_ir__snapshot_tests__batterskull_ir.snap | 123 +++++++++++++----- ...snapshot_tests__conformer_shuriken_ir.snap | 123 +++++++++++++----- ...le_ir__snapshot_tests__short_sword_ir.snap | 122 ++++++++++++----- .../src/parser/oracle_static/keyword_grant.rs | 4 +- crates/engine/src/parser/oracle_tests.rs | 27 ++-- 11 files changed, 513 insertions(+), 144 deletions(-) create mode 100644 crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__abraxas_named_equip_ir.snap create mode 100644 crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__abraxas_named_equip_lowered.snap diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index 81e7c6dfd3..4d5e91bbc6 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -6012,13 +6012,13 @@ mod tests { #[test] fn apply_create_token_materializes_intrinsic_equip_ability() { - use crate::parser::oracle::try_parse_equip; + use crate::parser::oracle::try_parse_equip_lowered; use crate::types::ability::{ContinuousModification, StaticDefinition}; use crate::types::card_type::CoreType; use crate::types::proposed_event::TokenSpec; use std::collections::HashSet; - let equip = try_parse_equip("Equip {0}").expect("equip static"); + let equip = try_parse_equip_lowered("Equip {0}").expect("equip static"); let equip_static = StaticDefinition::continuous() .affected(TargetFilter::SelfRef) .modifications(vec![ContinuousModification::GrantAbility { @@ -6079,13 +6079,13 @@ mod tests { #[test] fn apply_create_token_does_not_materialize_conditional_grant_ability() { - use crate::parser::oracle::try_parse_equip; + use crate::parser::oracle::try_parse_equip_lowered; use crate::types::ability::{ContinuousModification, StaticCondition, StaticDefinition}; use crate::types::card_type::CoreType; use crate::types::proposed_event::TokenSpec; use std::collections::HashSet; - let equip = try_parse_equip("Equip {0}").expect("equip static"); + let equip = try_parse_equip_lowered("Equip {0}").expect("equip static"); let conditional_equip = StaticDefinition::continuous() .affected(TargetFilter::SelfRef) .condition(StaticCondition::IsPresent { filter: None }) diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index ba5cea9b6e..760456c615 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -63,6 +63,7 @@ use super::oracle_effect::{ parse_effect_chain_with_context, rewrite_condition_keyword, try_parse_temporal_delayed_trigger_ability, }; +use super::oracle_ir::ast::parsed_clause; use super::oracle_ir::context::ParseContext; use super::oracle_ir::diagnostic::OracleDiagnostic; use super::oracle_ir::doc::{ @@ -71,7 +72,8 @@ use super::oracle_ir::doc::{ PrintedAbilityIndex, PrintedTriggerIndex, SpellPayloadIr, UnsupportedAbilityIr, }; use super::oracle_ir::effect_chain::{ - AbilityIr, AbilityRootTransform, AbilityShellIr, ResidualConditionPolicy, ShellStage, + AbilityIr, AbilityRootTransform, AbilityShellIr, EffectChainIr, ResidualConditionPolicy, + ShellStage, }; use super::oracle_ir::feature::ItemIdTracks; use super::oracle_ir::relation::{DocumentRelationIr, LinkedChoiceKind, LinkedReturnOutcome}; @@ -4558,7 +4560,7 @@ pub(crate) fn parse_oracle_ir( // ability and still needs the synthesized activation body. if lower_starts_with(&lower, "equip") && !lower_starts_with(&lower, "equipped") { if let Some(ability) = try_parse_equip(&line) { - emitter.ability_at(item_line, ability); + emitter.ability_ir_at(item_line, ability); i += 1; continue; } @@ -4725,7 +4727,7 @@ pub(crate) fn parse_oracle_ir( .trim_start_matches(" \u{2014} ") .trim_start_matches(" - "); if let Some(ability) = try_parse_equip(equip_part) { - emitter.ability_at(item_line, ability); + emitter.ability_ir_at(item_line, ability); i += 1; continue; } @@ -7065,7 +7067,7 @@ fn scrub_replacement_descriptions(rep: &mut ReplacementDefinition) { /// "Equipment you control have equip {0}" (Puresteel Paladin granted-equip /// pattern) does not slice off the first 5 bytes of "Equipment" and parse the /// remainder ("ment you control...") as a malformed activated ability cost. -pub(crate) fn try_parse_equip(line: &str) -> Option { +pub(crate) fn try_parse_equip(line: &str) -> Option { let (activation_line, cost_reduction) = split_trailing_self_cost_reduction(line); // Caller already verified lower.starts_with("equip") — strip 5-char prefix. // "equip" is always ASCII so byte length == char length. @@ -7095,26 +7097,42 @@ pub(crate) fn try_parse_equip(line: &str) -> Option { let (cost_text, constraints) = strip_activated_constraints(cost_text); let target = parse_equip_target_filter(&cost_text)?; let cost = parse_equip_cost(&cost_text); - let mut ability = AbilityDefinition::new( - AbilityKind::Activated, - Effect::Attach { - attachment: crate::types::ability::TargetFilter::SelfRef, - target, - }, - ) - .cost(cost) - .description(line.to_string()) - .sorcery_speed(); - if !constraints.restrictions.is_empty() { - for restriction in constraints.restrictions { - if !ability.activation_restrictions.contains(&restriction) { - ability.activation_restrictions.push(restriction); - } + let mut activation_restrictions = vec![ActivationRestriction::AsSorcery]; + for restriction in constraints.restrictions { + if !activation_restrictions.contains(&restriction) { + activation_restrictions.push(restriction); } } - ability.cost_reduction = cost_reduction; - ability.ability_tag = Some(AbilityTag::Equip); - Some(ability) + + Some(AbilityIr { + source_text: line.to_string(), + body: EffectChainIr::single_clause( + line, + AbilityKind::Activated, + parsed_clause(Effect::Attach { + attachment: crate::types::ability::TargetFilter::SelfRef, + target, + }), + None, + None, + false, + ), + shell: AbilityShellIr { + cost: Some(cost), + cost_reduction, + activation_restrictions, + ability_tag: Some(AbilityTag::Equip), + description: Some(line.to_string()), + ..AbilityShellIr::default() + }, + die_results: vec![], + root_transforms: vec![], + }) +} + +/// Lower native Equip IR for grant/token consumers that are not document emitters. +pub(crate) fn try_parse_equip_lowered(line: &str) -> Option { + try_parse_equip(line).map(|ir| lower_ability_ir(&ir)) } fn parse_equip_target_filter(cost_text: &str) -> Option { diff --git a/crates/engine/src/parser/oracle_effect/token.rs b/crates/engine/src/parser/oracle_effect/token.rs index 40f1640409..fd9cf21dfb 100644 --- a/crates/engine/src/parser/oracle_effect/token.rs +++ b/crates/engine/src/parser/oracle_effect/token.rs @@ -1179,7 +1179,7 @@ fn append_unquoted_equip_grants(text: &str, out: &mut Vec) { .get(start..start + clause_lower.len()) .unwrap_or(clause_lower) .trim(); - if let Some(ability) = super::super::oracle::try_parse_equip(clause_orig) { + if let Some(ability) = super::super::oracle::try_parse_equip_lowered(clause_orig) { out.push( StaticDefinition::continuous() .affected(TargetFilter::SelfRef) diff --git a/crates/engine/src/parser/oracle_ir/snapshot_tests.rs b/crates/engine/src/parser/oracle_ir/snapshot_tests.rs index 87ebb80a4b..8cd3fbcec8 100644 --- a/crates/engine/src/parser/oracle_ir/snapshot_tests.rs +++ b/crates/engine/src/parser/oracle_ir/snapshot_tests.rs @@ -948,6 +948,18 @@ fn short_sword() { insta::assert_json_snapshot!("short_sword_lowered", &lowered); } +#[test] +fn abraxas_named_equip() { + let (ir, lowered) = parse_two_layer( + "Abraxas — Equip {3}", + "Named Equip", + &["Artifact"], + &["Equipment"], + ); + insta::assert_json_snapshot!("abraxas_named_equip_ir", &ir); + insta::assert_json_snapshot!("abraxas_named_equip_lowered", &lowered); +} + #[test] fn smugglers_copter() { let (ir, lowered) = parse_two_layer( diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__abraxas_named_equip_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__abraxas_named_equip_ir.snap new file mode 100644 index 0000000000..bd0e258d44 --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__abraxas_named_equip_ir.snap @@ -0,0 +1,123 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +assertion_line: 959 +expression: "&ir" +--- +{ + "items": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 0 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 21, + "precision": "Exact", + "ordinal_within_span": 0 + }, + "fragment": "Abraxas — Equip {3}" + }, + "node": { + "Spell": { + "source_text": "Equip {3}", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 9, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Equip {3}" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Attach", + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null, + "cost": { + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [], + "generic": 3 + } + }, + "activation_restrictions": [ + { + "type": "AsSorcery" + } + ], + "ability_tag": { + "type": "Equip" + }, + "description": "Equip {3}" + }, + "die_results": [], + "root_transforms": [] + } + } + } + ], + "source_text": "Abraxas — Equip {3}", + "card_name": "Named Equip" +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__abraxas_named_equip_lowered.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__abraxas_named_equip_lowered.snap new file mode 100644 index 0000000000..3bc3c62974 --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__abraxas_named_equip_lowered.snap @@ -0,0 +1,51 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +assertion_line: 960 +expression: "&lowered" +--- +{ + "abilities": [ + { + "kind": "Activated", + "effect": { + "type": "Attach", + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] + } + }, + "cost": { + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [], + "generic": 3 + } + }, + "sub_ability": null, + "duration": null, + "description": "Equip {3}", + "target_prompt": null, + "activation_restrictions": [ + { + "type": "AsSorcery" + } + ], + "ability_tag": { + "type": "Equip" + }, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + } + ], + "triggers": [], + "statics": [], + "replacements": [], + "extractedKeywords": [] +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__batterskull_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__batterskull_ir.snap index 93c7d15be0..428424f5b0 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__batterskull_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__batterskull_ir.snap @@ -1,5 +1,6 @@ --- source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +assertion_line: 1995 expression: "&ir" --- { @@ -211,43 +212,97 @@ expression: "&ir" "fragment": "Equip {5}" }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "Attach", - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - } + "Spell": { + "source_text": "Equip {5}", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 9, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Equip {5}" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Attach", + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": { - "type": "Mana", + "shell": { + "sub_link": null, "cost": { - "type": "Cost", - "shards": [], - "generic": 5 - } - }, - "sub_ability": null, - "duration": null, - "description": "Equip {5}", - "target_prompt": null, - "activation_restrictions": [ - { - "type": "AsSorcery" - } - ], - "ability_tag": { - "type": "Equip" + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [], + "generic": 5 + } + }, + "activation_restrictions": [ + { + "type": "AsSorcery" + } + ], + "ability_tag": { + "type": "Equip" + }, + "description": "Equip {5}" }, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__conformer_shuriken_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__conformer_shuriken_ir.snap index 08052e1b50..d5a57a7fc5 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__conformer_shuriken_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__conformer_shuriken_ir.snap @@ -1,5 +1,6 @@ --- source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +assertion_line: 1796 expression: "&ir" --- { @@ -143,43 +144,97 @@ expression: "&ir" "fragment": "Equip {2}" }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "Attach", - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - } + "Spell": { + "source_text": "Equip {2}", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 9, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Equip {2}" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Attach", + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": { - "type": "Mana", + "shell": { + "sub_link": null, "cost": { - "type": "Cost", - "shards": [], - "generic": 2 - } - }, - "sub_ability": null, - "duration": null, - "description": "Equip {2}", - "target_prompt": null, - "activation_restrictions": [ - { - "type": "AsSorcery" - } - ], - "ability_tag": { - "type": "Equip" + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [], + "generic": 2 + } + }, + "activation_restrictions": [ + { + "type": "AsSorcery" + } + ], + "ability_tag": { + "type": "Equip" + }, + "description": "Equip {2}" }, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__short_sword_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__short_sword_ir.snap index 8215398e16..8622d6242d 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__short_sword_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__short_sword_ir.snap @@ -77,43 +77,97 @@ expression: "&ir" "fragment": "Equip {1} ({1}: Attach to target creature you control. Equip only as a sorcery.)" }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "Attach", - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "You", - "properties": [] - } + "Spell": { + "source_text": "Equip {1}", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 9, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Equip {1}" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Attach", + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "You", + "properties": [] + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": { - "type": "Mana", + "shell": { + "sub_link": null, "cost": { - "type": "Cost", - "shards": [], - "generic": 1 - } - }, - "sub_ability": null, - "duration": null, - "description": "Equip {1}", - "target_prompt": null, - "activation_restrictions": [ - { - "type": "AsSorcery" - } - ], - "ability_tag": { - "type": "Equip" + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [], + "generic": 1 + } + }, + "activation_restrictions": [ + { + "type": "AsSorcery" + } + ], + "ability_tag": { + "type": "Equip" + }, + "description": "Equip {1}" }, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_static/keyword_grant.rs b/crates/engine/src/parser/oracle_static/keyword_grant.rs index 120cfbfe55..6fa5ee2740 100644 --- a/crates/engine/src/parser/oracle_static/keyword_grant.rs +++ b/crates/engine/src/parser/oracle_static/keyword_grant.rs @@ -1758,7 +1758,7 @@ pub(crate) fn push_grant_clause_modifications( // equip {0}") is the equip activated ability — not an inert AddKeyword. // Mirrors `classify_quoted_inner`'s pre-keyword equip dispatch. if nom_tag_lower(&part_lower, &part_lower, "equip").is_some() { - if let Some(ability) = super::oracle::try_parse_equip(part_trimmed) { + if let Some(ability) = super::oracle::try_parse_equip_lowered(part_trimmed) { modifications.push(ContinuousModification::GrantAbility { definition: Box::new(ability), }); @@ -1910,7 +1910,7 @@ pub(crate) fn classify_quoted_inner(ability_text: &str) -> Vec { assert_eq!(costs.len(), 2, "expected 2 alternatives, got {:?}", costs); @@ -20628,7 +20628,7 @@ fn restricted_equip_costs_use_embedded_mana_cost() { #[test] fn restricted_equip_costs_preserve_target_requirement() { - let legendary = super::try_parse_equip("Equip legendary creature {1}") + let legendary = super::try_parse_equip_lowered("Equip legendary creature {1}") .expect("legendary equip should parse"); let Effect::Attach { target, .. } = *legendary.effect else { panic!("expected Attach, got {:?}", legendary.effect); @@ -20642,8 +20642,8 @@ fn restricted_equip_costs_preserve_target_requirement() { value: crate::types::card_type::Supertype::Legendary, })); - let commander = - super::try_parse_equip("Equip commander {3}").expect("commander equip should parse"); + let commander = super::try_parse_equip_lowered("Equip commander {3}") + .expect("commander equip should parse"); let Effect::Attach { target, .. } = *commander.effect else { panic!("expected Attach, got {:?}", commander.effect); }; @@ -20667,7 +20667,7 @@ fn restricted_equip_costs_cover_observed_target_classes() { "Equip Pirate {1}", "Equip Soldier {W}", ] { - let ability = super::try_parse_equip(line).expect("subtype equip should parse"); + let ability = super::try_parse_equip_lowered(line).expect("subtype equip should parse"); let Effect::Attach { target, .. } = *ability.effect else { panic!("expected Attach, got {:?}", ability.effect); }; @@ -20684,7 +20684,7 @@ fn restricted_equip_costs_cover_observed_target_classes() { ); } - let class_union = super::try_parse_equip("Equip Shaman, Warlock, or Wizard {1}") + let class_union = super::try_parse_equip_lowered("Equip Shaman, Warlock, or Wizard {1}") .expect("multi-subtype equip should parse"); let Effect::Attach { target, .. } = *class_union.effect else { panic!("expected Attach, got {:?}", class_union.effect); @@ -20705,7 +20705,7 @@ fn restricted_equip_costs_cover_observed_target_classes() { ))); } - let token = super::try_parse_equip("Equip creature token {1}") + let token = super::try_parse_equip_lowered("Equip creature token {1}") .expect("creature-token equip should parse"); let Effect::Attach { target, .. } = *token.effect else { panic!("expected Attach, got {:?}", token.effect); @@ -20717,8 +20717,8 @@ fn restricted_equip_costs_cover_observed_target_classes() { assert!(tf.type_filters.contains(&TypeFilter::Creature)); assert!(tf.properties.contains(&FilterProp::Token)); - let planeswalker = - super::try_parse_equip("Equip planeswalker {1}").expect("planeswalker equip should parse"); + let planeswalker = super::try_parse_equip_lowered("Equip planeswalker {1}") + .expect("planeswalker equip should parse"); let Effect::Attach { target, .. } = *planeswalker.effect else { panic!("expected Attach, got {:?}", planeswalker.effect); }; @@ -20729,8 +20729,9 @@ fn restricted_equip_costs_cover_observed_target_classes() { assert!(tf.type_filters.contains(&TypeFilter::Planeswalker)); assert!(!tf.type_filters.contains(&TypeFilter::Creature)); - let creature_or_planeswalker = super::try_parse_equip("Equip creature or planeswalker {3}") - .expect("creature-or-planeswalker equip should parse"); + let creature_or_planeswalker = + super::try_parse_equip_lowered("Equip creature or planeswalker {3}") + .expect("creature-or-planeswalker equip should parse"); let Effect::Attach { target, .. } = *creature_or_planeswalker.effect else { panic!("expected Attach, got {:?}", creature_or_planeswalker.effect); }; @@ -20766,7 +20767,7 @@ fn equip_cost_modifier_lines_are_not_equip_abilities() { #[test] fn equip_once_per_turn_constraint_strips_from_cost() { - let ability = super::try_parse_equip("Equip {0}. Activate only once each turn.") + let ability = super::try_parse_equip_lowered("Equip {0}. Activate only once each turn.") .expect("equip should parse"); assert_eq!( ability.cost, From 77e0a75c09b1090a5112752a744bb834f9273704 Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:22:44 -0700 Subject: [PATCH 33/63] refactor(parser): emit loyalty abilities as native ir (#6806) Co-authored-by: matthewevans --- crates/engine/src/parser/oracle.rs | 87 +- .../src/parser/oracle_ir/snapshot_tests.rs | 59 +- ...ts__chandra_nalaar_minus_x_loyalty_ir.snap | 128 +++ ...handra_nalaar_minus_x_loyalty_lowered.snap | 56 ++ ...shot_tests__jace_the_mind_sculptor_ir.snap | 755 +++++++++++++----- ...napshot_tests__liliana_of_the_veil_ir.snap | 430 +++++++--- 6 files changed, 1137 insertions(+), 378 deletions(-) create mode 100644 crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__chandra_nalaar_minus_x_loyalty_ir.snap create mode 100644 crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__chandra_nalaar_minus_x_loyalty_lowered.snap diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index 760456c615..70d9eda5d1 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -4734,7 +4734,7 @@ pub(crate) fn parse_oracle_ir( } // Priority 11: Planeswalker loyalty abilities: +N:, −N:, 0:, [+N]:, [−N]:, [0]: if let Some(ability) = try_parse_loyalty_line(&line, &mut ctx) { - emitter.ability_at(item_line, ability); + emitter.ability_ir_at(item_line, ability); i += 1; continue; } @@ -7293,7 +7293,7 @@ fn minus_x_loyalty_cost() -> AbilityCost { } /// Try to parse a planeswalker loyalty line: "+N:", "−N:", "0:", "[+N]:", "[−N]:", "[0]:", "[−X]:" -fn try_parse_loyalty_line(line: &str, ctx: &mut ParseContext) -> Option { +fn try_parse_loyalty_line(line: &str, ctx: &mut ParseContext) -> Option { let trimmed = line.trim(); // Try bracket format first: [+2]: ..., [−1]: ..., [0]: ..., [−X]: ... @@ -7305,26 +7305,20 @@ fn try_parse_loyalty_line(line: &str, ctx: &mut ParseContext) -> Option Option Option Option AbilityIr { + ctx.subject = None; + ctx.actor = None; + let mut ir = parse_ability_ir_with_context(effect_text, AbilityKind::Activated, ctx); + ir.shell.cost = Some(cost); + ir.shell.description = Some(description.to_string()); + apply_loyalty_restrictions(&mut ir.shell); + ir +} + /// CR 606.3: A player may activate a loyalty ability only during a main phase /// of their turn with an empty stack, and only if no player has previously /// activated a loyalty ability of that permanent that turn. The planeswalker @@ -7383,13 +7389,14 @@ fn try_parse_loyalty_line(line: &str, ctx: &mut ParseContext) -> Option bool { @@ -932,6 +934,61 @@ fn jace_the_mind_sculptor() { insta::assert_json_snapshot!("jace_the_mind_sculptor_lowered", &lowered); } +/// CR 606.3 + CR 606.5 + CR 107.3a: a `[−X]` loyalty header remains native +/// document IR, preserving its chosen-X loyalty-counter cost and sorcery-speed +/// activation envelope until the sole lowering seam. +#[test] +fn chandra_nalaar_minus_x_loyalty_is_ir_native() { + let (ir, lowered) = parse_two_layer( + "[−X]: Chandra Nalaar deals X damage to target creature.", + "Chandra Nalaar", + &["Planeswalker"], + &["Chandra Nalaar", "Chandra"], + ); + + assert_eq!(ir.items.len(), 1); + let OracleNodeIr::Spell(ability) = &ir.items[0].node else { + panic!( + "expected native loyalty spell IR, got {:?}", + ir.items[0].node + ); + }; + assert!(matches!( + ability.shell.cost.as_ref(), + Some(AbilityCost::RemoveCounter { + count: crate::types::ability::REMOVE_COUNTER_COST_X, + counter_type: crate::types::counter::CounterMatch::OfType( + crate::types::counter::CounterType::Loyalty + ), + target: None, + .. + }) + )); + assert_eq!( + ability.shell.description.as_deref(), + Some("[−X]: ~ deals X damage to target creature.") + ); + assert_eq!( + ability.shell.activation_restrictions, + vec![ActivationRestriction::AsSorcery] + ); + assert_eq!(lowered.abilities.len(), 1); + assert!(matches!( + lowered.abilities[0].cost.as_ref(), + Some(AbilityCost::RemoveCounter { + count: crate::types::ability::REMOVE_COUNTER_COST_X, + counter_type: crate::types::counter::CounterMatch::OfType( + crate::types::counter::CounterType::Loyalty + ), + target: None, + .. + }) + )); + + insta::assert_json_snapshot!("chandra_nalaar_minus_x_loyalty_ir", &ir); + insta::assert_json_snapshot!("chandra_nalaar_minus_x_loyalty_lowered", &lowered); +} + // --------------------------------------------------------------------------- // Equipment / Vehicles // --------------------------------------------------------------------------- diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__chandra_nalaar_minus_x_loyalty_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__chandra_nalaar_minus_x_loyalty_ir.snap new file mode 100644 index 0000000000..25b696378d --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__chandra_nalaar_minus_x_loyalty_ir.snap @@ -0,0 +1,128 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&ir" +--- +{ + "items": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 0 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 44, + "precision": "Exact", + "ordinal_within_span": 0 + }, + "fragment": "[−X]: ~ deals X damage to target creature." + }, + "node": { + "Spell": { + "source_text": "~ deals X damage to target creature.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 35, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "~ deals X damage to target creature" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "DealDamage", + "amount": { + "type": "Ref", + "qty": { + "type": "Variable", + "name": "X" + } + }, + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [] + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null, + "cost": { + "type": "RemoveCounter", + "count": 4294967295, + "counter_type": { + "type": "OfType", + "data": "loyalty" + }, + "target": null, + "selection": "SingleObject" + }, + "activation_restrictions": [ + { + "type": "AsSorcery" + } + ], + "description": "[−X]: ~ deals X damage to target creature." + }, + "die_results": [], + "root_transforms": [] + } + } + } + ], + "source_text": "[−X]: Chandra Nalaar deals X damage to target creature.", + "card_name": "Chandra Nalaar" +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__chandra_nalaar_minus_x_loyalty_lowered.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__chandra_nalaar_minus_x_loyalty_lowered.snap new file mode 100644 index 0000000000..cc900bc4c8 --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__chandra_nalaar_minus_x_loyalty_lowered.snap @@ -0,0 +1,56 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&lowered" +--- +{ + "abilities": [ + { + "kind": "Activated", + "effect": { + "type": "DealDamage", + "amount": { + "type": "Ref", + "qty": { + "type": "Variable", + "name": "X" + } + }, + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [] + } + }, + "cost": { + "type": "RemoveCounter", + "count": 4294967295, + "counter_type": { + "type": "OfType", + "data": "loyalty" + }, + "target": null, + "selection": "SingleObject" + }, + "sub_ability": null, + "duration": null, + "description": "[−X]: ~ deals X damage to target creature.", + "target_prompt": null, + "activation_restrictions": [ + { + "type": "AsSorcery" + } + ], + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + } + ], + "triggers": [], + "statics": [], + "replacements": [], + "extractedKeywords": [] +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jace_the_mind_sculptor_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jace_the_mind_sculptor_ir.snap index 223b385cb9..ffd51dbcfd 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jace_the_mind_sculptor_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__jace_the_mind_sculptor_ir.snap @@ -22,69 +22,156 @@ expression: "&ir" "fragment": "[+2]: Look at the top card of target player's library. You may put that card on the bottom of that player's library." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "Dig", - "player": { - "type": "Player" - }, - "count": { - "type": "Fixed", - "value": 1 - }, - "destination": null, - "keep_count": 0, - "up_to": false, - "filter": { - "type": "Any" - }, - "rest_destination": null, - "reveal": false, - "enter_tapped": false - }, - "cost": { - "type": "Loyalty", - "amount": 2 - }, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "PutAtLibraryPosition", - "target": { - "type": "ParentTarget" - }, - "count": { - "type": "Fixed", - "value": 1 + "Spell": { + "source_text": "Look at the top card of target player's library. You may put that card on the bottom of that player's library.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 47, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Look at the top card of target player's library" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Dig", + "player": { + "type": "Player" + }, + "count": { + "type": "Fixed", + "value": 1 + }, + "destination": null, + "keep_count": null, + "up_to": false, + "filter": { + "type": "Any" + }, + "rest_destination": null, + "reveal": false, + "enter_tapped": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "position": { - "type": "Bottom" + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 49, + "end_byte": 109, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "You may put that card on the bottom of that player's library" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "PutAtLibraryPosition", + "target": { + "type": "ParentTarget" + }, + "count": { + "type": "Fixed", + "value": 1 + }, + "position": { + "type": "Bottom" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": true, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null, + "cost": { + "type": "Loyalty", + "amount": 2 }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": true, - "forward_result": false, - "sub_link": "SequentialSibling" + "activation_restrictions": [ + { + "type": "AsSorcery" + } + ], + "description": "[+2]: Look at the top card of target player's library. You may put that card on the bottom of that player's library." }, - "duration": null, - "description": "[+2]: Look at the top card of target player's library. You may put that card on the bottom of that player's library.", - "target_prompt": null, - "activation_restrictions": [ - { - "type": "AsSorcery" - } - ], - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [], + "root_transforms": [] } } }, @@ -106,69 +193,157 @@ expression: "&ir" "fragment": "[0]: Draw three cards, then put two cards from your hand on top of your library in any order." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "Draw", - "count": { - "type": "Fixed", - "value": 3 - }, - "target": { - "type": "Controller" - } - }, - "cost": { - "type": "Loyalty", - "amount": 0 - }, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "PutAtLibraryPosition", - "target": { - "type": "Typed", - "type_filters": [ - "Card" - ], - "controller": "You", - "properties": [ - { - "type": "InZone", - "zone": "Hand" + "Spell": { + "source_text": "Draw three cards, then put two cards from your hand on top of your library in any order.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 16, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Draw three cards" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null } - ] - }, - "count": { - "type": "Fixed", - "value": 2 + }, + "parsed": { + "effect": { + "type": "Draw", + "count": { + "type": "Fixed", + "value": 3 + }, + "target": { + "type": "Controller" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Then", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "position": { - "type": "Top" + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 23, + "end_byte": 87, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "put two cards from your hand on top of your library in any order" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "PutAtLibraryPosition", + "target": { + "type": "Typed", + "type_filters": [ + "Card" + ], + "controller": "You", + "properties": [ + { + "type": "InZone", + "zone": "Hand" + } + ] + }, + "count": { + "type": "Fixed", + "value": 2 + }, + "position": { + "type": "Top" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null, + "cost": { + "type": "Loyalty", + "amount": 0 }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "activation_restrictions": [ + { + "type": "AsSorcery" + } + ], + "description": "[0]: Draw three cards, then put two cards from your hand on top of your library in any order." }, - "duration": null, - "description": "[0]: Draw three cards, then put two cards from your hand on top of your library in any order.", - "target_prompt": null, - "activation_restrictions": [ - { - "type": "AsSorcery" - } - ], - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [], + "root_transforms": [] } } }, @@ -190,37 +365,91 @@ expression: "&ir" "fragment": "[−1]: Return target creature to its owner's hand." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "Bounce", - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": null, - "properties": [] - }, - "destination": null + "Spell": { + "source_text": "Return target creature to its owner's hand.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 42, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Return target creature to its owner's hand" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Bounce", + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [] + }, + "destination": null + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": { - "type": "Loyalty", - "amount": -1 + "shell": { + "sub_link": null, + "cost": { + "type": "Loyalty", + "amount": -1 + }, + "activation_restrictions": [ + { + "type": "AsSorcery" + } + ], + "description": "[−1]: Return target creature to its owner's hand." }, - "sub_ability": null, - "duration": null, - "description": "[−1]: Return target creature to its owner's hand.", - "target_prompt": null, - "activation_restrictions": [ - { - "type": "AsSorcery" - } - ], - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [], + "root_transforms": [] } } }, @@ -242,83 +471,171 @@ expression: "&ir" "fragment": "[−12]: Exile all cards from target player's library, then that player shuffles their hand into their library." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "ChangeZoneAll", - "origin": "Library", - "destination": "Exile", - "target": { - "type": "Typed", - "type_filters": [ - "Card" - ], - "controller": null, - "properties": [ - { - "type": "Owned", - "controller": "TargetPlayer" + "Spell": { + "source_text": "Exile all cards from target player's library, then that player shuffles their hand into their library.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 44, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Exile all cards from target player's library" }, - { - "type": "InZone", - "zone": "Library" - } - ] - } - }, - "cost": { - "type": "Loyalty", - "amount": -12 - }, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "ChangeZoneAll", - "origin": "Hand", - "destination": "Library", - "target": { - "type": "ParentTargetController" - } - }, - "cost": null, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "Shuffle", - "target": { - "type": "ParentTargetController" - } + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "ChangeZoneAll", + "origin": "Library", + "destination": "Exile", + "target": { + "type": "Typed", + "type_filters": [ + "Card" + ], + "controller": null, + "properties": [ + { + "type": "Owned", + "controller": "TargetPlayer" + }, + { + "type": "InZone", + "zone": "Library" + } + ] + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Then", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 51, + "end_byte": 101, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "that player shuffles their hand into their library" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "ChangeZoneAll", + "origin": "Hand", + "destination": "Library", + "target": { + "type": "ParentTargetController" + } + }, + "duration": null, + "sub_ability": { + "kind": "Spell", + "effect": { + "type": "Shuffle", + "target": { + "type": "ParentTargetController" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null, + "cost": { + "type": "Loyalty", + "amount": -12 }, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "activation_restrictions": [ + { + "type": "AsSorcery" + } + ], + "description": "[−12]: Exile all cards from target player's library, then that player shuffles their hand into their library." }, - "duration": null, - "description": "[−12]: Exile all cards from target player's library, then that player shuffles their hand into their library.", - "target_prompt": null, - "activation_restrictions": [ - { - "type": "AsSorcery" - } - ], - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [], + "root_transforms": [] } } } diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__liliana_of_the_veil_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__liliana_of_the_veil_ir.snap index fc7f912ec4..d22437a372 100644 --- a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__liliana_of_the_veil_ir.snap +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__liliana_of_the_veil_ir.snap @@ -22,38 +22,91 @@ expression: "&ir" "fragment": "[+1]: Each player discards a card." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "Discard", - "count": { - "type": "Fixed", - "value": 1 - }, - "target": { - "type": "Controller" - } + "Spell": { + "source_text": "Each player discards a card.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 27, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Each player discards a card" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Discard", + "count": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "Controller" + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": { + "type": "All" + }, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": { - "type": "Loyalty", - "amount": 1 + "shell": { + "sub_link": null, + "cost": { + "type": "Loyalty", + "amount": 1 + }, + "activation_restrictions": [ + { + "type": "AsSorcery" + } + ], + "description": "[+1]: Each player discards a card." }, - "sub_ability": null, - "duration": null, - "description": "[+1]: Each player discards a card.", - "target_prompt": null, - "activation_restrictions": [ - { - "type": "AsSorcery" - } - ], - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "player_scope": { - "type": "All" - } + "die_results": [], + "root_transforms": [] } } }, @@ -75,40 +128,94 @@ expression: "&ir" "fragment": "[−2]: Target player sacrifices a creature." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "Sacrifice", - "target": { - "type": "Typed", - "type_filters": [ - "Creature" - ], - "controller": "TargetPlayer", - "properties": [] - }, - "count": { - "type": "Fixed", - "value": 1 - } + "Spell": { + "source_text": "Target player sacrifices a creature.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 35, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Target player sacrifices a creature" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Sacrifice", + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "TargetPlayer", + "properties": [] + }, + "count": { + "type": "Fixed", + "value": 1 + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null }, - "cost": { - "type": "Loyalty", - "amount": -2 + "shell": { + "sub_link": null, + "cost": { + "type": "Loyalty", + "amount": -2 + }, + "activation_restrictions": [ + { + "type": "AsSorcery" + } + ], + "description": "[−2]: Target player sacrifices a creature." }, - "sub_ability": null, - "duration": null, - "description": "[−2]: Target player sacrifices a creature.", - "target_prompt": null, - "activation_restrictions": [ - { - "type": "AsSorcery" - } - ], - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [], + "root_transforms": [] } } }, @@ -130,67 +237,154 @@ expression: "&ir" "fragment": "[−6]: Separate all permanents target player controls into two piles. That player sacrifices all permanents in the pile of their choice." }, "node": { - "PreLoweredSpell": { - "kind": "Activated", - "effect": { - "type": "Unimplemented", - "name": "separate", - "description": "Separate all permanents target player controls into two piles" - }, - "cost": { - "type": "Loyalty", - "amount": -6 - }, - "sub_ability": { - "kind": "Spell", - "effect": { - "type": "Sacrifice", - "target": { - "type": "Typed", - "type_filters": [ - "Permanent" - ], - "controller": "ParentTargetController", - "properties": [] + "Spell": { + "source_text": "Separate all permanents target player controls into two piles. That player sacrifices all permanents in the pile of their choice.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 61, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Separate all permanents target player controls into two piles" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "Unimplemented", + "name": "separate", + "description": "Separate all permanents target player controls into two piles" + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null }, - "count": { - "type": "Ref", - "qty": { - "type": "ObjectCount", - "filter": { - "type": "Typed", - "type_filters": [ - "Permanent" - ], - "controller": null, - "properties": [] + { + "id": 1, + "source": { + "id": { + "item": 0, + "ordinal": 2 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 63, + "end_byte": 128, + "precision": "ChainRelative", + "ordinal_within_span": 1 + }, + "fragment": "That player sacrifices all permanents in the pile of their choice" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null } - } + }, + "parsed": { + "effect": { + "type": "Sacrifice", + "target": { + "type": "Typed", + "type_filters": [ + "Permanent" + ], + "controller": "ParentTargetController", + "properties": [] + }, + "count": { + "type": "Ref", + "qty": { + "type": "ObjectCount", + "filter": { + "type": "Typed", + "type_filters": [ + "Permanent" + ], + "controller": null, + "properties": [] + } + } + } + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": "Sentence", + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null, + "cost": { + "type": "Loyalty", + "amount": -6 }, - "cost": null, - "sub_ability": null, - "duration": null, - "description": null, - "target_prompt": null, - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false, - "sub_link": "SequentialSibling" + "activation_restrictions": [ + { + "type": "AsSorcery" + } + ], + "description": "[−6]: Separate all permanents target player controls into two piles. That player sacrifices all permanents in the pile of their choice." }, - "duration": null, - "description": "[−6]: Separate all permanents target player controls into two piles. That player sacrifices all permanents in the pile of their choice.", - "target_prompt": null, - "activation_restrictions": [ - { - "type": "AsSorcery" - } - ], - "condition": null, - "optional_targeting": false, - "optional": false, - "forward_result": false + "die_results": [], + "root_transforms": [] } } } From b67f6284716f4ae243ffa3bb2988b5c2a9f9fbd6 Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:03:09 -0700 Subject: [PATCH 34/63] refactor(parser): emit temporal delayed triggers as native ir (#6809) * refactor(parser): emit temporal delayed triggers as native ir * test(parser): cover whenever temporal trigger ir * test(parser): cover copy-next temporal ir helper * test(parser): isolate copy-next temporal helper clause --------- Co-authored-by: matthewevans --- crates/engine/src/parser/oracle.rs | 5 +- crates/engine/src/parser/oracle_effect/mod.rs | 10 +- .../engine/src/parser/oracle_effect/tests.rs | 20 +++ .../src/parser/oracle_ir/snapshot_tests.rs | 95 ++++++++++ ...yed_trigger@full_throttle_temporal_ir.snap | 154 ++++++++++++++++ ...rigger@full_throttle_temporal_lowered.snap | 84 +++++++++ ...rigger@galvanic_iteration_temporal_ir.snap | 164 ++++++++++++++++++ ...r@galvanic_iteration_temporal_lowered.snap | 94 ++++++++++ ..._trigger@pact_of_negation_temporal_ir.snap | 156 +++++++++++++++++ ...ger@pact_of_negation_temporal_lowered.snap | 86 +++++++++ 10 files changed, 864 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@full_throttle_temporal_ir.snap create mode 100644 crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@full_throttle_temporal_lowered.snap create mode 100644 crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@galvanic_iteration_temporal_ir.snap create mode 100644 crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@galvanic_iteration_temporal_lowered.snap create mode 100644 crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@pact_of_negation_temporal_ir.snap create mode 100644 crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@pact_of_negation_temporal_lowered.snap diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index 70d9eda5d1..8daa321626 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -5162,9 +5162,10 @@ pub(crate) fn parse_oracle_ir( // trigger-shaped temporal text through the effect parser before generic // trigger dispatch. if is_spell && has_trigger_prefix(&lower) { - if let Some(def) = try_parse_temporal_delayed_trigger_ability(&line, AbilityKind::Spell) + if let Some(ability) = + try_parse_temporal_delayed_trigger_ability(&line, AbilityKind::Spell) { - emitter.ability_at(item_line, def); + emitter.ability_ir_at(item_line, ability); i += 1; continue; } diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index c374d359c8..0bb81c1fb3 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -1653,14 +1653,20 @@ fn try_parse_copy_next_spell_when_cast(tp: TextPair) -> Option Option { +) -> Option { let lower = text.to_lowercase(); let tp = TextPair::new(text, &lower); let clause = try_parse_whenever_this_turn(tp) .or_else(|| try_parse_when_next_event(tp)) .or_else(|| try_parse_copy_next_spell_when_cast(tp)) .or_else(|| try_parse_at_next_phase_delayed_trigger(text, kind))?; - Some(ability_definition_from_clause(kind, clause)) + Some(AbilityIr { + source_text: text.to_string(), + body: EffectChainIr::single_clause(text, kind, clause, None, None, false), + shell: AbilityShellIr::default(), + die_results: vec![], + root_transforms: vec![], + }) } /// CR 603.7a: Pact-cycle instants ("At the beginning of your next upkeep, pay diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index f76c015079..10720f6b56 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -11127,6 +11127,26 @@ fn effect_tzaangor_copy_next_spell_when_cast() { ); } +/// CR 603.7: Tzaangor Shaman's copy-next clause is reachable from the +/// effect-chain parser, but not from the spell trigger-prefix router. Its +/// temporal helper must therefore construct native IR directly. +#[test] +fn temporal_copy_next_helper_emits_native_ir() { + // This is the first sentence of Tzaangor Shaman's AtomicCards Oracle text. + // `parse_effect_chain` splits the following retarget sentence before this + // helper sees its all-consuming copy-next grammar. + let text = "Copy the next instant or sorcery spell you cast this turn when you cast it"; + let ir = try_parse_temporal_delayed_trigger_ability(text, AbilityKind::Spell) + .expect("Tzaangor Shaman copy-next grammar must parse"); + + assert_eq!(ir.source_text, text); + assert_eq!(ir.body.clauses.len(), 1); + assert!(matches!( + &ir.body.clauses[0].parsed.effect, + Effect::CreateDelayedTrigger { .. } + )); +} + #[test] fn effect_each_merfolk_creature_you_control_explores_uses_explore_all() { let e = parse_effect("Each Merfolk creature you control explores"); diff --git a/crates/engine/src/parser/oracle_ir/snapshot_tests.rs b/crates/engine/src/parser/oracle_ir/snapshot_tests.rs index 0468eff710..d3e5d2c6bc 100644 --- a/crates/engine/src/parser/oracle_ir/snapshot_tests.rs +++ b/crates/engine/src/parser/oracle_ir/snapshot_tests.rs @@ -989,6 +989,101 @@ fn chandra_nalaar_minus_x_loyalty_is_ir_native() { insta::assert_json_snapshot!("chandra_nalaar_minus_x_loyalty_lowered", &lowered); } +// --------------------------------------------------------------------------- +// Spell temporal delayed triggers +// --------------------------------------------------------------------------- + +/// CR 603.7a-c: spell-only temporal trigger lines stay as native document IR +/// until the sole lowering seam. The Pact payload remains a deliberately +/// lowered boxed ability inside the outer delayed-trigger clause. +#[test] +fn temporal_delayed_trigger_spell_router_is_ir_native() { + // The three established grammar representatives remain the stable IR/lowered + // snapshot fixtures. The direct `Whenever … this turn` arm uses the same + // structural assertions without a fourth snapshot pair. + let cases = [ + ( + None, + "Whenever you cast a creature spell this turn, draw a card.", + "Glimpse of Nature", + &["Sorcery"][..], + ), + ( + Some(( + "pact_of_negation_temporal_ir", + "pact_of_negation_temporal_lowered", + )), + "At the beginning of your next upkeep, pay {3}{U}{U}. If you don't, you lose the game.", + "Pact of Negation", + &["Instant"][..], + ), + ( + Some(( + "full_throttle_temporal_ir", + "full_throttle_temporal_lowered", + )), + "At the beginning of each combat this turn, untap all creatures that attacked this turn.", + "Full Throttle", + &["Sorcery"][..], + ), + ( + Some(( + "galvanic_iteration_temporal_ir", + "galvanic_iteration_temporal_lowered", + )), + "When you next cast an instant or sorcery spell this turn, copy that spell. You may choose new targets for the copy.", + "Galvanic Iteration", + &["Instant"][..], + ), + ]; + + for (snapshots, oracle_text, card_name, types) in cases { + let (ir, lowered) = parse_two_layer(oracle_text, card_name, types, &[]); + assert_eq!( + ir.items.len(), + 1, + "{card_name}: one source line emits one item" + ); + let OracleNodeIr::Spell(ability) = &ir.items[0].node else { + panic!("{card_name}: expected native temporal spell IR"); + }; + assert!(matches!( + &ability.body.clauses[0].parsed.effect, + Effect::CreateDelayedTrigger { .. } + )); + assert_eq!( + lowered.abilities.len(), + 1, + "{card_name}: one lowered ability" + ); + assert!(matches!( + lowered.abilities[0].effect.as_ref(), + Effect::CreateDelayedTrigger { .. } + )); + + if card_name == "Pact of Negation" { + let Effect::CreateDelayedTrigger { effect, .. } = + &ability.body.clauses[0].parsed.effect + else { + unreachable!("checked above"); + }; + assert!(matches!( + effect.kind, + crate::types::ability::AbilityKind::Spell + )); + } + + if let Some((ir_snapshot, lowered_snapshot)) = snapshots { + insta::with_settings!({ snapshot_suffix => ir_snapshot }, { + insta::assert_json_snapshot!("temporal_delayed_trigger", &ir); + }); + insta::with_settings!({ snapshot_suffix => lowered_snapshot }, { + insta::assert_json_snapshot!("temporal_delayed_trigger", &lowered); + }); + } + } +} + // --------------------------------------------------------------------------- // Equipment / Vehicles // --------------------------------------------------------------------------- diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@full_throttle_temporal_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@full_throttle_temporal_ir.snap new file mode 100644 index 0000000000..f0fd67cc8a --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@full_throttle_temporal_ir.snap @@ -0,0 +1,154 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&ir" +--- +{ + "items": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 0 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 87, + "precision": "Exact", + "ordinal_within_span": 0 + }, + "fragment": "At the beginning of each combat this turn, untap all creatures that attacked this turn." + }, + "node": { + "Spell": { + "source_text": "At the beginning of each combat this turn, untap all creatures that attacked this turn.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 87, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "At the beginning of each combat this turn, untap all creatures that attacked this turn." + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "CreateDelayedTrigger", + "condition": { + "type": "WheneverEvent", + "trigger": { + "mode": "Phase", + "execute": null, + "valid_card": null, + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": "BeginCombat", + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + } + }, + "effect": { + "kind": "Spell", + "effect": { + "type": "SetTapState", + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [ + { + "type": "AttackedThisTurn" + } + ] + }, + "scope": { + "type": "All" + }, + "state": { + "type": "Untap" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "uses_tracked_set": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null + }, + "die_results": [], + "root_transforms": [] + } + } + } + ], + "source_text": "At the beginning of each combat this turn, untap all creatures that attacked this turn.", + "card_name": "Full Throttle" +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@full_throttle_temporal_lowered.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@full_throttle_temporal_lowered.snap new file mode 100644 index 0000000000..b0cade1af7 --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@full_throttle_temporal_lowered.snap @@ -0,0 +1,84 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&lowered" +--- +{ + "abilities": [ + { + "kind": "Spell", + "effect": { + "type": "CreateDelayedTrigger", + "condition": { + "type": "WheneverEvent", + "trigger": { + "mode": "Phase", + "execute": null, + "valid_card": null, + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": "BeginCombat", + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + } + }, + "effect": { + "kind": "Spell", + "effect": { + "type": "SetTapState", + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [ + { + "type": "AttackedThisTurn" + } + ] + }, + "scope": { + "type": "All" + }, + "state": { + "type": "Untap" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "uses_tracked_set": false + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + } + ], + "triggers": [], + "statics": [], + "replacements": [], + "extractedKeywords": [] +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@galvanic_iteration_temporal_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@galvanic_iteration_temporal_ir.snap new file mode 100644 index 0000000000..97380972af --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@galvanic_iteration_temporal_ir.snap @@ -0,0 +1,164 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&ir" +--- +{ + "items": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 0 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 115, + "precision": "Exact", + "ordinal_within_span": 0 + }, + "fragment": "When you next cast an instant or sorcery spell this turn, copy that spell. You may choose new targets for the copy." + }, + "node": { + "Spell": { + "source_text": "When you next cast an instant or sorcery spell this turn, copy that spell. You may choose new targets for the copy.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 115, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "When you next cast an instant or sorcery spell this turn, copy that spell. You may choose new targets for the copy." + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "CreateDelayedTrigger", + "condition": { + "type": "WhenNextEvent", + "trigger": { + "mode": "SpellCast", + "execute": null, + "valid_card": { + "type": "Or", + "filters": [ + { + "type": "Typed", + "type_filters": [ + "Instant" + ], + "controller": null, + "properties": [] + }, + { + "type": "Typed", + "type_filters": [ + "Sorcery" + ], + "controller": null, + "properties": [] + } + ] + }, + "origin": null, + "destination": null, + "trigger_zones": [], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": { + "type": "Controller" + }, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + }, + "or_trigger": null + }, + "effect": { + "kind": "Spell", + "effect": { + "type": "CopySpell", + "target": { + "type": "TriggeringSource" + }, + "retarget": { + "type": "MayChooseNewTargets" + }, + "starting_loyalty_from_casualty_sacrifice": false + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "uses_tracked_set": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null + }, + "die_results": [], + "root_transforms": [] + } + } + } + ], + "source_text": "When you next cast an instant or sorcery spell this turn, copy that spell. You may choose new targets for the copy.", + "card_name": "Galvanic Iteration" +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@galvanic_iteration_temporal_lowered.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@galvanic_iteration_temporal_lowered.snap new file mode 100644 index 0000000000..a643b06c9e --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@galvanic_iteration_temporal_lowered.snap @@ -0,0 +1,94 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&lowered" +--- +{ + "abilities": [ + { + "kind": "Spell", + "effect": { + "type": "CreateDelayedTrigger", + "condition": { + "type": "WhenNextEvent", + "trigger": { + "mode": "SpellCast", + "execute": null, + "valid_card": { + "type": "Or", + "filters": [ + { + "type": "Typed", + "type_filters": [ + "Instant" + ], + "controller": null, + "properties": [] + }, + { + "type": "Typed", + "type_filters": [ + "Sorcery" + ], + "controller": null, + "properties": [] + } + ] + }, + "origin": null, + "destination": null, + "trigger_zones": [], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": { + "type": "Controller" + }, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + }, + "or_trigger": null + }, + "effect": { + "kind": "Spell", + "effect": { + "type": "CopySpell", + "target": { + "type": "TriggeringSource" + }, + "retarget": { + "type": "MayChooseNewTargets" + }, + "starting_loyalty_from_casualty_sacrifice": false + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "uses_tracked_set": false + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + } + ], + "triggers": [], + "statics": [], + "replacements": [], + "extractedKeywords": [] +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@pact_of_negation_temporal_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@pact_of_negation_temporal_ir.snap new file mode 100644 index 0000000000..941b4c01bf --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@pact_of_negation_temporal_ir.snap @@ -0,0 +1,156 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&ir" +--- +{ + "items": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 0 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 85, + "precision": "Exact", + "ordinal_within_span": 0 + }, + "fragment": "At the beginning of your next upkeep, pay {3}{U}{U}. If you don't, you lose the game." + }, + "node": { + "Spell": { + "source_text": "At the beginning of your next upkeep, pay {3}{U}{U}. If you don't, you lose the game.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 85, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "At the beginning of your next upkeep, pay {3}{U}{U}. If you don't, you lose the game." + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "CreateDelayedTrigger", + "condition": { + "type": "AtNextPhaseForPlayer", + "phase": "Upkeep", + "player": 0 + }, + "effect": { + "kind": "Spell", + "effect": { + "type": "PayCost", + "cost": { + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [ + "Blue", + "Blue" + ], + "generic": 3 + } + }, + "payer": { + "type": "Controller" + } + }, + "cost": null, + "sub_ability": { + "kind": "Spell", + "effect": { + "type": "LoseTheGame", + "target": { + "type": "Controller" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": { + "type": "Not", + "condition": { + "type": "EffectOutcome", + "signal": "OptionalEffectPerformed" + } + }, + "optional_targeting": false, + "optional": false, + "forward_result": false, + "sub_link": "SequentialSibling" + }, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "uses_tracked_set": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null + }, + "die_results": [], + "root_transforms": [] + } + } + } + ], + "source_text": "At the beginning of your next upkeep, pay {3}{U}{U}. If you don't, you lose the game.", + "card_name": "Pact of Negation" +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@pact_of_negation_temporal_lowered.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@pact_of_negation_temporal_lowered.snap new file mode 100644 index 0000000000..818853fe57 --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@pact_of_negation_temporal_lowered.snap @@ -0,0 +1,86 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&lowered" +--- +{ + "abilities": [ + { + "kind": "Spell", + "effect": { + "type": "CreateDelayedTrigger", + "condition": { + "type": "AtNextPhaseForPlayer", + "phase": "Upkeep", + "player": 0 + }, + "effect": { + "kind": "Spell", + "effect": { + "type": "PayCost", + "cost": { + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [ + "Blue", + "Blue" + ], + "generic": 3 + } + }, + "payer": { + "type": "Controller" + } + }, + "cost": null, + "sub_ability": { + "kind": "Spell", + "effect": { + "type": "LoseTheGame", + "target": { + "type": "Controller" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": { + "type": "Not", + "condition": { + "type": "EffectOutcome", + "signal": "OptionalEffectPerformed" + } + }, + "optional_targeting": false, + "optional": false, + "forward_result": false, + "sub_link": "SequentialSibling" + }, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "uses_tracked_set": false + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + } + ], + "triggers": [], + "statics": [], + "replacements": [], + "extractedKeywords": [] +} From 896f0aadf2be3929dee1d5141b68dc9098e0d9f1 Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:41:29 -0700 Subject: [PATCH 35/63] chore: refresh metagame feeds (#6810) --- .../public/feeds/mtggoldfish-commander.json | 1888 ++++++++--------- client/public/feeds/mtggoldfish-modern.json | 138 +- client/public/feeds/mtggoldfish-pioneer.json | 2 +- client/public/feeds/mtggoldfish-standard.json | 2 +- 4 files changed, 990 insertions(+), 1040 deletions(-) diff --git a/client/public/feeds/mtggoldfish-commander.json b/client/public/feeds/mtggoldfish-commander.json index 19d539cbb7..a6e23521ce 100644 --- a/client/public/feeds/mtggoldfish-commander.json +++ b/client/public/feeds/mtggoldfish-commander.json @@ -5,7 +5,7 @@ "icon": "G", "format": "commander", "version": 1, - "updated": "2026-07-29T00:00:00Z", + "updated": "2026-07-30T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/commander", "decks": [ { @@ -90,6 +90,10 @@ "count": 1, "name": "Fishing Gear" }, + { + "count": 1, + "name": "Hulkbuster Armor" + }, { "count": 1, "name": "Invisible Force Field" @@ -146,6 +150,10 @@ "count": 1, "name": "Shielded by Faith" }, + { + "count": 1, + "name": "Sigarda's Aid" + }, { "count": 1, "name": "Smuggler's Share" @@ -156,7 +164,7 @@ }, { "count": 1, - "name": "Spectacular Spider-Man" + "name": "Silence" }, { "count": 1, @@ -191,7 +199,7 @@ "name": "Gift of Estates" }, { - "count": 29, + "count": 26, "name": "Plains" }, { @@ -305,6 +313,10 @@ { "count": 1, "name": "Infiltration Lens" + }, + { + "count": 1, + "name": "Psychosis Crawler" } ], "sideboard": [] @@ -313,7 +325,9 @@ "name": "Doctor Doom, King of Latveria", "author": "MTGGoldfish", "colors": [ - "U" + "U", + "B", + "R" ], "tags": [ "metagame" @@ -321,355 +335,367 @@ "main": [ { "count": 1, - "name": "Abomination, World Ravager" + "name": "Molecule Man" }, { "count": 1, - "name": "Arcane Signet" + "name": "Helmut Zemo, Mastermind" }, { "count": 1, - "name": "Archfiend of Ifnir" + "name": "The Frightful Four" }, { "count": 1, - "name": "Archnemesis" + "name": "Iron Monger, Sadistic Tycoon" }, { "count": 1, - "name": "Baron Strucker, HYDRA Overlord" + "name": "Klaw, Master of Sound" }, { "count": 1, - "name": "Bedevil" + "name": "Batroc the Leaper" }, { "count": 1, - "name": "Black Market Connections" + "name": "Killmonger, Ruthless Usurper" }, { "count": 1, - "name": "Blasphemous Act" + "name": "Lady Loki, Agent of Chaos" }, { "count": 1, - "name": "Chameleon, Master of Disguise" + "name": "Living Laser" }, { "count": 1, - "name": "Chaos Warp" + "name": "Kang Prime" }, { "count": 1, - "name": "Containment Construct" + "name": "Loki, the Deceiver" }, { "count": 1, - "name": "Cool but Rude" + "name": "Red Ghost, Intangible Genius" }, { "count": 1, - "name": "Counterspell" + "name": "The Squadron Sinister" }, { "count": 1, - "name": "Currency Converter" + "name": "Typhoid Mary, Fractured" }, { "count": 1, - "name": "Dark Ritual" + "name": "Ultron, Unlimited" }, { "count": 1, - "name": "Doctor Octopus, Master Planner" + "name": "Tri-Sentinel, Act of Vengeance" }, { "count": 1, - "name": "Doom Reigns Supreme" + "name": "Spark Double" }, { "count": 1, - "name": "Doom's Time Platform" + "name": "Titan of Littjara" }, { "count": 1, - "name": "Endless Ranks of HYDRA" + "name": "Baron Strucker, HYDRA Overlord" }, { "count": 1, - "name": "Extract Power" + "name": "Moonstone, Harsh Mistress" }, { "count": 1, - "name": "Frantic Search" + "name": "Kang, Temporal Tyrant" }, { "count": 1, - "name": "Glorious Purpose" + "name": "Madame Hydra" }, { "count": 1, - "name": "Iron Monger, Sadistic Tycoon" + "name": "Containment Construct" }, { "count": 1, - "name": "Kang, Temporal Tyrant" + "name": "Chameleon, Master of Disguise" }, { "count": 1, - "name": "Killmonger, Ruthless Usurper" + "name": "Tombstone, Career Criminal" }, { "count": 1, - "name": "Kindred Dominance" + "name": "Prowler, Clawed Thief" }, { "count": 1, - "name": "Leader, Super-Genius" + "name": "Glorious Purpose" }, { "count": 1, - "name": "Lethal Scheme" + "name": "Kang Dynasty" }, { "count": 1, - "name": "Living Laser" + "name": "Age of Ultron" }, { "count": 1, - "name": "Loki's Scepter" + "name": "Archnemesis" }, { "count": 1, - "name": "Loki, the Deceiver" + "name": "Black Market Connections" }, { "count": 1, - "name": "M.O.D.O.K." + "name": "Damocles Base, Sword of Kang" }, { "count": 1, - "name": "Madame Hydra" + "name": "Loki's Scepter" }, { "count": 1, - "name": "Molecule Man" + "name": "Doom's Time Platform" }, { "count": 1, - "name": "Moonstone, Harsh Mistress" + "name": "Currency Converter" }, { "count": 1, - "name": "Night's Whisper" + "name": "Skullclamp" }, { "count": 1, - "name": "Norman Osborn" + "name": "Arcane Signet" }, { "count": 1, - "name": "Propaganda" + "name": "Sol Ring" }, { "count": 1, - "name": "Prowler, Clawed Thief" + "name": "Swiftfoot Boots" }, { "count": 1, - "name": "Puppet Master, String Puller" + "name": "Talisman of Dominance" }, { "count": 1, - "name": "Red Ghost, Intangible Genius" + "name": "Talisman of Indulgence" }, { "count": 1, - "name": "Sol Ring" + "name": "Lethal Scheme" }, { "count": 1, - "name": "Spark Double" + "name": "Chaos Warp" }, { "count": 1, - "name": "Swiftfoot Boots" + "name": "Bedevil" }, { "count": 1, - "name": "Talisman of Dominance" + "name": "Withering Torment" }, { "count": 1, - "name": "Talisman of Indulgence" + "name": "Terminate" }, { "count": 1, - "name": "Terminate" + "name": "Extract Power" }, { "count": 1, - "name": "The Frightful Four" + "name": "Kindred Dominance" }, { "count": 1, - "name": "The Squadron Sinister" + "name": "Toxic Deluge" }, { "count": 1, - "name": "Titan of Littjara" + "name": "Vandalblast" }, { "count": 1, - "name": "Tombstone, Career Criminal" + "name": "Canyon Slough" }, { "count": 1, - "name": "Toxic Deluge" + "name": "Choked Estuary" }, { "count": 1, - "name": "Typhoid Mary, Fractured" + "name": "Coastal Peak" }, { "count": 1, - "name": "Ultron, Unlimited" + "name": "Dragonskull Summit" }, { "count": 1, - "name": "Vandalblast" + "name": "Drowned Catacomb" }, { "count": 1, - "name": "Villainous Hideout" + "name": "Exotic Orchard" }, { "count": 1, - "name": "Withering Torment" + "name": "Fetid Pools" }, { "count": 1, - "name": "Patchwork Banner" + "name": "Foreboding Ruins" }, { "count": 1, - "name": "Green Goblin, Revenant" + "name": "Frostboil Snarl" }, { "count": 1, - "name": "Big Score" + "name": "Luxury Suite" }, { "count": 1, - "name": "Reanimate" + "name": "Scorched Geyser" }, { "count": 1, - "name": "Monument to Endurance" + "name": "Smoldering Marsh" }, { "count": 1, - "name": "Kang Dynasty" + "name": "Sulfur Falls" }, { "count": 1, - "name": "Age of Ultron" + "name": "Sunken Hollow" }, { "count": 1, - "name": "Canyon Slough" + "name": "Command Tower" }, { "count": 1, - "name": "Choked Estuary" + "name": "Crumbling Necropolis" }, { "count": 1, - "name": "Coastal Peak" + "name": "Path of Ancestry" }, { "count": 1, - "name": "Command Tower" + "name": "Secluded Courtyard" + }, + { + "count": 4, + "name": "Island" + }, + { + "count": 5, + "name": "Swamp" + }, + { + "count": 3, + "name": "Mountain" }, { "count": 1, - "name": "Crumbling Necropolis" + "name": "Doctor Doom, King of Latveria" }, { "count": 1, - "name": "Dragonskull Summit" + "name": "Underworld Breach" }, { "count": 1, - "name": "Drowned Catacomb" + "name": "Norman Osborn" }, { "count": 1, - "name": "Exotic Orchard" + "name": "Reanimate" }, { "count": 1, - "name": "Fetid Pools" + "name": "Ledger Shredder" }, { "count": 1, - "name": "Foreboding Ruins" + "name": "Unexpected Windfall" }, { "count": 1, - "name": "Frostboil Snarl" + "name": "Monument to Endurance" }, { "count": 1, - "name": "Luxury Suite" + "name": "Shadow of the Goblin" }, { "count": 1, - "name": "Oscorp Industries" + "name": "Rielle, the Everwise" }, { "count": 1, - "name": "Path of Ancestry" + "name": "Big Score" }, { "count": 1, - "name": "Scavenger Grounds" + "name": "Damnation" }, { "count": 1, - "name": "Scorched Geyser" + "name": "Proft's Eidetic Memory" }, { "count": 1, - "name": "Secluded Courtyard" + "name": "Relic of Sauron" }, { "count": 1, - "name": "Smoldering Marsh" + "name": "Sulfurous Springs" }, { "count": 1, - "name": "Sulfur Falls" + "name": "Underground River" }, { "count": 1, - "name": "Sunken Hollow" + "name": "Xander's Lounge" }, { "count": 1, - "name": "Terramorphic Expanse" + "name": "Arid Mesa" }, { "count": 1, - "name": "Unclaimed Territory" + "name": "Blazemire Verge" }, { - "count": 13, - "name": "Island" + "count": 1, + "name": "Stormcarved Coast" }, { "count": 1, - "name": "Doctor Doom, King of Latveria" + "name": "Verdant Catacombs" } ], "sideboard": [] @@ -678,8 +704,8 @@ "name": "Tony Stark", "author": "MTGGoldfish", "colors": [ - "R", - "U" + "U", + "R" ], "tags": [ "metagame" @@ -687,338 +713,294 @@ "main": [ { "count": 1, - "name": "Irma, Part-Time Mutant" - }, - { - "count": 1, - "name": "Ironheart, Clever Champion" - }, - { - "count": 1, - "name": "Shuri, Wakandan Inventor" - }, - { - "count": 1, - "name": "Lady Octopus, Inspired Inventor" + "name": "An Offer You Can't Refuse" }, { "count": 1, - "name": "Raubahn, Bull of Ala Mhigo" + "name": "Arcane Signet" }, { "count": 1, - "name": "Mm'menon, Uthros Exile" + "name": "Gogo, Master of Mimicry" }, { "count": 1, - "name": "Yue, the Moon Spirit" + "name": "Counterspell" }, { "count": 1, - "name": "Vedalken Engineer" + "name": "Narset's Reversal" }, { "count": 1, - "name": "Iron Lad, Diverging Destiny" + "name": "Isochron Scepter" }, { "count": 1, - "name": "Solemn Simulacrum" + "name": "Sol Ring" }, { "count": 1, - "name": "Iron Man, Master of Machines" + "name": "Command Tower" }, { "count": 1, - "name": "Panther Robot" + "name": "Terramorphic Expanse" }, { - "count": 1, - "name": "Ornithopter of Paradise" + "count": 22, + "name": "Island" }, { "count": 1, - "name": "Armory Automaton" + "name": "Swiftfoot Boots" }, { "count": 1, - "name": "Canoptek Wraith" + "name": "Giggling Skitterspike" }, { "count": 1, - "name": "Brass Squire" + "name": "Lightning Greaves" }, { "count": 1, - "name": "Suspicious Bookcase" + "name": "Mithril Coat" }, { "count": 1, - "name": "Loyal Apprentice" + "name": "Skullclamp" }, { "count": 1, - "name": "Captain America's Shield" + "name": "Lithoform Engine" }, { "count": 1, - "name": "Mystic Forge" + "name": "Twincast" }, { "count": 1, - "name": "Coin of Mastery" + "name": "Basalt Monolith" }, { "count": 1, - "name": "Prowler's Helm" + "name": "Wandering Archaic" }, { "count": 1, - "name": "Thran Power Suit" + "name": "Sapphire Medallion" }, { "count": 1, - "name": "Argentum Armor" + "name": "Thought Vessel" }, { "count": 1, - "name": "Improvised Arsenal" + "name": "Dramatic Reversal" }, { "count": 1, - "name": "Iron Man Armor" + "name": "Rapid Hybridization" }, { "count": 1, - "name": "The Reaver Cleaver" + "name": "Taigam, Master Opportunist" }, { "count": 1, - "name": "Genji Glove" + "name": "Gifts Ungiven" }, { "count": 1, - "name": "Arc Reactor" + "name": "Flare of Denial" }, { "count": 1, - "name": "The Ten Rings" + "name": "Pollywog Prodigy" }, { "count": 1, - "name": "Sol Ring" + "name": "Pirated Copy" }, { "count": 1, - "name": "Diamond Pick-Axe" + "name": "Mana Drain" }, { "count": 1, - "name": "Arcane Signet" + "name": "Mystic Sanctuary" }, { "count": 1, - "name": "Talisman of Creativity" + "name": "Flow State" }, { "count": 1, - "name": "Hammer of Nazahn" + "name": "Mystic Forge" }, { "count": 1, - "name": "Vibranium Strike Gauntlets" + "name": "Krark-Clan Ironworks" }, { "count": 1, - "name": "Vibranium Mining Mech" + "name": "Unctus, Grand Metatect" }, { "count": 1, - "name": "Shuri's Fabricator" + "name": "Foundry Inspector" }, { "count": 1, - "name": "Helm of the Host" + "name": "Mechanized Production" }, { "count": 1, - "name": "The Key to the Vault" + "name": "Ornithopter of Paradise" }, { "count": 1, - "name": "The Spear of Leonidas" + "name": "Tezzeret, Artifice Master" }, { "count": 1, - "name": "Swiftfoot Boots" + "name": "Sculpting Steel" }, { "count": 1, - "name": "Darksteel Ingot" + "name": "Fugitive Droid" }, { "count": 1, - "name": "Sword of Forge and Frontier" + "name": "Platinum Angel" }, { "count": 1, - "name": "Darksteel Plate" + "name": "Fabricate" }, { "count": 1, - "name": "Sisay's Ring" + "name": "Jhoira's Familiar" }, { "count": 1, - "name": "Cryogen Relic" + "name": "Urza, Lord High Artificer" }, { "count": 1, - "name": "Izzet Signet" + "name": "Forensic Gadgeteer" }, { "count": 1, - "name": "Armor Wars" + "name": "Training Grounds" }, { "count": 1, - "name": "Frostcliff Siege" + "name": "Grand Architect" }, { "count": 1, - "name": "Mechanized Production" + "name": "Memnarch" }, { "count": 1, - "name": "Waterbender Ascension" + "name": "Mystical Tutor" }, { "count": 1, - "name": "Fevered Visions" + "name": "Sensei's Divining Top" }, { "count": 1, - "name": "Stoic Rebuttal" + "name": "Cybermen Squadron" }, { "count": 1, - "name": "Machinate" + "name": "Phyrexian Metamorph" }, { "count": 1, - "name": "Into the Flood Maw" + "name": "Spellskite" }, { "count": 1, - "name": "Pym Particles" + "name": "Vision, Synthezoid Avenger" }, { "count": 1, - "name": "Reverse Engineer" + "name": "Arcane Denial" }, { "count": 1, - "name": "Vision Quest" + "name": "Fellwar Stone" }, { "count": 1, - "name": "Stark Industries" + "name": "Gilded Lotus" }, { "count": 1, - "name": "Baxter Building" + "name": "Sword of the Animist" }, { "count": 1, - "name": "Spectacle Summit" + "name": "Mirage Mirror" }, { "count": 1, - "name": "Stock Up" + "name": "Pili-Pala" }, { "count": 1, - "name": "Command Tower" + "name": "Tony Stark" }, { - "count": 8, + "count": 10, "name": "Mountain" }, - { - "count": 13, - "name": "Island" - }, - { - "count": 1, - "name": "Buried Ruin" - }, - { - "count": 1, - "name": "Dread Statuary" - }, - { - "count": 1, - "name": "Turbulent Springs" - }, - { - "count": 1, - "name": "Shimmering Grotto" - }, - { - "count": 1, - "name": "Transguild Promenade" - }, { "count": 1, - "name": "Highland Lake" - }, - { - "count": 1, - "name": "Swiftwater Cliffs" - }, - { - "count": 1, - "name": "Darksteel Citadel" + "name": "Talisman of Creativity" }, { "count": 1, - "name": "I Am Iron Man" + "name": "Steam Vents" }, { "count": 1, - "name": "Machinesmith Automaton" + "name": "Enthusiastic Mechanaut" }, { "count": 1, - "name": "Hydraulic Helper" + "name": "Flare of Duplication" }, { "count": 1, - "name": "Tony Stark" + "name": "Mystic Remora" }, { "count": 1, - "name": "The Aetherspark" + "name": "Iron Man, Master of Machines" }, { "count": 1, - "name": "Iron Spider, Stark Upgrade" + "name": "Hulkbuster Armor" }, { "count": 1, - "name": "Humble Defector" + "name": "Blackblade Reforged" } ], "sideboard": [] }, { - "name": "Fire Lord Azula", + "name": "Kaalia of the Vast", "author": "MTGGoldfish", "colors": [ - "B", + "W", "R", - "U" + "B" ], "tags": [ "metagame" @@ -1026,654 +1008,669 @@ "main": [ { "count": 1, - "name": "An Offer You Can't Refuse" + "name": "Gisela, Blade of Goldnight" }, { "count": 1, - "name": "Arcane Signet" + "name": "Aurelia, the Warleader" }, { "count": 1, - "name": "Archmage Emeritus" + "name": "Sephara, Sky's Blade" }, { "count": 1, - "name": "Ashling, Flame Dancer" + "name": "Liesa, Forgotten Archangel" }, { "count": 1, - "name": "Azula, Cunning Usurper" + "name": "Reya Dawnbringer" }, { "count": 1, - "name": "Big Score" + "name": "Rakdos, Patron of Chaos" }, { "count": 1, - "name": "Birgi, God of Storytelling" + "name": "Rakdos, Lord of Riots" }, { "count": 1, - "name": "Blasphemous Act" + "name": "Drana and Linvala" }, { "count": 1, - "name": "Blood Crypt" + "name": "Isshin, Two Heavens as One" }, { "count": 1, - "name": "Bloodstained Mire" + "name": "Drakuseth, Maw of Flames" }, { "count": 1, - "name": "Borne Upon a Wind" + "name": "Lyra Dawnbringer" }, { "count": 1, - "name": "Brainstorm" + "name": "Akroma, Angel of Wrath" }, { "count": 1, - "name": "Bulk Up" + "name": "Neriv, Heart of the Storm" }, { "count": 1, - "name": "Cascade Bluffs" + "name": "Tariel, Reckoner of Souls" }, { "count": 1, - "name": "Chaos Warp" + "name": "Rune-Scarred Demon" }, { "count": 1, - "name": "Comet Storm" + "name": "Master of Cruelties" }, { "count": 1, - "name": "Command Tower" + "name": "Hellkite Tyrant" }, { "count": 1, - "name": "Counterspell" + "name": "Archfiend of Depravity" }, { "count": 1, - "name": "Crackle with Power" + "name": "Angel of Serenity" }, { "count": 1, - "name": "Dark Ritual" + "name": "Angel of Despair" }, { "count": 1, - "name": "Deadly Rollick" + "name": "Serra's Emissary" }, { "count": 1, - "name": "Deflecting Swat" + "name": "Blast-Furnace Hellkite" }, { "count": 1, - "name": "Demand Answers" + "name": "Steel Hellkite" }, { "count": 1, - "name": "Dragonskull Summit" + "name": "Harvester of Souls" }, { "count": 1, - "name": "Drowned Catacomb" + "name": "Firemane Commando" }, { "count": 1, - "name": "Dualcaster Mage" + "name": "Angelic Arbiter" }, { "count": 1, - "name": "Electro, Assaulting Battery" + "name": "Bloodgift Demon" }, { "count": 1, - "name": "Electrodominance" + "name": "Aegis Angel" }, { "count": 1, - "name": "Emergence Zone" + "name": "Terminate" }, { "count": 1, - "name": "Etali, Primal Storm" + "name": "Boros Charm" }, { "count": 1, - "name": "Exotic Orchard" + "name": "Path to Exile" }, { "count": 1, - "name": "Faerie Mastermind" + "name": "Mortify" }, { "count": 1, - "name": "Fated Firepower" + "name": "Swords to Plowshares" }, { "count": 1, - "name": "Fellwar Stone" + "name": "Crackling Doom" }, { "count": 1, - "name": "Fire Nation Palace" + "name": "Utter End" }, { "count": 1, - "name": "Firebender Ascension" + "name": "Rakdos Charm" }, { "count": 1, - "name": "Firebending Student" + "name": "Despark" }, { "count": 1, - "name": "Fists of Flame" + "name": "Demonic Counsel" }, { "count": 1, - "name": "Frantic Search" + "name": "Read the Bones" }, { "count": 1, - "name": "High Fae Trickster" + "name": "Sign in Blood" }, { - "count": 4, - "name": "Island" + "count": 1, + "name": "Vindicate" }, { "count": 1, - "name": "Jeska's Will" + "name": "Armageddon" }, { "count": 1, - "name": "Kess, Dissident Mage" + "name": "Ruinous Ultimatum" }, { "count": 1, - "name": "Leyline of Anticipation" + "name": "Lightning Greaves" }, { "count": 1, - "name": "Lightning Bolt" + "name": "Swiftfoot Boots" }, { "count": 1, - "name": "Lightning Greaves" + "name": "Lavaspur Boots" }, { "count": 1, - "name": "Luxury Suite" + "name": "Whispersilk Cloak" }, { "count": 1, - "name": "Mana Geyser" + "name": "Boros Signet" }, { "count": 1, - "name": "Morphic Pool" + "name": "Rakdos Signet" }, { - "count": 4, - "name": "Mountain" + "count": 1, + "name": "Orzhov Signet" }, { "count": 1, - "name": "Mystic Remora" + "name": "Arcane Signet" }, { "count": 1, - "name": "Narset's Reversal" + "name": "Chromatic Lantern" }, { "count": 1, - "name": "Nightscape Familiar" + "name": "Sol Ring" }, { "count": 1, - "name": "Opt" + "name": "Dragon Tempest" }, { "count": 1, - "name": "Ozai, the Phoenix King" + "name": "Rising of the Day" }, { "count": 1, - "name": "Polluted Delta" + "name": "Phyrexian Arena" }, { "count": 1, - "name": "Pyretic Ritual" + "name": "Warstorm Surge" }, { "count": 1, - "name": "Redirect Lightning" + "name": "Kaya's Ghostform" }, { "count": 1, - "name": "Reliquary Tower" + "name": "Rampage of the Valkyries" }, { "count": 1, - "name": "Scalding Tarn" + "name": "Windcrag Siege" }, { "count": 1, - "name": "Seething Song" + "name": "All-Out Assault" }, { "count": 1, - "name": "Shivan Reef" + "name": "Dihada, Binder of Wills" }, { "count": 1, - "name": "Snap" + "name": "Rogue's Passage" }, { "count": 1, - "name": "Snapcaster Mage" + "name": "Seraph Sanctuary" }, { "count": 1, - "name": "Sol Ring" + "name": "Boros Garrison" }, { "count": 1, - "name": "Sozin's Comet" + "name": "Orzhov Basilica" }, { "count": 1, - "name": "Steam Vents" + "name": "Rakdos Carnarium" }, { "count": 1, - "name": "Storm-Kiln Artist" + "name": "Nomad Outpost" }, { "count": 1, - "name": "Stormcarved Coast" + "name": "Command Tower" }, { "count": 1, - "name": "Sulfur Falls" + "name": "Clifftop Retreat" }, { "count": 1, - "name": "Sulfurous Springs" + "name": "Isolated Chapel" }, { - "count": 3, + "count": 1, + "name": "Dragonskull Summit" + }, + { + "count": 9, + "name": "Plains" + }, + { + "count": 9, + "name": "Mountain" + }, + { + "count": 9, "name": "Swamp" }, { "count": 1, - "name": "Swan Song" - }, + "name": "Kaalia of the Vast" + } + ], + "sideboard": [] + }, + { + "name": "Valgavoth, Harrower of Souls", + "author": "MTGGoldfish", + "colors": [ + "B", + "R" + ], + "tags": [ + "metagame" + ], + "main": [ { "count": 1, - "name": "Swiftfoot Boots" + "name": "Valgavoth, Harrower of Souls" }, { "count": 1, - "name": "Talisman of Creativity" + "name": "Arcane Signet" }, { "count": 1, - "name": "Talisman of Dominance" + "name": "Fellwar Stone" }, { "count": 1, - "name": "Talisman of Indulgence" + "name": "Jet Medallion" }, { "count": 1, - "name": "The Last Agni Kai" + "name": "Lightning Greaves" }, { "count": 1, - "name": "Thrill of Possibility" + "name": "Mithril Coat" }, { "count": 1, - "name": "Toxic Deluge" + "name": "Rakdos Signet" }, { "count": 1, - "name": "Training Center" + "name": "Ruby Medallion" }, { "count": 1, - "name": "Twinning Staff" + "name": "Sol Ring" }, { "count": 1, - "name": "Underground River" + "name": "Swiftfoot Boots" }, { "count": 1, - "name": "Unexpected Windfall" + "name": "Talisman of Indulgence" }, { "count": 1, - "name": "Valley Floodcaller" + "name": "Thought Vessel" }, { "count": 1, - "name": "Vedalken Orrery" + "name": "Blood Seeker" }, { "count": 1, - "name": "Veyran, Voice of Duality" + "name": "Fate Unraveler" }, { "count": 1, - "name": "Waterlogged Teachings" + "name": "Gleeful Arsonist" }, { "count": 1, - "name": "Watery Grave" + "name": "Harsh Mentor" }, { "count": 1, - "name": "Xander's Lounge" + "name": "Karazikar, the Eye Tyrant" }, { "count": 1, - "name": "Zuko, Firebending Master" + "name": "Kardur, Doomscourge" }, { "count": 1, - "name": "Fire Lord Azula" - } - ], - "sideboard": [] - }, - { - "name": "Kaalia of the Vast", - "author": "MTGGoldfish", - "colors": [ - "R", - "W", - "B" - ], - "tags": [ - "metagame" - ], - "main": [ + "name": "Kederekt Parasite" + }, { "count": 1, - "name": "Kaalia of the Vast" + "name": "Mai, Scornful Striker" }, { "count": 1, - "name": "Akroma, Angel of Wrath" + "name": "Massacre Wurm" }, { "count": 1, - "name": "Ambition's Cost" + "name": "Mogis, God of Slaughter" }, { "count": 1, - "name": "Angel of Despair" + "name": "Nightshade Harvester" }, { "count": 1, - "name": "Arcane Signet" + "name": "Ojer Axonil, Deepest Might" }, { "count": 1, - "name": "Archangel of Tithes" + "name": "Persistent Constrictor" }, { "count": 1, - "name": "Archfiend of Depravity" + "name": "Rampaging Ferocidon" }, { "count": 1, - "name": "Armageddon" + "name": "Razorkin Needlehead" }, { "count": 1, - "name": "Aurelia, the Law Above" + "name": "Rug of Smothering" }, { "count": 1, - "name": "Balefire Dragon" + "name": "Scrawling Crawler" }, { "count": 1, - "name": "Bedevil" + "name": "Solphim, Mayhem Dominus" }, { "count": 1, - "name": "Bitter Reunion" + "name": "The Lord of Pain" }, { "count": 1, - "name": "Blitzball" + "name": "Bloodchief Ascension" }, { "count": 1, - "name": "Bolt Bend" + "name": "Exquisite Blood" }, { "count": 1, - "name": "Breaching Dragonstorm" + "name": "Manabarbs" }, { "count": 1, - "name": "Caves of Koilos" + "name": "Painful Quandary" }, { "count": 1, - "name": "Charcoal Diamond" + "name": "Phyrexian Arena" }, { "count": 1, - "name": "Clifftop Retreat" + "name": "Polluted Bonds" }, { "count": 1, - "name": "Command Tower" + "name": "Roiling Vortex" }, { "count": 1, - "name": "Dark Ritual" + "name": "Spellshock" }, { "count": 1, - "name": "Day of Judgment" + "name": "Spiked Corridor // Torture Pit" }, { "count": 1, - "name": "Demand Answers" + "name": "Spiteful Visions" }, { "count": 1, - "name": "Demon of Loathing" + "name": "Sulfuric Vortex" }, { "count": 1, - "name": "Demonic Counsel" + "name": "Underworld Dreams" }, { "count": 1, - "name": "Divine Resilience" + "name": "Bedevil" }, { "count": 1, - "name": "Dragon Tempest" + "name": "Bolt Bend" }, { "count": 1, - "name": "Dragonskull Summit" + "name": "Chaos Warp" }, { "count": 1, - "name": "Drakuseth, Maw of Flames" + "name": "Dark Ritual" }, { "count": 1, - "name": "Fetid Heath" + "name": "Imp's Mischief" }, { "count": 1, - "name": "Fire Diamond" + "name": "Malakir Rebirth" }, { "count": 1, - "name": "Foreboding Ruins" + "name": "Not Dead After All" }, { "count": 1, - "name": "Furycalm Snarl" + "name": "Price of Progress" }, { "count": 1, - "name": "Gisela, Blade of Goldnight" + "name": "Rakdos Charm" }, { "count": 1, - "name": "Gods Willing" + "name": "Return the Favor" }, { "count": 1, - "name": "Harvester of Souls" + "name": "Terminate" }, { "count": 1, - "name": "Haunted Ridge" + "name": "Untimely Malfunction" }, { "count": 1, - "name": "Hearth Charm" + "name": "Withering Torment" }, { "count": 1, - "name": "Hellkite Tyrant" + "name": "Blackcleave Cliffs" }, { "count": 1, - "name": "Hot Soup" + "name": "Blazemire Verge" }, { "count": 1, - "name": "Indulgent Tormentor" + "name": "Blood Crypt" }, { "count": 1, - "name": "Inevitable Defeat" + "name": "Bojuka Bog" }, { "count": 1, - "name": "Isolated Chapel" + "name": "Command Tower" }, { "count": 1, - "name": "Key to the City" + "name": "Dragonskull Summit" }, { "count": 1, - "name": "Lyra Dawnbringer" + "name": "Exotic Orchard" }, { "count": 1, - "name": "Make a Stand" + "name": "Fabled Passage" }, { "count": 1, - "name": "Marble Diamond" + "name": "Foreboding Ruins" }, { "count": 1, - "name": "Mother of Runes" + "name": "Geier Reach Sanitarium" }, { - "count": 4, - "name": "Mountain" + "count": 1, + "name": "Graven Cairns" }, { "count": 1, - "name": "Myriad Landscape" + "name": "Haunted Ridge" }, { "count": 1, - "name": "Neriv, Heart of the Storm" + "name": "Leechridden Swamp" }, { "count": 1, - "name": "Night's Whisper" + "name": "Mount Doom" }, { - "count": 1, - "name": "Nomad Outpost" + "count": 5, + "name": "Mountain" }, { "count": 1, - "name": "Painful Truths" + "name": "Reliquary Tower" }, { - "count": 11, - "name": "Plains" + "count": 1, + "name": "Rogue's Passage" }, { "count": 1, - "name": "Rakdos, Patron of Chaos" + "name": "Shadowblood Ridge" }, { "count": 1, - "name": "Rampage of the Valkyries" + "name": "Shivan Gorge" }, { "count": 1, - "name": "Read the Bones" + "name": "Smoldering Marsh" }, { "count": 1, - "name": "Reanimate" + "name": "Sulfurous Springs" + }, + { + "count": 8, + "name": "Swamp" }, { "count": 1, - "name": "Reliquary Tower" + "name": "Tainted Peak" }, { "count": 1, - "name": "Rising of the Day" + "name": "Witch's Clinic" }, { "count": 1, - "name": "Rugged Prairie" + "name": "Chandra, Awakened Inferno" }, { "count": 1, - "name": "Rune-Scarred Demon" + "name": "Blasphemous Act" }, { "count": 1, - "name": "Scoured Barrens" + "name": "Chandra's Ignition" }, { "count": 1, - "name": "Scourge of the Throne" + "name": "Exsanguinate" }, { "count": 1, - "name": "Sephara, Sky's Blade" + "name": "Feed the Swarm" }, { "count": 1, - "name": "Serra's Guardian" + "name": "Jeska's Will" }, { "count": 1, - "name": "Shineshadow Snarl" + "name": "Night's Whisper" }, { "count": 1, @@ -1681,425 +1678,435 @@ }, { "count": 1, - "name": "Smoldering Marsh" + "name": "Toxic Deluge" + } + ], + "sideboard": [] + }, + { + "name": "The Ur-Dragon", + "author": "MTGGoldfish", + "colors": [ + "B", + "R", + "U", + "G", + "W" + ], + "tags": [ + "metagame" + ], + "main": [ + { + "count": 1, + "name": "The Ur-Dragon" }, { "count": 1, - "name": "Sol Ring" + "name": "Ancient Silver Dragon" }, { "count": 1, - "name": "Subjugator Angel" + "name": "Astral Dragon" }, { - "count": 2, - "name": "Swamp" + "count": 1, + "name": "Atarka, World Render" }, { "count": 1, - "name": "Tainted Field" + "name": "Birds of Paradise" }, { "count": 1, - "name": "Tainted Peak" + "name": "Bloom Tender" }, { "count": 1, - "name": "Temple of Malice" + "name": "Cavern-Hoard Dragon" }, { "count": 1, - "name": "Temple of Silence" + "name": "Delighted Halfling" }, { "count": 1, - "name": "Temple of Triumph" + "name": "Dragonhawk, Fate's Tempest" }, { "count": 1, - "name": "Temple of the False God" + "name": "Dragonlord Dromoka" }, { "count": 1, - "name": "Terror of Mount Velus" + "name": "Goldspan Dragon" }, { "count": 1, - "name": "Thought Vessel" + "name": "Hellkite Courser" }, { "count": 1, - "name": "Thran Dynamo" + "name": "Klauth, Unrivaled Ancient" }, { "count": 1, - "name": "Tormenting Voice" + "name": "Lathliss, Dragon Queen" }, { "count": 1, - "name": "Unbreakable Formation" + "name": "Miirym, Sentinel Wyrm" }, { "count": 1, - "name": "Vilis, Broker of Blood" + "name": "Morophon, the Boundless" }, { "count": 1, - "name": "Whispersilk Cloak" + "name": "Old Gnawbone" }, { "count": 1, - "name": "Windborn Muse" - } - ], - "sideboard": [] - }, - { - "name": "Muldrotha, the Gravetide", - "author": "MTGGoldfish", - "colors": [ - "U", - "B", - "G" - ], - "tags": [ - "metagame" - ], - "main": [ + "name": "Realmwalker" + }, { "count": 1, - "name": "Sakura-Tribe Elder" + "name": "Rith, Liberated Primeval" }, { "count": 1, - "name": "Seal of Removal" + "name": "Rivaz of the Claw" }, { "count": 1, - "name": "Solemn Simulacrum" + "name": "Roaming Throne" }, { "count": 1, - "name": "Nihil Spellbomb" + "name": "Sarkhan, Soul Aflame" }, { "count": 1, - "name": "Lord of the Forsaken" + "name": "Savage Ventmaw" }, { "count": 1, - "name": "Command Tower" + "name": "Scion of Draco" }, { "count": 1, - "name": "Nyx Weaver" + "name": "Scourge of Valkas" }, { "count": 1, - "name": "Sinister Concoction" + "name": "Terror of the Peaks" }, { "count": 1, - "name": "Commander's Sphere" + "name": "Tiamat" }, { "count": 1, - "name": "Syr Konrad, the Grim" + "name": "Twinflame Tyrant" }, { "count": 1, - "name": "Kaya's Ghostform" + "name": "Ureni of the Unwritten" }, { "count": 1, - "name": "Sol Ring" + "name": "Utvara Hellkite" }, { "count": 1, - "name": "Farseek" + "name": "Wrathful Red Dragon" }, { "count": 1, - "name": "Nevinyrral's Disk" + "name": "Zurgo and Ojutai" }, { "count": 1, - "name": "Tatyova, Benthic Druid" + "name": "Arcane Signet" }, { "count": 1, - "name": "Assassin's Trophy" + "name": "Cursed Mirror" }, { "count": 1, - "name": "Arcane Signet" + "name": "Firdoch Core" }, { "count": 1, - "name": "Archaeomancer" + "name": "Mana Vault" }, { "count": 1, - "name": "Wood Elves" + "name": "Mox Jasper" }, { "count": 1, - "name": "Ravenous Chupacabra" + "name": "Sol Ring" }, { "count": 1, - "name": "Plaguecrafter" + "name": "The Great Henge" }, { "count": 1, - "name": "Lightning Greaves" + "name": "Urza's Incubator" }, { "count": 1, - "name": "Animate Dead" + "name": "Aggravated Assault" }, { "count": 1, - "name": "Noxious Gearhulk" + "name": "Dragon Tempest" }, { "count": 1, - "name": "Springbloom Druid" + "name": "Mystic Remora" }, { "count": 1, - "name": "Stitcher's Supplier" + "name": "Rhythm of the Wild" }, { "count": 1, - "name": "Satyr Wayfinder" + "name": "Rhystic Study" }, { "count": 1, - "name": "Spore Frog" + "name": "Temur Ascendancy" }, { "count": 1, - "name": "World Shaper" + "name": "Up the Beanstalk" }, { "count": 1, - "name": "Caustic Caterpillar" + "name": "Assassin's Trophy" }, { "count": 1, - "name": "Temple of the False God" + "name": "Cyclonic Rift" }, { "count": 1, - "name": "Executioner's Capsule" + "name": "Enlightened Tutor" }, { "count": 1, - "name": "Pernicious Deed" + "name": "Heroic Intervention" }, { "count": 1, - "name": "Seal of Primordium" + "name": "Smuggler's Surprise" }, { "count": 1, - "name": "Mulldrifter" + "name": "Stubborn Denial" }, { "count": 1, - "name": "Underrealm Lich" + "name": "Swan Song" }, { "count": 1, - "name": "Baleful Strix" + "name": "Swords to Plowshares" }, { "count": 1, - "name": "Siren Stormtamer" + "name": "Teferi's Protection" }, { "count": 1, - "name": "Gravebreaker Lamia" + "name": "Vampiric Tutor" }, { "count": 1, - "name": "Muldrotha, the Gravetide" + "name": "Worldly Tutor" }, { "count": 1, - "name": "Disciple of Bolas" + "name": "Blasphemous Act" }, { "count": 1, - "name": "The Gitrog Monster" + "name": "Crux of Fate" }, { "count": 1, - "name": "Woodland Cemetery" + "name": "Demonic Tutor" }, { "count": 1, - "name": "Vessel of Nascency" + "name": "Farseek" }, { "count": 1, - "name": "Villainous Wealth" + "name": "Nature's Lore" }, { "count": 1, - "name": "Painful Truths" + "name": "Patriarch's Bidding" }, { "count": 1, - "name": "Grisly Salvage" + "name": "Skyshroud Claim" }, { "count": 1, - "name": "Avenger of Zendikar" + "name": "Three Visits" }, { "count": 1, - "name": "Fleshbag Marauder" + "name": "Arid Mesa" }, { "count": 1, - "name": "Shriekmaw" + "name": "Blood Crypt" }, { "count": 1, - "name": "Jarad, Golgari Lich Lord" + "name": "Bloodstained Mire" }, { "count": 1, - "name": "Daemogoth Woe-Eater" + "name": "Boseiju, Who Endures" }, { "count": 1, - "name": "Sidisi, Brood Tyrant" + "name": "Breeding Pool" }, { "count": 1, - "name": "Woodfall Primus" + "name": "Cavern of Souls" }, { "count": 1, - "name": "Thragtusk" + "name": "City of Brass" }, { "count": 1, - "name": "Greenwarden of Murasa" + "name": "Command Tower" }, { "count": 1, - "name": "Terastodon" + "name": "Exotic Orchard" }, { "count": 1, - "name": "Phyrexian Delver" + "name": "Flooded Strand" }, { "count": 1, - "name": "Fierce Empath" + "name": "Forest" }, { "count": 1, - "name": "Decree of Pain" + "name": "Godless Shrine" }, { "count": 1, - "name": "Black Sun's Zenith" + "name": "Hallowed Fountain" }, { "count": 1, - "name": "Rampant Growth" + "name": "Haven of the Spirit Dragon" }, { "count": 1, - "name": "Sultai Ascendancy" + "name": "Indatha Triome" }, { "count": 1, - "name": "Seal of Doom" + "name": "Ketria Triome" }, { "count": 1, - "name": "Polluted Mire" + "name": "Maelstrom of the Spirit Dragon" }, { "count": 1, - "name": "Kheru Goldkeeper" + "name": "Mana Confluence" }, { "count": 1, - "name": "River Kelpie" + "name": "Marsh Flats" }, { "count": 1, - "name": "Lord of Extinction" + "name": "Misty Rainforest" }, { "count": 1, - "name": "Dread Return" + "name": "Mountain" }, { "count": 1, - "name": "Dakmor Salvage" + "name": "Overgrown Tomb" }, { "count": 1, - "name": "Opulent Palace" + "name": "Plaza of Heroes" }, { "count": 1, - "name": "Evolving Wilds" + "name": "Polluted Delta" }, { "count": 1, - "name": "Terramorphic Expanse" + "name": "Reflecting Pool" }, { "count": 1, - "name": "Hinterland Harbor" + "name": "Sacred Foundry" }, { "count": 1, - "name": "Lonely Sandbar" + "name": "Scalding Tarn" }, { "count": 1, - "name": "Tranquil Thicket" + "name": "Steam Vents" }, { "count": 1, - "name": "Exotic Orchard" + "name": "Stomping Ground" }, { "count": 1, - "name": "Temple of Malady" + "name": "Temple Garden" }, { - "count": 6, - "name": "Island" + "count": 1, + "name": "Verdant Catacombs" }, { - "count": 8, - "name": "Swamp" + "count": 1, + "name": "Watery Grave" }, { - "count": 8, - "name": "Forest" + "count": 1, + "name": "Windswept Heath" + }, + { + "count": 1, + "name": "Wooded Foothills" } ], "sideboard": [] }, { - "name": "Edgar Markov", + "name": "Fire Lord Azula", "author": "MTGGoldfish", "colors": [ - "B", + "U", "R", - "W" + "B" ], "tags": [ "metagame" @@ -2107,275 +2114,247 @@ "main": [ { "count": 1, - "name": "Rakdos Charm" - }, - { - "count": 1, - "name": "Strefan, Maurer Progenitor" - }, - { - "count": 1, - "name": "Infernal Grasp" - }, - { - "count": 1, - "name": "Restless Bloodseeker" - }, - { - "count": 1, - "name": "Swords to Plowshares" + "name": "An Offer You Can't Refuse" }, { "count": 1, - "name": "Balustrade Spy" + "name": "Arcane Denial" }, { "count": 1, - "name": "Alluring Suitor" + "name": "Arcane Signet" }, { "count": 1, - "name": "Falkenrath Perforator" + "name": "Archmage Emeritus" }, { "count": 1, - "name": "Belligerent Guest" + "name": "Baral, Chief of Compliance" }, { "count": 1, - "name": "Edgar, Charmed Groom" + "name": "Big Score" }, { "count": 1, - "name": "Stromkirk Bloodthief" + "name": "Narset's Reversal" }, { "count": 1, - "name": "Child of Night" + "name": "Blasphemous Act" }, { "count": 1, - "name": "Humiliate" + "name": "Borne Upon a Wind" }, { "count": 1, - "name": "Indulgent Aristocrat" + "name": "Cackling Counterpart" }, { "count": 1, - "name": "Markov Purifier" + "name": "Cascade Bluffs" }, { "count": 1, - "name": "Rakdos Signet" + "name": "Cephalid Coliseum" }, { "count": 1, - "name": "Blasphemous Act" + "name": "Choked Estuary" }, { "count": 1, - "name": "Vampire of the Dire Moon" + "name": "Command Tower" }, { "count": 1, - "name": "Silverquill Pledgemage" + "name": "Consider" }, { "count": 1, - "name": "Bloodthrone Vampire" + "name": "Dark Ritual" }, { "count": 1, - "name": "Immersturm Predator" + "name": "Darkwater Catacombs" }, { "count": 1, - "name": "Bloodthirsty Aerialist" + "name": "Deadly Dispute" }, { "count": 1, - "name": "Captivating Vampire" + "name": "Delay" }, { "count": 1, - "name": "Thrill of Possibility" + "name": "Demand Answers" }, { "count": 1, - "name": "Vanquish the Horde" + "name": "Desperate Ritual" }, { "count": 1, - "name": "Oversold Cemetery" + "name": "Dimir Signet" }, { "count": 1, - "name": "Olivia, Crimson Bride" + "name": "Dispel" }, { "count": 1, - "name": "Vampire Spawn" + "name": "Dragon's Rage Channeler" }, { "count": 1, - "name": "Fracture" + "name": "Dragonskull Summit" }, { "count": 1, - "name": "Blood Burglar" + "name": "Drift of Phantasms" }, { "count": 1, - "name": "Welcoming Vampire" + "name": "Dualcaster Mage" }, { "count": 1, - "name": "Ephemerate" + "name": "Electroduplicate" }, { "count": 1, - "name": "Bladebrand" + "name": "Emergence Zone" }, { "count": 1, - "name": "Famished Foragers" + "name": "Exotic Orchard" }, { "count": 1, - "name": "Urza's Incubator" + "name": "Fellwar Stone" }, { "count": 1, - "name": "Collective Brutality" + "name": "Flare of Duplication" }, { "count": 1, - "name": "Basilisk Collar" + "name": "Foreboding Ruins" }, { "count": 1, - "name": "Day of Judgment" + "name": "Frantic Search" }, { "count": 1, - "name": "Bloodtithe Harvester" + "name": "Frostboil Snarl" }, { "count": 1, - "name": "Village Rites" + "name": "Increasing Vengeance" }, { "count": 1, - "name": "Vampire Interloper" + "name": "Invoke Calamity" }, { - "count": 1, - "name": "Voldaren Ambusher" + "count": 6, + "name": "Island" }, { "count": 1, - "name": "Shadow Stinger" + "name": "Izzet Signet" }, { "count": 1, - "name": "Bloodcrazed Socialite" + "name": "Krark, the Thumbless" }, { "count": 1, - "name": "Markov Waltzer" + "name": "Lightning Bolt" }, { "count": 1, - "name": "Esper Sentinel" + "name": "Lim-Dul's Vault" }, { "count": 1, - "name": "Knight of the Ebon Legion" + "name": "Long River's Pull" }, { "count": 1, - "name": "Blood Petal Celebrant" + "name": "Malevolent Hermit" }, { "count": 1, - "name": "Boros Signet" + "name": "Miscast" }, { - "count": 1, - "name": "Dusk Legion Duelist" + "count": 5, + "name": "Mountain" }, { "count": 1, - "name": "Mass Hysteria" + "name": "Mystical Teachings" }, { "count": 1, - "name": "Phyrexian Arena" + "name": "Narset, Parter of Veils" }, { "count": 1, - "name": "Mari, the Killing Quill" + "name": "Negate" }, { "count": 1, - "name": "Thought Vessel" + "name": "Nightscape Familiar" }, { "count": 1, - "name": "Sulfuric Vortex" + "name": "Notion Thief" }, { "count": 1, - "name": "Thrilling Discovery" + "name": "Perplex" }, { "count": 1, - "name": "Voldaren Stinger" + "name": "Prismari Command" }, { "count": 1, - "name": "Condemn" + "name": "Quicken" }, { "count": 1, - "name": "Gluttonous Guest" + "name": "Rakdos Signet" }, { "count": 1, - "name": "Voldaren Epicure" + "name": "Resculpt" }, { "count": 1, - "name": "Cleric of Life's Bond" + "name": "River of Tears" }, { "count": 1, - "name": "Markov Retribution" + "name": "Saw in Half" }, { "count": 1, - "name": "Pulse Tracker" + "name": "Sazacap's Brew" }, { "count": 1, - "name": "Sol Ring" - }, - { - "count": 9, - "name": "Swamp" - }, - { - "count": 6, - "name": "Mountain" - }, - { - "count": 7, - "name": "Plains" + "name": "Shadowblood Ridge" }, { "count": 1, - "name": "Exotic Orchard" + "name": "Shivan Reef" }, { "count": 1, @@ -2383,477 +2362,457 @@ }, { "count": 1, - "name": "Blood Crypt" - }, - { - "count": 1, - "name": "Gemstone Mine" - }, - { - "count": 1, - "name": "Command Tower" - }, - { - "count": 1, - "name": "Temple of Triumph" + "name": "Snap" }, { "count": 1, - "name": "Raucous Theater" + "name": "Sol Ring" }, { "count": 1, - "name": "Isolated Chapel" + "name": "Spell Pierce" }, { "count": 1, - "name": "Shadowy Backstreet" + "name": "Spire of Industry" }, { "count": 1, - "name": "Clifftop Retreat" + "name": "Springleaf Drum" }, { "count": 1, - "name": "Foreboding Ruins" + "name": "Step Through" }, { "count": 1, - "name": "Maze of Ith" + "name": "Storm-Kiln Artist" }, { "count": 1, - "name": "Elegant Parlor" + "name": "Stormcatch Mentor" }, { "count": 1, - "name": "Edgar Markov" - } - ], - "sideboard": [] - }, - { - "name": "The Ur-Dragon", - "author": "MTGGoldfish", - "colors": [ - "R", - "G", - "B" - ], - "tags": [ - "metagame" - ], - "main": [ - { - "count": 1, - "name": "Silumgar, the Drifting Death" + "name": "Sulfur Falls" }, { "count": 1, - "name": "Sundown Pass" + "name": "Sulfurous Springs" }, { "count": 1, - "name": "Rockfall Vale" + "name": "Sunken Hollow" }, { - "count": 1, - "name": "Bountiful Promenade" + "count": 4, + "name": "Swamp" }, { "count": 1, - "name": "Jodah, Archmage Eternal" + "name": "Talisman of Creativity" }, { "count": 1, - "name": "Raugrin Triome" + "name": "Talisman of Dominance" }, { "count": 1, - "name": "Scourge of the Throne" + "name": "Talisman of Indulgence" }, { "count": 1, - "name": "Dryad of the Ilysian Grove" + "name": "Thrill of Possibility" }, { "count": 1, - "name": "Command Tower" + "name": "Unexpected Windfall" }, { "count": 1, - "name": "Atarka, World Render" + "name": "Unwind" }, { "count": 1, - "name": "Amulet of Vigor" + "name": "Valley Floodcaller" }, { "count": 1, - "name": "Orb of Dragonkind" + "name": "Volatile Stormdrake" }, { "count": 1, - "name": "Flooded Strand" + "name": "Waterlogged Teachings" }, { "count": 1, - "name": "Ancient Brass Dragon" + "name": "Whispering Madness" }, { "count": 1, - "name": "Zagoth Triome" + "name": "Will of the Jeskai" }, { "count": 1, - "name": "Ketria Triome" + "name": "Windfall" }, { "count": 1, - "name": "Timeless Lotus" + "name": "Wishclaw Talisman" }, { - "count": 1, - "name": "Rith, Liberated Primeval" - }, + "count": 1, + "name": "Fire Lord Azula" + } + ], + "sideboard": [] + }, + { + "name": "Muldrotha, the Gravetide", + "author": "MTGGoldfish", + "colors": [], + "tags": [ + "metagame" + ], + "main": [ { "count": 1, - "name": "Hoarding Broodlord" + "name": "Deranged Assistant [CMR]" }, { "count": 1, - "name": "Utvara Hellkite" + "name": "Man-o'-War [MH1]" }, { "count": 1, - "name": "Hellkite Courser" + "name": "Laboratory Drudge [CMR]" }, { "count": 1, - "name": "Vesuva" + "name": "Naga Fleshcrafter [TDM] (F)" }, { "count": 1, - "name": "Herald's Horn" + "name": "Overcharged Amalgam [VOW]" }, { "count": 1, - "name": "Dragonlord Dromoka" + "name": "Putrid Imp [TOR]" }, { "count": 1, - "name": "Tiamat" + "name": "Cynical Loner [DSK]" }, { "count": 1, - "name": "Hellkite Tyrant" + "name": "Nantuko Husk [ONS]" }, { "count": 1, - "name": "Vanquisher's Banner" + "name": "Fleshbag Marauder [MIC]" }, { "count": 1, - "name": "Dragonspeaker Shaman" + "name": "Gravedigger [M19]" }, { "count": 1, - "name": "There and Back Again" + "name": "Ravenous Chupacabra [MKC]" }, { "count": 1, - "name": "Ancient Gold Dragon" + "name": "Shriekmaw [ECC]" }, { "count": 1, - "name": "Wrathful Red Dragon" + "name": "Grave Titan [DRC]" }, { "count": 1, - "name": "Rivaz of the Claw" + "name": "Rick Jones, Destined Sidekick <019ea7c9-1155-7efa-8a46-abf16808901b> [MSH]" }, { "count": 1, - "name": "Scourge of Valkas" + "name": "Sakura-Tribe Elder <019d4a1a-5821-7f99-9d4e-2be3f235dc9e> [SOC]" }, { "count": 1, - "name": "Smuggler's Surprise" + "name": "Satyr Wayfinder [M15]" }, { "count": 1, - "name": "The Ur-Dragon" + "name": "Dawntreader Elk [JMP]" }, { "count": 1, - "name": "Crux of Fate" + "name": "Undergrowth Leopard [TDM]" }, { "count": 1, - "name": "Dragonlord Atarka" + "name": "Springbloom Druid <019d4a1a-5931-74fe-aa98-3055592ba63e> [SOC]" }, { "count": 1, - "name": "Korlessa, Scale Singer" + "name": "Fertilid [MOR]" }, { "count": 1, - "name": "Blasphemous Act" + "name": "Yavimaya Elder [M3C]" }, { "count": 1, - "name": "Old Gnawbone" + "name": "Foundation Breaker [MH2]" }, { "count": 1, - "name": "Sarkhan, Soul Aflame" + "name": "Acidic Slime [AFC]" }, { "count": 1, - "name": "Mana Vault" + "name": "Mycoloth <019d4a1a-5222-7f0a-8d51-6c82241e6ba5> [SOC]" }, { "count": 1, - "name": "Dragon Tempest" + "name": "Greater Tanuki [NEO]" }, { "count": 1, - "name": "Sol Ring" + "name": "Baleful Strix [MKC]" }, { "count": 1, - "name": "Temur Ascendancy" + "name": "Obsessive Stitcher [M21]" }, { "count": 1, - "name": "Miirym, Sentinel Wyrm" + "name": "Hostage Taker [LCC]" }, { "count": 1, - "name": "Monster Manual" + "name": "Kishla Skimmer [TDM]" }, { "count": 1, - "name": "Reflecting Pool" + "name": "Coiling Oracle [C21]" }, { "count": 1, - "name": "Temur Battlecrier" + "name": "Trygon Predator [OTC]" }, { "count": 1, - "name": "Ancient Bronze Dragon" + "name": "Akawalli, the Seething Tower [LCI]" }, { "count": 1, - "name": "Morophon, the Boundless" + "name": "The Gitrog Monster [EOC]" }, { "count": 1, - "name": "Indatha Triome" + "name": "Lord of Extinction [TDC]" }, { "count": 1, - "name": "Karlach, Fury of Avernus" + "name": "Beledros Witherbloom <019d4a1a-6d99-7994-90a5-2f3abe7bc921> [SOC]" }, { "count": 1, - "name": "Savai Triome" + "name": "Gurmag Nightwatch [TDM]" }, { "count": 1, - "name": "Cavern of Souls" + "name": "Kheru Goldkeeper [TDM]" }, { "count": 1, - "name": "Karplusan Forest" + "name": "Lotuslight Dancers [TDM]" }, { "count": 1, - "name": "Great Hall of the Citadel" + "name": "Millikin <019d4a1a-9a69-7b93-8b4e-514c608c2f78> [SOC]" }, { "count": 1, - "name": "Deathcap Glade" + "name": "Diamond Lion [MH2]" }, { "count": 1, - "name": "Morphic Pool" + "name": "Solemn Simulacrum [BLC]" }, { "count": 1, - "name": "Wooded Foothills" + "name": "Muldrotha, the Gravetide [ECC]" }, { "count": 1, - "name": "Cavern-Hoard Dragon" + "name": "Noose Constrictor [PRM-FNM] (F)" }, { "count": 1, - "name": "Overgrown Farmland" + "name": "Aboshan's Desire [OD]" }, { "count": 1, - "name": "Urborg, Tomb of Yawgmoth" + "name": "Spontaneous Mutation [EMN]" }, { "count": 1, - "name": "Luxury Suite" + "name": "Seal of Doom [MM3]" }, { "count": 1, - "name": "Mana Drain" + "name": "The Eldest Reborn [C19]" }, { "count": 1, - "name": "Tribute to the World Tree" + "name": "Seal of Primordium [PLC]" }, { "count": 1, - "name": "Sokenzan, Crucible of Defiance" + "name": "Dredger's Insight [DFT]" }, { "count": 1, - "name": "Stomping Ground" + "name": "Ground Seal [WOT]" }, { "count": 1, - "name": "Yavimaya, Cradle of Growth" + "name": "Kraven's Last Hunt [SPM]" }, { "count": 1, - "name": "Chromatic Lantern" + "name": "Deadbridge Chant [M3C]" }, { "count": 1, - "name": "Heroic Intervention" + "name": "Awaken the Honored Dead [TDM]" }, { "count": 1, - "name": "Delighted Halfling" + "name": "Waterlogged Hulk [LCI]" }, { "count": 1, - "name": "Blood Crypt" + "name": "Witch's Cauldron [M21]" }, { "count": 1, - "name": "Zurgo and Ojutai" + "name": "Soul-Guide Lantern [THB]" }, { "count": 1, - "name": "Black Market Connections" + "name": "Wayfarer's Bauble [WOC]" }, { "count": 1, - "name": "Arcane Signet" + "name": "Elixir of Immortality [M11]" }, { "count": 1, - "name": "Vault of Champions" + "name": "Sol Ring [CMM]" }, { "count": 1, - "name": "Marsh Flats" + "name": "Arcane Signet [OTC]" }, { "count": 1, - "name": "Minion of the Mighty" + "name": "Perpetual Timepiece <019d4a1a-9cc2-7672-acfd-e7af2946acc5> [SOC]" }, { "count": 1, - "name": "Farseek" + "name": "Millstone [M19]" }, { "count": 1, - "name": "Three Visits" + "name": "Swiftfoot Boots [LCC]" }, { "count": 1, - "name": "Urza's Incubator" + "name": "Oblivion Stone [NCC]" }, { "count": 1, - "name": "Mirkwood Bats" + "name": "Nevinyrral's Disk [CMR]" }, { "count": 1, - "name": "Swashbuckler Extraordinaire" + "name": "Command Tower [DSC]" }, { "count": 1, - "name": "Bootleggers' Stash" + "name": "Opulent Palace [C20]" }, { "count": 1, - "name": "Ganax, Astral Hunter" + "name": "Dimir Aqueduct [OTC]" }, { "count": 1, - "name": "Tireless Provisioner" + "name": "Simic Growth Chamber [LCC]" }, { "count": 1, - "name": "Draconic Muralists" + "name": "Golgari Rot Farm [MM2]" }, { "count": 1, - "name": "Wasteland" + "name": "Dismal Backwater [IKO]" }, { "count": 1, - "name": "Ugin's Labyrinth" + "name": "Thornwood Falls [CMR]" }, { "count": 1, - "name": "Contaminated Landscape" + "name": "Jungle Hollow [KTK]" }, { "count": 1, - "name": "Two-Headed Hellkite" + "name": "Sinister Hideout [SPM]" }, { "count": 1, - "name": "Xander's Lounge" + "name": "Dakmor Salvage [UMA]" }, { "count": 1, - "name": "Hidetsugu and Kairi" + "name": "Foreboding Landscape [MH3]" }, { "count": 1, - "name": "Horn of the Mark" + "name": "Terramorphic Expanse [M11]" }, { "count": 1, - "name": "Zhao, the Moon Slayer" + "name": "Evolving Wilds [AFR]" }, { "count": 1, - "name": "Dracogenesis" + "name": "Myriad Landscape [C20]" }, { - "count": 1, - "name": "Fable of the Mirror-Breaker" + "count": 6, + "name": "Island <293> [C19]" }, { - "count": 1, - "name": "Exotic Orchard" + "count": 8, + "name": "Forest <274> [M21]" }, { - "count": 1, - "name": "Dragonlord's Servant" + "count": 7, + "name": "Swamp <397> [LCI]" } ], "sideboard": [] }, { - "name": "Y'shtola, Night's Blessed", + "name": "Edgar Markov", "author": "MTGGoldfish", "colors": [ - "W", "B", - "U" + "R", + "W" ], "tags": [ "metagame" @@ -2861,11 +2820,15 @@ "main": [ { "count": 1, - "name": "Anguished Unmaking" + "name": "Aetherflux Reservoir" + }, + { + "count": 1, + "name": "Agadeem's Awakening" }, { "count": 1, - "name": "Arcane Sanctum" + "name": "Anguished Unmaking" }, { "count": 1, @@ -2873,143 +2836,143 @@ }, { "count": 1, - "name": "Archmage Emeritus" + "name": "Arid Mesa" }, { "count": 1, - "name": "Ash Barrens" + "name": "Athreos, God of Passage" }, { "count": 1, - "name": "Authority of the Consuls" + "name": "Ayara, First of Locthwain" }, { "count": 1, - "name": "Avatar's Wrath" + "name": "Badlands" }, { "count": 1, - "name": "Baleful Strix" + "name": "Bastion of Remembrance" }, { "count": 1, - "name": "Baral, Chief of Compliance" + "name": "Beseech the Mirror" }, { "count": 1, - "name": "Bender's Waterskin" + "name": "Blood Artist" }, { "count": 1, - "name": "Bolas's Citadel" + "name": "Blood Crypt" }, { "count": 1, - "name": "Choked Estuary" + "name": "Bloodchief Ascension" }, { "count": 1, - "name": "Circle of Power" + "name": "Bloodletter of Aclazotz" }, { "count": 1, - "name": "Cleansing Nova" + "name": "Bloodstained Mire" }, { "count": 1, - "name": "Command Tower" + "name": "Bloodthirsty Conqueror" }, { "count": 1, - "name": "Concealed Courtyard" + "name": "Bolas's Citadel" }, { "count": 1, - "name": "Contaminated Aquifer" + "name": "Boros Charm" }, { "count": 1, - "name": "Crux of Fate" + "name": "Captivating Vampire" }, { "count": 1, - "name": "Curiosity" + "name": "Cavern of Souls" }, { "count": 1, - "name": "Cut a Deal" + "name": "Champion of Dusk" }, { "count": 1, - "name": "Darkwater Catacombs" + "name": "Charismatic Conqueror" }, { "count": 1, - "name": "Delney, Streetwise Lookout" + "name": "City of Brass" }, { "count": 1, - "name": "Desolate Mire" + "name": "Clavileno, First of the Blessed" }, { "count": 1, - "name": "Dig Through Time" + "name": "Cliffhaven Vampire" }, { "count": 1, - "name": "Disallow" + "name": "Command Tower" }, { "count": 1, - "name": "Dovin's Veto" + "name": "Cordial Vampire" }, { "count": 1, - "name": "Drowned Catacomb" + "name": "Corrupted Conviction" }, { "count": 1, - "name": "Emet-Selch of the Third Seat" + "name": "Cruel Celebrant" }, { "count": 1, - "name": "Evolving Wilds" + "name": "Damn" }, { "count": 1, - "name": "Exsanguinate" + "name": "Dark Prophecy" }, { "count": 1, - "name": "Fandaniel, Telophoroi Ascian" + "name": "Demonic Tutor" }, { "count": 1, - "name": "Fell the Profane" + "name": "Diabolic Intent" }, { "count": 1, - "name": "Fetid Heath" + "name": "Dictate of Erebos" }, { "count": 1, - "name": "Flooded Strand" + "name": "Drana and Linvala" }, { "count": 1, - "name": "Floodfarm Verge" + "name": "Eiganjo, Seat of the Empire" }, { "count": 1, - "name": "Frantic Search" + "name": "Elenda, the Dusk Rose" }, { "count": 1, - "name": "Ghostly Prison" + "name": "Enlightened Tutor" }, { "count": 1, - "name": "Glacial Fortress" + "name": "Exquisite Blood" }, { "count": 1, @@ -3017,135 +2980,139 @@ }, { "count": 1, - "name": "Grim Tutor" + "name": "Grand Abolisher" }, { "count": 1, - "name": "Hallowed Fountain" + "name": "Indulgent Aristocrat" + }, + { + "count": 1, + "name": "Jet Medallion" }, { "count": 1, - "name": "Haughty Djinn" + "name": "Kalastria Highborn" }, { "count": 1, - "name": "Helm of the Ghastlord" + "name": "Kalitas, Traitor of Ghet" }, { "count": 1, - "name": "Hermes, Overseer of Elpis" + "name": "Knight of the Ebon Legion" }, { "count": 1, - "name": "Hindering Light" + "name": "Luxury Suite" }, { "count": 1, - "name": "Hullbreaker Horror" + "name": "Malakir Bloodwitch" }, { "count": 1, - "name": "Hypnotic Sprite" + "name": "Mana Confluence" }, { "count": 1, - "name": "Irrigated Farmland" + "name": "Marauding Blight-Priest" }, { - "count": 2, - "name": "Island" + "count": 1, + "name": "Markov Baron" }, { "count": 1, - "name": "Isolated Chapel" + "name": "Marsh Flats" }, { "count": 1, - "name": "Kambal, Consul of Allocation" + "name": "Master of Dark Rites" }, { "count": 1, - "name": "Krile Baldesion" + "name": "Mindcrank" }, { "count": 1, - "name": "Lingering Souls" + "name": "Mortify" }, { "count": 1, - "name": "Lotho, Corrupt Shirriff" + "name": "Mountain" }, { "count": 1, - "name": "Lyse Hext" + "name": "Necropotence" }, { "count": 1, - "name": "Mockingbird" + "name": "Nullpriest of Oblivion" }, { "count": 1, - "name": "Murderous Rider" + "name": "Oathsworn Vampire" }, { "count": 1, - "name": "Ophidian Eye" + "name": "Path of Ancestry" }, { "count": 1, - "name": "Papalymo Totolymo" + "name": "Path to Exile" }, { "count": 1, - "name": "Path of Ancestry" + "name": "Phyrexian Altar" }, { "count": 1, - "name": "Phyrexian Arena" + "name": "Phyrexian Reclamation" }, { - "count": 3, - "name": "Plains" + "count": 1, + "name": "Phyrexian Tower" }, { "count": 1, - "name": "Port Town" + "name": "Plains" }, { "count": 1, - "name": "Prairie Stream" + "name": "Plateau" }, { "count": 1, - "name": "Precognition Field" + "name": "Sacred Foundry" }, { "count": 1, - "name": "Propaganda" + "name": "Sanctum Seeker" }, { "count": 1, - "name": "Relic of Legends" + "name": "Sanguine Bond" }, { "count": 1, - "name": "Scavenger Grounds" + "name": "Savai Triome" }, { "count": 1, - "name": "Shineshadow Snarl" + "name": "Scrubland" }, { "count": 1, - "name": "Skycloud Expanse" + "name": "Shizo, Death's Storehouse" }, { "count": 1, - "name": "Slaughter" + "name": "Silent Clearing" }, { "count": 1, - "name": "Snuff Out" + "name": "Skullclamp" }, { "count": 1, @@ -3153,19 +3120,19 @@ }, { "count": 1, - "name": "Spirit Water Revival" + "name": "Sorin, Imperious Bloodlord" }, { "count": 1, - "name": "Sublime Epiphany" + "name": "Spectator Seating" }, { "count": 1, - "name": "Sunken Hollow" + "name": "Springleaf Drum" }, { "count": 1, - "name": "Sunken Ruins" + "name": "Stinging Study" }, { "count": 2, @@ -3173,85 +3140,88 @@ }, { "count": 1, - "name": "Syphon Mind" + "name": "Swords to Plowshares" }, { "count": 1, - "name": "Talisman of Dominance" + "name": "Teferi's Protection" }, { "count": 1, - "name": "Talisman of Hierarchy" + "name": "The Meathook Massacre" }, { "count": 1, - "name": "Talisman of Progress" + "name": "Three Tree City" }, { "count": 1, - "name": "Tandem Lookout" + "name": "Torment of Hailfire" }, { "count": 1, - "name": "Teferi, Time Raveler" + "name": "Urborg, Tomb of Yawgmoth" }, { "count": 1, - "name": "Temple of the False God" + "name": "Vampiric Tutor" }, { "count": 1, - "name": "The Mechanist, Aerial Artisan" + "name": "Vandalblast" }, { "count": 1, - "name": "Thought Vessel" + "name": "Vault of Champions" }, { "count": 1, - "name": "Tome of Legends" + "name": "Vengeful Bloodwitch" }, { "count": 1, - "name": "Torment of Hailfire" + "name": "Village Rites" }, { "count": 1, - "name": "Toxic Deluge" + "name": "Viscera Seer" }, { "count": 1, - "name": "Transpose" + "name": "Vito, Thorn of the Dusk Rose" }, { "count": 1, - "name": "Underground River" + "name": "Voldaren Estate" }, { "count": 1, - "name": "Vindicate" + "name": "Wake the Dead" }, { "count": 1, - "name": "Void Rend" + "name": "Wasteland" }, { "count": 1, - "name": "Watery Grave" + "name": "Welcoming Vampire" + }, + { + "count": 1, + "name": "Zulaport Cutthroat" }, { "count": 1, - "name": "Y'shtola, Night's Blessed" + "name": "Edgar Markov" } ], "sideboard": [] }, { - "name": "Valgavoth, Harrower of Souls", + "name": "Krenko, Mob Boss", "author": "MTGGoldfish", "colors": [ - "R", - "B" + "R" ], "tags": [ "metagame" @@ -3259,155 +3229,87 @@ "main": [ { "count": 1, - "name": "Ash Barrens" - }, - { - "count": 1, - "name": "Evolving Wilds" - }, - { - "count": 1, - "name": "Terramorphic Expanse" - }, - { - "count": 1, - "name": "Canyon Slough" - }, - { - "count": 1, - "name": "Exotic Orchard" - }, - { - "count": 1, - "name": "Foreboding Ruins" - }, - { - "count": 1, - "name": "Command Tower" - }, - { - "count": 1, - "name": "Mayhem Devil" - }, - { - "count": 1, - "name": "Return the Favor" - }, - { - "count": 1, - "name": "Blood Seeker" - }, - { - "count": 1, - "name": "Dragonskull Summit" - }, - { - "count": 1, - "name": "Shadowblood Ridge" - }, - { - "count": 1, - "name": "Smoldering Marsh" - }, - { - "count": 1, - "name": "Temple of Malice" - }, - { - "count": 1, - "name": "Arcane Signet" - }, - { - "count": 1, - "name": "Bedevil" - }, - { - "count": 1, - "name": "Chaos Warp" - }, - { - "count": 1, - "name": "Hissing Miasma" + "name": "Lightning Bolt" }, { "count": 1, - "name": "Infernal Grasp" + "name": "Krenko, Mob Boss" }, { - "count": 1, - "name": "Leechridden Swamp" + "count": 31, + "name": "Mountain" }, { "count": 1, - "name": "Retreat to Hagra" + "name": "Goblin Researcher" }, { "count": 1, - "name": "Sangromancer" + "name": "Goblin Oriflamme" }, { "count": 1, - "name": "Kardur, Doomscourge" + "name": "Active Volcano" }, { "count": 1, - "name": "Rakdos Charm" + "name": "Goblin Shortcutter" }, { "count": 1, - "name": "Sign in Blood" + "name": "Hordeling Outburst" }, { "count": 1, - "name": "Ebony Owl Netsuke" + "name": "Riot Piker" }, { "count": 1, - "name": "Painful Quandary" + "name": "Goblin Lackey" }, { "count": 1, - "name": "Rug of Smothering" + "name": "Icon of Ancestry" }, { "count": 1, - "name": "Spinerock Knoll" + "name": "Barge In" }, { "count": 1, - "name": "Tainted Peak" + "name": "Impact Tremors" }, { "count": 1, - "name": "Underworld Dreams" + "name": "Goblin Arsonist" }, { "count": 1, - "name": "Blasphemous Act" + "name": "Fists of Flame" }, { "count": 1, - "name": "Shivan Gorge" + "name": "Titan's Strength" }, { "count": 1, - "name": "Gray Merchant of Asphodel" + "name": "Goblin Motivator" }, { "count": 1, - "name": "Sulfurous Springs" + "name": "Goblin War Drums" }, { "count": 1, - "name": "Champion's Helm" + "name": "Goblin Piledriver" }, { "count": 1, - "name": "Rakdos Signet" + "name": "Pashalik Mons" }, { "count": 1, - "name": "Rampaging Ferocidon" + "name": "Dragon Fodder" }, { "count": 1, @@ -3415,199 +3317,195 @@ }, { "count": 1, - "name": "Vandalblast" + "name": "Boneclub Berserker" }, { "count": 1, - "name": "Roiling Vortex" + "name": "Zada, Hedron Grinder" }, { "count": 1, - "name": "Blackcleave Cliffs" + "name": "Mardu Scout" }, { "count": 1, - "name": "Brash Taunter" + "name": "Boggart Brute" }, { "count": 1, - "name": "Boltwave" + "name": "Volley Veteran" }, { "count": 1, - "name": "Fate Unraveler" + "name": "Krenko, Tin Street Kingpin" }, { "count": 1, - "name": "Talisman of Indulgence" + "name": "Ember Hauler" }, { "count": 1, - "name": "Thought Vessel" + "name": "Goblin Negotiation" }, { "count": 1, - "name": "Blood Artist" + "name": "Mogg Sentry" }, { "count": 1, - "name": "Swiftfoot Boots" + "name": "Goblin War Party" }, { "count": 1, - "name": "Dark Ritual" + "name": "Pyroblast" }, { "count": 1, - "name": "Citadel of Pain" + "name": "Blood Sun" }, { "count": 1, - "name": "Graven Cairns" + "name": "Reckless Ransacking" }, { "count": 1, - "name": "Bojuka Bog" + "name": "Lunar Frenzy" }, { "count": 1, - "name": "Mogis, God of Slaughter" + "name": "Goblin Trailblazer" }, { "count": 1, - "name": "Zo-Zu the Punisher" + "name": "Door of Destinies" }, { "count": 1, - "name": "Lightning Greaves" + "name": "Anger" }, { "count": 1, - "name": "Massacre Wurm" + "name": "Siege-Gang Commander" }, { "count": 1, - "name": "Basilisk Collar" + "name": "Goblin Grenade" }, { "count": 1, - "name": "Spiked Corridor // Torture Pit [DSC]" + "name": "Goblin Guide" }, { "count": 1, - "name": "Disrupt Decorum" + "name": "Daggersail Aeronaut" }, { "count": 1, - "name": "Gleeful Arsonist" + "name": "Heraldic Banner" }, { "count": 1, - "name": "Howling Mine" + "name": "Frenzied Goblin" }, { "count": 1, - "name": "Razorkin Needlehead" - }, - { - "count": 7, - "name": "Mountain" + "name": "Torbran, Thane of Red Fell" }, { "count": 1, - "name": "Horn of Greed" + "name": "Castle Embereth" }, { "count": 1, - "name": "Kederekt Parasite" + "name": "Courageous Goblin" }, { "count": 1, - "name": "The Lord of Pain" + "name": "Torch Courier" }, { "count": 1, - "name": "Vein Ripper" + "name": "Relentless Assault" }, { "count": 1, - "name": "Pyrohemia" + "name": "Stingscourger" }, { "count": 1, - "name": "Sword of War and Peace" + "name": "Berserkers' Onslaught" }, { "count": 1, - "name": "Chandra's Ignition" + "name": "Goblin Matron" }, { "count": 1, - "name": "Reanimate" + "name": "Goblin Taskmaster" }, { "count": 1, - "name": "Scrawling Crawler" + "name": "Reckless Air Strike" }, { "count": 1, - "name": "Haunted Ridge" + "name": "Fanatical Firebrand" }, { - "count": 7, - "name": "Swamp" + "count": 1, + "name": "Goblin Ringleader" }, { "count": 1, - "name": "Chandra, Awakened Inferno" + "name": "Goblinslide" }, { "count": 1, - "name": "Vito, Thorn of the Dusk Rose" + "name": "Ferocity of the Wilds" }, { "count": 1, - "name": "Raucous Theater" + "name": "Flame Slash" }, { "count": 1, - "name": "Persistent Constrictor" + "name": "Purphoros, Bronze-Blooded" }, { "count": 1, - "name": "Tibalt's Trickery" + "name": "Quest for the Goblin Lord" }, { "count": 1, - "name": "Descent into Avernus" + "name": "Blood Moon" }, { "count": 1, - "name": "Crypt Ghast" + "name": "Goblin Bombardment" }, { "count": 1, - "name": "Witch's Clinic" + "name": "Ruby Medallion" }, { "count": 1, - "name": "Ob Nixilis, Captive Kingpin" + "name": "Brightstone Ritual" }, { "count": 1, - "name": "Bloodchief Ascension" + "name": "Goblin Warchief" }, { "count": 1, - "name": "Hexing Squelcher" + "name": "Rundvelt Hordemaster" }, { "count": 1, - "name": "Solphim, Mayhem Dominus" + "name": "Conspicuous Snoop" }, { "count": 1, - "name": "Valgavoth, Harrower of Souls" + "name": "Goblin Chieftain" } ], "sideboard": [] diff --git a/client/public/feeds/mtggoldfish-modern.json b/client/public/feeds/mtggoldfish-modern.json index 6bfea1590b..65121c0f8d 100644 --- a/client/public/feeds/mtggoldfish-modern.json +++ b/client/public/feeds/mtggoldfish-modern.json @@ -5,7 +5,7 @@ "icon": "G", "format": "modern", "version": 1, - "updated": "2026-07-29T00:00:00Z", + "updated": "2026-07-30T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/modern", "decks": [ { @@ -1275,129 +1275,181 @@ ] }, { - "name": "Boros Ponza", + "name": "Neobrand", "author": "MTGGoldfish", "colors": [ - "R", - "W" + "G", + "U" ], "tags": [ "metagame" ], "main": [ { - "count": 4, - "name": "Erode" + "count": 1, + "name": "Flooded Strand" }, { - "count": 3, - "name": "High Noon" + "count": 4, + "name": "Neoform" }, { "count": 4, - "name": "Cori Mountain Monastery" + "name": "Planar Genesis" }, { "count": 4, - "name": "Demolition Field" + "name": "Eldritch Evolution" }, { - "count": 4, - "name": "Price of Freedom" + "count": 2, + "name": "Generous Ent" }, { - "count": 3, - "name": "Avengers Disassembled" + "count": 1, + "name": "Forest" + }, + { + "count": 1, + "name": "Griselbrand" }, { "count": 4, - "name": "Sunken Citadel" + "name": "Misty Rainforest" + }, + { + "count": 3, + "name": "Hedge Maze" }, { "count": 1, - "name": "Castle Ardenvale" + "name": "Wistfulness" }, { "count": 4, - "name": "Field of Ruin" + "name": "Allosaurus Rider" }, { "count": 2, - "name": "Mountain" + "name": "Scalding Tarn" }, { - "count": 3, - "name": "Galvanic Discharge" + "count": 1, + "name": "Breeding Pool" }, { "count": 4, - "name": "Cleansing Wildfire" + "name": "Consign to Memory" + }, + { + "count": 1, + "name": "Island" + }, + { + "count": 1, + "name": "Hooting Mandrills" + }, + { + "count": 1, + "name": "Nourishing Shoal" + }, + { + "count": 1, + "name": "Wooded Foothills" + }, + { + "count": 1, + "name": "Xenagos, God of Revels" + }, + { + "count": 2, + "name": "Disciple of Freyalise" }, { "count": 3, - "name": "Sacred Foundry" + "name": "Veil of Summer" + }, + { + "count": 2, + "name": "Ghalta, Stampede Tyrant" }, { "count": 1, - "name": "The Legend of Roku" + "name": "Ureni, the Song Unending" }, { "count": 4, - "name": "Plains" + "name": "Summoner's Pact" }, { - "count": 4, - "name": "Solitude" + "count": 1, + "name": "Boseiju, Who Endures" }, { - "count": 4, - "name": "Wrath of the Skies" + "count": 3, + "name": "Pact of Negation" }, { - "count": 4, - "name": "Path to Exile" + "count": 1, + "name": "Endurance" + }, + { + "count": 1, + "name": "Atraxa, Grand Unifier" + }, + { + "count": 1, + "name": "Bridgeworks Battle" } ], "sideboard": [ { "count": 1, - "name": "High Noon" + "name": "Elesh Norn, Grand Cenobite" }, { "count": 1, - "name": "Surgical Extraction" + "name": "Endurance" }, { - "count": 1, - "name": "Vexing Bauble" + "count": 2, + "name": "Nature's Claim" + }, + { + "count": 2, + "name": "Mystical Dispute" }, { "count": 1, - "name": "Avengers Disassembled" + "name": "Quandrix Charm" }, { "count": 2, - "name": "Orim's Chant" + "name": "Hooting Mandrills" }, { - "count": 3, - "name": "Wear // Tear" + "count": 1, + "name": "Into the Flood Maw" }, { "count": 1, - "name": "The Legend of Roku" + "name": "Wistfulness" }, { "count": 1, - "name": "Wrath of God" + "name": "Dismember" }, { - "count": 3, - "name": "Rest in Peace" + "count": 1, + "name": "Boseiju, Who Endures" + }, + { + "count": 1, + "name": "Veil of Summer" }, { "count": 1, - "name": "Kaheera, the Orphanguard" + "name": "Atraxa, Grand Unifier" } ] } diff --git a/client/public/feeds/mtggoldfish-pioneer.json b/client/public/feeds/mtggoldfish-pioneer.json index 82e28f87e8..1fe8ad0027 100644 --- a/client/public/feeds/mtggoldfish-pioneer.json +++ b/client/public/feeds/mtggoldfish-pioneer.json @@ -5,7 +5,7 @@ "icon": "G", "format": "pioneer", "version": 1, - "updated": "2026-07-29T00:00:00Z", + "updated": "2026-07-30T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/pioneer", "decks": [ { diff --git a/client/public/feeds/mtggoldfish-standard.json b/client/public/feeds/mtggoldfish-standard.json index 10232f121e..5ed8e49227 100644 --- a/client/public/feeds/mtggoldfish-standard.json +++ b/client/public/feeds/mtggoldfish-standard.json @@ -5,7 +5,7 @@ "icon": "G", "format": "standard", "version": 1, - "updated": "2026-07-29T00:00:00Z", + "updated": "2026-07-30T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/standard", "decks": [ { From fd6c14fbbaf2341bb8e7ca289449f43556daa462 Mon Sep 17 00:00:00 2001 From: atoz96 <132365140+atoz96@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:46:31 -0400 Subject: [PATCH 36/63] fix(server-core): keep the forfeit record when a reconnect is expired (#6813) attempt_reconnect removed the disconnect entry before deciding whether the grace period had elapsed, so an expired attempt consumed the very record check_expired needs to raise a forfeit. Two consequences, both reachable from a normal client: - the forfeit sweep never sees the entry again, so the timed-out seat is never forfeited (games) and never auto-picked (drafts) - every retry after the first falls through to NotFound, which both callers treat as "was never disconnected, allow the reconnect anyway" (session.rs re-seats and returns filtered state; draft_session.rs re-seats and mirrors SetSeatConnected into the engine bitmap) so disconnecting, waiting out the grace period, then calling reconnect twice puts the player back in the game and cancels the forfeit. only a successful reconnect consumes the record now; an expired one is left in place so check_expired / check_expired_with_players still report it and repeat attempts keep answering Expired. Co-authored-by: atoz96 --- crates/server-core/src/reconnect.rs | 53 +++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/crates/server-core/src/reconnect.rs b/crates/server-core/src/reconnect.rs index b0c6d65f89..0d318538ae 100644 --- a/crates/server-core/src/reconnect.rs +++ b/crates/server-core/src/reconnect.rs @@ -52,17 +52,25 @@ impl ReconnectManager { /// here we check if the disconnect is within the grace period. pub fn attempt_reconnect(&mut self, game_code: &str, player: PlayerId) -> ReconnectResult { let key = format!("{}:{}", game_code, player.0); - match self.disconnected.remove(&key) { - Some(info) => { - if info.disconnect_time.elapsed() <= info.grace_period { - ReconnectResult::Ok { - game_code: info.game_code, - } - } else { - ReconnectResult::Expired - } - } - None => ReconnectResult::NotFound, + // Only a successful reconnect consumes the record. An expired one must + // leave it in place: `check_expired` is what turns it into a forfeit, + // and removing it here both skipped that forfeit and downgraded every + // retry to `NotFound` — which callers treat as "was never disconnected, + // allow the reconnect anyway", re-seating a player who had already + // timed out. + let expired = match self.disconnected.get(&key) { + Some(info) => info.disconnect_time.elapsed() > info.grace_period, + None => return ReconnectResult::NotFound, + }; + if expired { + return ReconnectResult::Expired; + } + let info = self + .disconnected + .remove(&key) + .expect("entry observed present above"); + ReconnectResult::Ok { + game_code: info.game_code, } } @@ -142,6 +150,29 @@ mod tests { } } + #[test] + fn expired_attempt_does_not_consume_the_forfeit_record() { + let mut mgr = ReconnectManager::new(Duration::from_millis(0)); + mgr.record_disconnect("GAME01", PlayerId(0), Duration::from_millis(0)); + std::thread::sleep(Duration::from_millis(1)); + + // A late reconnect must not clear the record the forfeit sweep needs. + assert!(matches!( + mgr.attempt_reconnect("GAME01", PlayerId(0)), + ReconnectResult::Expired + )); + + // Retrying must stay Expired, not fall through to the permissive + // NotFound branch that lets the caller re-seat the player. + assert!(matches!( + mgr.attempt_reconnect("GAME01", PlayerId(0)), + ReconnectResult::Expired + )); + + assert!(mgr.is_disconnected("GAME01", PlayerId(0))); + assert_eq!(mgr.check_expired(), vec!["GAME01".to_string()]); + } + #[test] fn reconnect_unknown_game_returns_not_found() { let mut mgr = ReconnectManager::default(); From daf5c1df7b2f372042b1a093558312c1dd150bb4 Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:48:24 -0700 Subject: [PATCH 37/63] feat: prompt for sacrificial mana sources (#6801) * feat: prompt for sacrificial mana sources * test(client): fix mana source selection fixture type * fix(engine): complete sacrificial mana selection state * fix(ai): classify sacrificial mana actions * fix(manabrew): map sacrificial mana actions * test(engine): count sacrificial mana prompt variant * test(engine): map cancel-cast display state authority --------- Co-authored-by: matthewevans --- .../manaSourceSelectionWireTypes.test.ts | 1 + .../adapter/generated/interaction/index.ts | 2 +- client/src/adapter/types.ts | 14 +- .../board/__tests__/PermanentCard.test.tsx | 1 + client/src/components/mana/ManaPaymentUI.tsx | 63 ++++ .../mana/__tests__/ManaPaymentUI.test.tsx | 62 +++- .../components/settings/PreferencesModal.tsx | 21 +- .../game/__tests__/castPaymentMode.test.ts | 31 ++ client/src/game/castPaymentMode.ts | 31 +- client/src/game/controllers/aiController.ts | 11 +- client/src/game/waitingForRegistry.ts | 2 + .../__tests__/useKeyboardShortcuts.test.tsx | 1 + client/src/i18n/locales/de/game.json | 1 + client/src/i18n/locales/de/settings.json | 1 + client/src/i18n/locales/en/game.json | 7 + client/src/i18n/locales/en/settings.json | 5 + client/src/i18n/locales/es/game.json | 1 + client/src/i18n/locales/es/settings.json | 1 + client/src/i18n/locales/fr/game.json | 1 + client/src/i18n/locales/fr/settings.json | 1 + client/src/i18n/locales/it/game.json | 1 + client/src/i18n/locales/it/settings.json | 1 + client/src/i18n/locales/pl/game.json | 1 + client/src/i18n/locales/pl/settings.json | 1 + client/src/i18n/locales/pt/game.json | 1 + client/src/i18n/locales/pt/settings.json | 1 + client/src/network/__tests__/protocol.test.ts | 2 +- client/src/network/protocol.ts | 3 +- client/src/pages/GamePage.tsx | 4 +- client/src/stores/preferencesStore.ts | 18 +- .../__tests__/cardActionChoice.test.ts | 1 + crates/engine/src/ai_support/candidates.rs | 26 +- crates/engine/src/ai_support/filter.rs | 2 +- crates/engine/src/ai_support/mod.rs | 39 ++ .../src/ai_support/payment_continuation.rs | 16 +- crates/engine/src/game/casting.rs | 1 + crates/engine/src/game/casting_costs.rs | 94 ++++- crates/engine/src/game/costs.rs | 5 + crates/engine/src/game/derived.rs | 3 + crates/engine/src/game/effects/prepare.rs | 4 +- crates/engine/src/game/engine.rs | 85 +++++ crates/engine/src/game/interaction.rs | 30 +- crates/engine/src/game/mana_abilities.rs | 21 ++ crates/engine/src/game/mana_sources.rs | 235 +++++++++++- crates/engine/src/game/replay.rs | 31 +- crates/engine/src/game/scenario.rs | 12 + .../engine/src/types/action_stable_order.rs | 12 + crates/engine/src/types/actions.rs | 15 + crates/engine/src/types/game_state.rs | 70 +++- crates/engine/src/types/interaction.rs | 2 + crates/engine/src/types/mana.rs | 71 +++- crates/engine/src/types/mod.rs | 4 +- .../fixtures/cr733/authority_matrix.json.gz | Bin 40716 -> 40792 bytes crates/engine/tests/integration/main.rs | 1 + .../integration/sacrificial_mana_choice.rs | 341 ++++++++++++++++++ crates/manabrew-compat/src/lib.rs | 9 +- crates/phase-ai/src/decision_kind.rs | 11 +- .../phase-ai/src/policies/discard_payoff.rs | 2 + crates/phase-ai/src/policies/draw_payoff.rs | 2 + crates/phase-ai/src/search.rs | 25 +- .../src/client_message_wire_guard.rs | 2 + .../src/game_action_payload_guard.rs | 3 +- crates/server-core/src/protocol.rs | 17 + .../tests/game_action_payload_guard.rs | 1 + 64 files changed, 1417 insertions(+), 67 deletions(-) create mode 100644 crates/engine/tests/integration/sacrificial_mana_choice.rs diff --git a/client/src/adapter/__tests__/manaSourceSelectionWireTypes.test.ts b/client/src/adapter/__tests__/manaSourceSelectionWireTypes.test.ts index 09741924d1..18bd28f042 100644 --- a/client/src/adapter/__tests__/manaSourceSelectionWireTypes.test.ts +++ b/client/src/adapter/__tests__/manaSourceSelectionWireTypes.test.ts @@ -59,6 +59,7 @@ describe("mana-source selection wire types", () => { source: { object_id: 17, incarnation: 3 }, ability_index: 0, mana_type: "Blue", + output: { type: "Concrete", data: "Blue" }, atomic_combination: null, restrictions, penalty: "None", diff --git a/client/src/adapter/generated/interaction/index.ts b/client/src/adapter/generated/interaction/index.ts index 0e71b51b46..fff6e5b18f 100644 --- a/client/src/adapter/generated/interaction/index.ts +++ b/client/src/adapter/generated/interaction/index.ts @@ -57,7 +57,7 @@ export type SelectionConstraint = { "type": "count", "data": { min: number, max: export type ConfirmSemantics = "immediate" | "explicit"; -export type InteractionActionCode = "passPriority" | "chooseMeldPair" | "chooseEntryAttackTarget" | "playLand" | "castSpell" | "foretell" | "activateAbility" | "declareAttackers" | "declareBlockers" | "chooseUntap" | "chooseExert" | "chooseEnlist" | "chooseClashOpponent" | "chooseZoneOpponentChooser" | "choosePileOpponent" | "chooseAnnouncingOpponent" | "chooseGiftRecipient" | "chooseAssistPlayer" | "commitAssistPayment" | "mulliganDecision" | "reorderHand" | "tapLandForMana" | "untapLandForMana" | "spendPoolMana" | "unspendPoolMana" | "selectCards" | "chooseRemoveCounterCostDistribution" | "selectCoinFlips" | "chooseOutsideGameCards" | "selectTargets" | "chooseTarget" | "chooseReplacement" | "orderTriggers" | "cancelCast" | "equip" | "crewVehicle" | "activateStation" | "saddleMount" | "transform" | "playFaceDown" | "turnFaceUp" | "submitSideboard" | "choosePlayDraw" | "chooseOption" | "submitVoteCandidate" | "submitSpellbookDraft" | "submitPilePartition" | "choosePile" | "chooseBranch" | "submitLifeRedistribution" | "chooseDamageSource" | "selectModes" | "decideOptionalCost" | "chooseAdventureFace" | "chooseModalFace" | "chooseAlternativeCast" | "chooseCastingVariant" | "keepAllCopyTargets" | "choosePermanentTypeSlot" | "activateNinjutsu" | "castSpellAsSneak" | "castSpellAsWebSlinging" | "castSpellForFree" | "castSpellAsMiracle" | "castSpellAsMadness" | "decideOptionalEffect" | "respondToSpliceOffer" | "decideOptionalEffectAndRemember" | "payUnlessCost" | "chooseUnlessCostBranch" | "chooseActivationCostBranch" | "payCombatTax" | "chooseRingBearer" | "choosePair" | "chooseDungeon" | "chooseDungeonRoom" | "unlockRoomDoor" | "rollPlanarDie" | "chooseRoomDoor" | "tapForConvoke" | "harmonizeTap" | "declareCompanion" | "companionToHand" | "discoverChoice" | "graveyardPaidCastChoice" | "cascadeChoice" | "rippleChoice" | "freeCastWindowChoice" | "chooseTopOrBottom" | "chooseMutateMergeSide" | "cipherEncode" | "chooseLegend" | "chooseBattleProtector" | "setAutoPass" | "cancelAutoPass" | "setPhaseStops" | "setPriorityPassingMode" | "setPriorityYield" | "setMayTriggerAutoChoice" | "setTriggerOrderTemplate" | "assignCombatDamage" | "assignBlockerDamage" | "distributeAmong" | "chooseCounterMoveDistribution" | "chooseCountersToRemove" | "submitPayAmount" | "retargetSpell" | "learnDecision" | "selectCategoryPermanents" | "chooseKeptCreatures" | "chooseKeptPermanents" | "chooseX" | "submitPhyrexianChoices" | "chooseManaColor" | "payManaAbilityMana" | "castPreparedCopy" | "chooseSpecializeColor" | "castParadigmCopy" | "passParadigmOffer" | "grantDebugPermission" | "revokeDebugPermission" | "concede" | "declareShortcut" | "respondToShortcut" | "declineShortcut" | "precastCopyShortcut" | "endContinuousEffect" | "debug"; +export type InteractionActionCode = "passPriority" | "chooseMeldPair" | "chooseEntryAttackTarget" | "playLand" | "castSpell" | "foretell" | "activateAbility" | "declareAttackers" | "declareBlockers" | "chooseUntap" | "chooseExert" | "chooseEnlist" | "chooseClashOpponent" | "chooseZoneOpponentChooser" | "choosePileOpponent" | "chooseAnnouncingOpponent" | "chooseGiftRecipient" | "chooseAssistPlayer" | "commitAssistPayment" | "mulliganDecision" | "reorderHand" | "tapLandForMana" | "activateManaSource" | "backToManaPayment" | "untapLandForMana" | "spendPoolMana" | "unspendPoolMana" | "selectCards" | "chooseRemoveCounterCostDistribution" | "selectCoinFlips" | "chooseOutsideGameCards" | "selectTargets" | "chooseTarget" | "chooseReplacement" | "orderTriggers" | "cancelCast" | "equip" | "crewVehicle" | "activateStation" | "saddleMount" | "transform" | "playFaceDown" | "turnFaceUp" | "submitSideboard" | "choosePlayDraw" | "chooseOption" | "submitVoteCandidate" | "submitSpellbookDraft" | "submitPilePartition" | "choosePile" | "chooseBranch" | "submitLifeRedistribution" | "chooseDamageSource" | "selectModes" | "decideOptionalCost" | "chooseAdventureFace" | "chooseModalFace" | "chooseAlternativeCast" | "chooseCastingVariant" | "keepAllCopyTargets" | "choosePermanentTypeSlot" | "activateNinjutsu" | "castSpellAsSneak" | "castSpellAsWebSlinging" | "castSpellForFree" | "castSpellAsMiracle" | "castSpellAsMadness" | "decideOptionalEffect" | "respondToSpliceOffer" | "decideOptionalEffectAndRemember" | "payUnlessCost" | "chooseUnlessCostBranch" | "chooseActivationCostBranch" | "payCombatTax" | "chooseRingBearer" | "choosePair" | "chooseDungeon" | "chooseDungeonRoom" | "unlockRoomDoor" | "rollPlanarDie" | "chooseRoomDoor" | "tapForConvoke" | "harmonizeTap" | "declareCompanion" | "companionToHand" | "discoverChoice" | "graveyardPaidCastChoice" | "cascadeChoice" | "rippleChoice" | "freeCastWindowChoice" | "chooseTopOrBottom" | "chooseMutateMergeSide" | "cipherEncode" | "chooseLegend" | "chooseBattleProtector" | "setAutoPass" | "cancelAutoPass" | "setPhaseStops" | "setPriorityPassingMode" | "setPriorityYield" | "setMayTriggerAutoChoice" | "setTriggerOrderTemplate" | "assignCombatDamage" | "assignBlockerDamage" | "distributeAmong" | "chooseCounterMoveDistribution" | "chooseCountersToRemove" | "submitPayAmount" | "retargetSpell" | "learnDecision" | "selectCategoryPermanents" | "chooseKeptCreatures" | "chooseKeptPermanents" | "chooseX" | "submitPhyrexianChoices" | "chooseManaColor" | "payManaAbilityMana" | "castPreparedCopy" | "chooseSpecializeColor" | "castParadigmCopy" | "passParadigmOffer" | "grantDebugPermission" | "revokeDebugPermission" | "concede" | "declareShortcut" | "respondToShortcut" | "declineShortcut" | "precastCopyShortcut" | "endContinuousEffect" | "debug"; export type InteractionRoleCode = "source" | "candidate" | "partner" | "attackTarget" | "target" | "paymentMode" | "abilityIndex" | "attacker" | "bandCount" | "blocker" | "blocked" | "untap" | "exert" | "enlistTarget" | "enlist" | "opponent" | "assistPlayer" | "assist" | "genericMana" | "mulligan" | "serumPowder" | "handCard" | "selected" | "counterSource" | "counterType" | "amount" | "coinFlipIndex" | "sideboardIndex" | "faceUpExile" | "optionIndex" | "triggerIndex" | "crewMember" | "stationCrew" | "x" | "mainCard" | "sideboardCard" | "playFirst" | "option" | "candidateIndex" | "cardName" | "pileA" | "pile" | "modeIndex" | "pay" | "face" | "castCost" | "permanentType" | "returnCreature" | "permissionSource" | "accept" | "spliceCard" | "splice" | "choice" | "costBranch" | "costBranchIndex" | "pair" | "dungeon" | "roomIndex" | "door" | "operation" | "convokeMana" | "harmonizeCreature" | "harmonize" | "companion" | "castChoice" | "castCard" | "placement" | "mergeSide" | "encodeCreature" | "encode" | "defender" | "protector" | "assignmentMode" | "damageTarget" | "damageAmount" | "trampleDamage" | "controllerDamage" | "destination" | "discardCard" | "learn" | "category" | "kept" | "phyrexianPayment" | "manaChoice" | "count" | "manaPayment" | "producedMana" | "color" | "player" | "castingVariant" | "mode" | "modeCost" | "castingCost" | "voteOption" | "voteCandidate"; diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index ec938837a8..ddc7c48e62 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -668,7 +668,10 @@ export interface CastingVariantChoiceOption { mana_cost: ManaCost; } -export type CastPaymentMode = { type: "Auto" } | { type: "Manual" }; +export type CastPaymentMode = + | { type: "Auto" } + | { type: "AutoExceptSacrificialMana" } + | { type: "Manual" }; export type UnlessCost = | { type: "Fixed"; cost: ManaCost } @@ -1158,6 +1161,10 @@ export type ManaSourcePenalty = | { PaysLifeOnActivation: { fixed_amount: number | null } } | "Sacrifices"; +export type ManaSourceOutput = + | { type: "Concrete"; data: ManaType } + | { type: "DeferredColorChoice" }; + export type ProductionOverride = | { type: "SingleColor"; data: ManaType } | { type: "Combination"; data: ManaType[] }; @@ -1172,6 +1179,7 @@ export interface ManaSourceSelection { source: ObjectIncarnationRef; ability_index: number | null; mana_type: ManaType; + output: ManaSourceOutput; atomic_combination: ManaType[] | null; restrictions: ManaRestriction[]; penalty: ManaSourcePenalty; @@ -1659,6 +1667,7 @@ export type WaitingFor = }; } | { type: "ManaPayment"; data: { player: PlayerId; convoke_mode?: ConvokeMode } } + | { type: "ManaSourceSelection"; data: { player: PlayerId; options: ManaSourceSelection[]; convoke_mode?: ConvokeMode } } | { type: "ChooseXValue"; data: { @@ -2186,6 +2195,8 @@ export type GameAction = | { type: "MulliganDecision"; data: { choice: MulliganChoice } } | { type: "ReorderHand"; data: { order: ObjectId[] } } | { type: "TapLandForMana"; data: { selection: ManaSourceSelection } } + | { type: "ActivateManaSource"; data: { selection: ManaSourceSelection } } + | { type: "BackToManaPayment" } | { type: "UntapLandForMana"; data: { object_id: ObjectId } } // CR 118.3a: pin / unpin a specific pool unit during manual mana payment. | { type: "SpendPoolMana"; data: { pip_id: number } } @@ -2927,6 +2938,7 @@ export interface GameState { combat: CombatState | null; waiting_for: WaitingFor; has_pending_cast: boolean; + allows_cancel_cast?: boolean; /** * CR 601.2f: The locked-in pending cast (cost, ability, object) while the * caster is mid-cast. Present during ManaPayment / cost-choice WaitingFor diff --git a/client/src/components/board/__tests__/PermanentCard.test.tsx b/client/src/components/board/__tests__/PermanentCard.test.tsx index 7c8d3f6038..0da939609d 100644 --- a/client/src/components/board/__tests__/PermanentCard.test.tsx +++ b/client/src/components/board/__tests__/PermanentCard.test.tsx @@ -1169,6 +1169,7 @@ describe("PermanentCard", () => { source: { object_id: 39, incarnation: 1 }, ability_index: null, mana_type: "Green", + output: { type: "Concrete", data: "Green" }, atomic_combination: null, restrictions: [], penalty: "None", diff --git a/client/src/components/mana/ManaPaymentUI.tsx b/client/src/components/mana/ManaPaymentUI.tsx index ac9b24a55c..834eb1d0af 100644 --- a/client/src/components/mana/ManaPaymentUI.tsx +++ b/client/src/components/mana/ManaPaymentUI.tsx @@ -621,6 +621,69 @@ export function ManaPaymentUI() { ); } +/** CR 605.3b: The engine has reached a mana ability that sacrifices a + * permanent. It supplies every legal capability; this display layer only + * renders those rows and returns the selected opaque action. */ +export function ManaSourceSelectionUI() { + const { t } = useTranslation("game"); + const waitingFor = useGameStore((s) => s.waitingFor); + const gameState = useGameStore((s) => s.gameState); + const dispatch = useGameStore((s) => s.dispatch); + + if (waitingFor?.type !== "ManaSourceSelection") return null; + + return ( + + + +

{t("manaSourceSelection.title")}

+

{t("manaSourceSelection.description")}

+
+ {waitingFor.data.options.map((selection, index) => { + const source = gameState?.objects[selection.source.object_id]; + return ( + + ); + })} +
+ +
+
+
+ ); +} + // Color → shard symbol code for `ManaSymbol` (White→"W", …, Colorless→"C"). const COLOR_SHARD: Record = { White: "W", diff --git a/client/src/components/mana/__tests__/ManaPaymentUI.test.tsx b/client/src/components/mana/__tests__/ManaPaymentUI.test.tsx index 2c68afa18e..639357af7c 100644 --- a/client/src/components/mana/__tests__/ManaPaymentUI.test.tsx +++ b/client/src/components/mana/__tests__/ManaPaymentUI.test.tsx @@ -2,7 +2,7 @@ import { act } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { ManaPaymentUI } from "../ManaPaymentUI"; +import { ManaPaymentUI, ManaSourceSelectionUI } from "../ManaPaymentUI"; import { useGameStore } from "../../../stores/gameStore"; import type { GameState } from "../../../adapter/types"; import { buildGameObjectWithCoreTypes, buildObjectMap } from "../../../test/factories/gameObjectFactory.ts"; @@ -421,3 +421,63 @@ describe("ManaPaymentUI", () => { }); }); }); + +describe("ManaSourceSelectionUI", () => { + beforeEach(() => { + useGameStore.getState().reset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("dispatches only the engine-issued sacrificial source and Back action", () => { + const dispatch = vi.fn().mockResolvedValue([]); + const source = buildGameObjectWithCoreTypes(["Artifact"], { + id: 91, + card_id: 91, + name: "Basal Sliver", + zone: "Battlefield", + }); + const selection = { + source: { object_id: 91, incarnation: 0 }, + ability_index: 0, + mana_type: "Black" as const, + output: { type: "Concrete" as const, data: "Black" as const }, + atomic_combination: null, + restrictions: [], + penalty: "Sacrifices" as const, + taps_for_mana: [], + }; + const gameState = createGameState({ + objects: buildObjectMap(source), + waiting_for: { + type: "ManaSourceSelection", + data: { + player: 0, + options: [selection], + }, + }, + }); + + act(() => { + useGameStore.setState({ + gameState, + waitingFor: gameState.waiting_for, + dispatch, + legalActions: [], + }); + }); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /basal sliver/i })); + expect(dispatch).toHaveBeenCalledWith({ + type: "ActivateManaSource", + data: { selection }, + }); + + fireEvent.click(screen.getByRole("button", { name: /back to mana payment/i })); + expect(dispatch).toHaveBeenCalledWith({ type: "BackToManaPayment" }); + }); +}); diff --git a/client/src/components/settings/PreferencesModal.tsx b/client/src/components/settings/PreferencesModal.tsx index 400e761eef..89c682a38b 100644 --- a/client/src/components/settings/PreferencesModal.tsx +++ b/client/src/components/settings/PreferencesModal.tsx @@ -34,6 +34,7 @@ import type { CommandZoneDisplay, LogDefaultState, MultiplayerBoardLayout, + SpellPaymentMode, ZoneCollapseMode, } from "../../stores/preferencesStore.ts"; import type { SupportedLng } from "../../i18n/resources.ts"; @@ -72,6 +73,7 @@ const CARD_SIZES: CardSizePreference[] = ["small", "medium", "large"]; const COMMAND_ZONE_DISPLAYS: CommandZoneDisplay[] = ["auto", "inline", "compact"]; const ZONE_COLLAPSE_MODES: ZoneCollapseMode[] = ["auto", "on", "off"]; const CARD_PREVIEW_MODES: CardPreviewMode[] = ["follow", "side", "shift"]; +const SPELL_PAYMENT_MODES: SpellPaymentMode[] = ["auto", "autoExceptSacrificialMana", "manual"]; const LOG_DEFAULTS: LogDefaultState[] = ["open", "closed"]; const VFX_QUALITIES: VfxQuality[] = ["full", "reduced", "minimal"]; const MULTIPLAYER_BOARD_LAYOUTS: MultiplayerBoardLayout[] = ["focused", "split"]; @@ -428,15 +430,12 @@ export function PreferencesModal({ - + t(`gameplay.spellPaymentOptions.${option}`)} + /> {isTauri() && ( @@ -723,7 +722,7 @@ export function PreferencesModal({ type="button" onClick={handleImportTheme} disabled={themeImportStatus === "loading" || !themeImportUrl.trim()} - className="rounded-[14px] border border-white/10 bg-sky-600/30 px-4 py-2 text-sm text-slate-100 hover:bg-sky-600/50 disabled:opacity-50" + className="rounded-[14px] border border-white/10 bg-sky-600/30 px-4 py-2 text-sm text-white hover:bg-sky-600/50 disabled:opacity-50" > {themeImportStatus === "loading" ? t("audioTheme.loading") : t("audioTheme.import")} @@ -1537,7 +1536,7 @@ function ArtChainEditor({ type="button" onClick={handleAddSet} disabled={!resolveSetCode(setInput)} - className="rounded-[14px] border border-white/10 bg-sky-600/30 px-4 py-2 text-sm text-slate-100 hover:bg-sky-600/50 disabled:opacity-50" + className="rounded-[14px] border border-white/10 bg-sky-600/30 px-4 py-2 text-sm text-white hover:bg-sky-600/50 disabled:opacity-50" > {t("artChain.addSet")} diff --git a/client/src/game/__tests__/castPaymentMode.test.ts b/client/src/game/__tests__/castPaymentMode.test.ts index 7d9c5be5b8..f717b30312 100644 --- a/client/src/game/__tests__/castPaymentMode.test.ts +++ b/client/src/game/__tests__/castPaymentMode.test.ts @@ -38,4 +38,35 @@ describe("applySpellPaymentPreference", () => { type: "Manual", }); }); + + it("stamps every cast-family action with the sacrificial-mana preference", () => { + usePreferencesStore.setState({ spellPaymentMode: "autoExceptSacrificialMana" }); + const castTypes = [ + "CastSpell", + "CastSpellForFree", + "CastSpellAsMiracle", + "CastSpellAsMadness", + "CastSpellAsSneak", + "CastSpellAsWebSlinging", + ] as const; + + for (const type of castTypes) { + const action = { type, data: {} } as GameAction; + const result = applySpellPaymentPreference(action) as GameAction & { + data: { payment_mode?: unknown }; + }; + expect(result.data.payment_mode).toEqual({ type: "AutoExceptSacrificialMana" }); + } + }); + + it("lets the per-game manual override dominate the sacrificial-mana preference", () => { + usePreferencesStore.setState({ spellPaymentMode: "autoExceptSacrificialMana" }); + useUiStore.setState({ manualManaOverride: true }); + + const result = applySpellPaymentPreference(castAction); + + expect((result as Extract).data.payment_mode).toEqual({ + type: "Manual", + }); + }); }); diff --git a/client/src/game/castPaymentMode.ts b/client/src/game/castPaymentMode.ts index 42ffebe37b..a94c7e6836 100644 --- a/client/src/game/castPaymentMode.ts +++ b/client/src/game/castPaymentMode.ts @@ -3,45 +3,52 @@ import { usePreferencesStore } from "../stores/preferencesStore"; import { useUiStore } from "../stores/uiStore"; const MANUAL_CAST_PAYMENT_MODE: CastPaymentMode = { type: "Manual" }; +const AUTO_EXCEPT_SACRIFICIAL_MANA_PAYMENT_MODE: CastPaymentMode = { + type: "AutoExceptSacrificialMana", +}; export function applySpellPaymentPreference(action: GameAction): GameAction { - // Two intended sources of truth: the durable `spellPaymentMode` preference and - // the ephemeral per-game `manualManaOverride` toggle. Manual wins if EITHER is on. - const manual = - usePreferencesStore.getState().spellPaymentMode === "manual" || - useUiStore.getState().manualManaOverride; - if (!manual) return action; + // Manual is the per-game escape hatch and deliberately dominates the saved + // preference. Otherwise stamp every cast-family action with the exact engine + // mode so all alternate casting routes share the same payment semantics. + const preference = usePreferencesStore.getState().spellPaymentMode; + const mode = useUiStore.getState().manualManaOverride || preference === "manual" + ? MANUAL_CAST_PAYMENT_MODE + : preference === "autoExceptSacrificialMana" + ? AUTO_EXCEPT_SACRIFICIAL_MANA_PAYMENT_MODE + : null; + if (!mode) return action; switch (action.type) { case "CastSpell": return { ...action, - data: { ...action.data, payment_mode: MANUAL_CAST_PAYMENT_MODE }, + data: { ...action.data, payment_mode: mode }, }; case "CastSpellForFree": return { ...action, - data: { ...action.data, payment_mode: MANUAL_CAST_PAYMENT_MODE }, + data: { ...action.data, payment_mode: mode }, }; case "CastSpellAsMiracle": return { ...action, - data: { ...action.data, payment_mode: MANUAL_CAST_PAYMENT_MODE }, + data: { ...action.data, payment_mode: mode }, }; case "CastSpellAsMadness": return { ...action, - data: { ...action.data, payment_mode: MANUAL_CAST_PAYMENT_MODE }, + data: { ...action.data, payment_mode: mode }, }; case "CastSpellAsSneak": return { ...action, - data: { ...action.data, payment_mode: MANUAL_CAST_PAYMENT_MODE }, + data: { ...action.data, payment_mode: mode }, }; case "CastSpellAsWebSlinging": return { ...action, - data: { ...action.data, payment_mode: MANUAL_CAST_PAYMENT_MODE }, + data: { ...action.data, payment_mode: mode }, }; default: return action; diff --git a/client/src/game/controllers/aiController.ts b/client/src/game/controllers/aiController.ts index a52f1c9eae..21e0586f62 100644 --- a/client/src/game/controllers/aiController.ts +++ b/client/src/game/controllers/aiController.ts @@ -280,7 +280,16 @@ export function createAIController(config: AIControllerConfig): AIController { waitingFor: WaitingFor, state: GameState, ): Promise { - if (state.has_pending_cast) { + if (waitingFor.type === "ManaSourceSelection") { + const { adapter } = useGameStore.getState(); + if (!adapter) return Promise.resolve(null); + return adapter.getLegalActions().then((result) => { + return result.actions.find((action) => action.type === "ActivateManaSource") + ?? result.actions.find((action) => action.type === "BackToManaPayment") + ?? null; + }); + } + if (state.has_pending_cast && state.allows_cancel_cast) { return Promise.resolve({ type: "CancelCast" }); } const { adapter } = useGameStore.getState(); diff --git a/client/src/game/waitingForRegistry.ts b/client/src/game/waitingForRegistry.ts index b70f979ca5..e8dfb6b406 100644 --- a/client/src/game/waitingForRegistry.ts +++ b/client/src/game/waitingForRegistry.ts @@ -38,6 +38,7 @@ export const HANDLED_WAITING_FOR_TYPES: ReadonlySet = "MeldAttackTargetChoice", // Cast / activation chain — ManaPayment + PhyrexianPayment share ManaPaymentUI. ...MANA_PAYMENT_WAITING_FOR_TYPES, + "ManaSourceSelection", "ChooseXValue", "PayAmountChoice", "TargetSelection", @@ -247,6 +248,7 @@ export function waitingForReason( case "RetargetChoice": return { key: "status.reason.choosingTargets" }; case "ManaPayment": + case "ManaSourceSelection": case "PhyrexianPayment": case "PayCost": case "PayManaAbilityMana": diff --git a/client/src/hooks/__tests__/useKeyboardShortcuts.test.tsx b/client/src/hooks/__tests__/useKeyboardShortcuts.test.tsx index dce6c74109..9f3d26bcbc 100644 --- a/client/src/hooks/__tests__/useKeyboardShortcuts.test.tsx +++ b/client/src/hooks/__tests__/useKeyboardShortcuts.test.tsx @@ -132,6 +132,7 @@ describe("useKeyboardShortcuts", () => { source: { object_id: objectId, incarnation: 1 }, ability_index: null, mana_type: "Green", + output: { type: "Concrete", data: "Green" }, atomic_combination: null, restrictions: [], penalty: "None", diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index fb7f3e5988..fdba287aa7 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -5,6 +5,7 @@ "cantActivateDuring": "Kann derzeit nicht aktiviert werden", "prohibited": "Das Aktivieren dieser Fähigkeit ist verboten" }, + "manaSourceSelection": { "title": "Manaquelle wählen", "description": "Diese Manafähigkeit erfordert das Opfern einer bleibenden Karte.", "sacrifice": "Opfern", "back": "Zurück zur Manabezahlung", "unknownSource": "Manaquelle" }, "comboShortcut": { "declareTitle": "Loop Shortcut", "declareSubtitle": "You control a loop with a determined outcome. Take the shortcut to resolve it.", diff --git a/client/src/i18n/locales/de/settings.json b/client/src/i18n/locales/de/settings.json index 70847ac630..d5699bd6af 100644 --- a/client/src/i18n/locales/de/settings.json +++ b/client/src/i18n/locales/de/settings.json @@ -29,6 +29,7 @@ "logDefault": "Protokoll-Standard", "spellPayment": "Zauberspruch-Bezahlung", "manualManaPayment": "Manuelle Manabezahlung für Zaubersprüche", + "spellPaymentOptions": { "auto": "Automatisch", "autoExceptSacrificialMana": "Vor dem Opfern fragen", "manual": "Manuell" }, "nativeEngine": "Native Engine für lokale Spiele und gehostete Mehrspielerpartien verwenden", "nativeEngineDescription": "Verwende die heruntergeladene Engine für lokale KI-Spiele und Spiele, die du über eine Lobby hostest. Gäste verbinden sich weiterhin über die Peer-to-Peer-Verbindung der Lobby. Fällt auf die integrierte Engine zurück, wenn sie nicht verfügbar ist.", "boardBackground": "Spielfeldhintergrund", diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index 2276c376b6..d22455282f 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -5,6 +5,13 @@ "cantActivateDuring": "Can't be activated right now", "prohibited": "Activating this ability is prohibited" }, + "manaSourceSelection": { + "title": "Choose a mana source", + "description": "This mana ability requires sacrificing a permanent.", + "sacrifice": "Sacrifice", + "back": "Back to mana payment", + "unknownSource": "Mana source" + }, "comboShortcut": { "declareTitle": "Loop Shortcut", "declareSubtitle": "You control a loop with a determined outcome. Take the shortcut to resolve it.", diff --git a/client/src/i18n/locales/en/settings.json b/client/src/i18n/locales/en/settings.json index c24b30e2a7..c8dd7ccf2e 100644 --- a/client/src/i18n/locales/en/settings.json +++ b/client/src/i18n/locales/en/settings.json @@ -29,6 +29,11 @@ "logDefault": "Log Default", "spellPayment": "Spell Payment", "manualManaPayment": "Manual mana payment for spells", + "spellPaymentOptions": { + "auto": "Automatic", + "autoExceptSacrificialMana": "Ask before sacrificing", + "manual": "Manual" + }, "nativeEngine": "Use native engine for local games and hosted multiplayer", "nativeEngineDescription": "Use the downloaded engine for local AI games and games you host through a lobby. Guests still connect through the lobby's peer-to-peer connection. Falls back to the built-in engine when unavailable.", "boardBackground": "Board Background", diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index 3d15c692c8..26e0897409 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -5,6 +5,7 @@ "cantActivateDuring": "No puede activarse ahora", "prohibited": "Activar esta habilidad está prohibido" }, + "manaSourceSelection": { "title": "Elige una fuente de maná", "description": "Esta habilidad de maná requiere sacrificar un permanente.", "sacrifice": "Sacrificar", "back": "Volver al pago de maná", "unknownSource": "Fuente de maná" }, "comboShortcut": { "declareTitle": "Loop Shortcut", "declareSubtitle": "You control a loop with a determined outcome. Take the shortcut to resolve it.", diff --git a/client/src/i18n/locales/es/settings.json b/client/src/i18n/locales/es/settings.json index 56d809adff..e23ea850ed 100644 --- a/client/src/i18n/locales/es/settings.json +++ b/client/src/i18n/locales/es/settings.json @@ -29,6 +29,7 @@ "logDefault": "Registro predeterminado", "spellPayment": "Pago de hechizos", "manualManaPayment": "Pago manual de maná para hechizos", + "spellPaymentOptions": { "auto": "Automático", "autoExceptSacrificialMana": "Preguntar antes de sacrificar", "manual": "Manual" }, "nativeEngine": "Usar el motor nativo para partidas locales y multijugador alojado", "nativeEngineDescription": "Usa el motor descargado para partidas locales contra IA y partidas que alojes mediante una sala. Los invitados siguen conectándose mediante la conexión punto a punto de la sala. Usa el motor integrado cuando no está disponible.", "boardBackground": "Fondo del tablero", diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index 4435e8d67d..af9fcad115 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -5,6 +5,7 @@ "cantActivateDuring": "Ne peut pas être activée pour l'instant", "prohibited": "L'activation de cette capacité est interdite" }, + "manaSourceSelection": { "title": "Choisir une source de mana", "description": "Cette capacité de mana demande de sacrifier un permanent.", "sacrifice": "Sacrifier", "back": "Retour au paiement de mana", "unknownSource": "Source de mana" }, "comboShortcut": { "declareTitle": "Loop Shortcut", "declareSubtitle": "You control a loop with a determined outcome. Take the shortcut to resolve it.", diff --git a/client/src/i18n/locales/fr/settings.json b/client/src/i18n/locales/fr/settings.json index f3c25aaf37..35f06d13eb 100644 --- a/client/src/i18n/locales/fr/settings.json +++ b/client/src/i18n/locales/fr/settings.json @@ -29,6 +29,7 @@ "logDefault": "Journal par défaut", "spellPayment": "Paiement des sorts", "manualManaPayment": "Paiement manuel du mana pour les sorts", + "spellPaymentOptions": { "auto": "Automatique", "autoExceptSacrificialMana": "Demander avant de sacrifier", "manual": "Manuel" }, "nativeEngine": "Utiliser le moteur natif pour les parties locales et le multijoueur hébergé", "nativeEngineDescription": "Utilisez le moteur téléchargé pour les parties locales contre l’IA et les parties que vous hébergez via un salon. Les invités se connectent toujours via la connexion pair à pair du salon. Utilise le moteur intégré lorsqu’il n’est pas disponible.", "boardBackground": "Arrière-plan du plateau", diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index 30a3c768c1..24aff1b0f9 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -5,6 +5,7 @@ "cantActivateDuring": "Non può essere attivata ora", "prohibited": "L'attivazione di questa abilità è proibita" }, + "manaSourceSelection": { "title": "Scegli una fonte di mana", "description": "Questa abilità di mana richiede di sacrificare un permanente.", "sacrifice": "Sacrifica", "back": "Torna al pagamento del mana", "unknownSource": "Fonte di mana" }, "comboShortcut": { "declareTitle": "Loop Shortcut", "declareSubtitle": "You control a loop with a determined outcome. Take the shortcut to resolve it.", diff --git a/client/src/i18n/locales/it/settings.json b/client/src/i18n/locales/it/settings.json index 0f969c6722..ab047477e3 100644 --- a/client/src/i18n/locales/it/settings.json +++ b/client/src/i18n/locales/it/settings.json @@ -29,6 +29,7 @@ "logDefault": "Registro predefinito", "spellPayment": "Pagamento magie", "manualManaPayment": "Pagamento manuale del mana per le magie", + "spellPaymentOptions": { "auto": "Automatico", "autoExceptSacrificialMana": "Chiedi prima di sacrificare", "manual": "Manuale" }, "nativeEngine": "Usa il motore nativo per partite locali e multiplayer ospitato", "nativeEngineDescription": "Usa il motore scaricato per le partite locali contro l'IA e le partite che ospiti tramite una lobby. Gli ospiti continuano a connettersi tramite la connessione peer-to-peer della lobby. Usa il motore integrato quando non è disponibile.", "boardBackground": "Sfondo del campo", diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index c7acb281b3..1b28510c77 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -5,6 +5,7 @@ "cantActivateDuring": "Nie można teraz aktywować", "prohibited": "Aktywacja tej zdolności jest zabroniona" }, + "manaSourceSelection": { "title": "Wybierz źródło many", "description": "Ta zdolność many wymaga poświęcenia permanenta.", "sacrifice": "Poświęć", "back": "Wróć do płatności many", "unknownSource": "Źródło many" }, "comboShortcut": { "declareTitle": "Loop Shortcut", "declareSubtitle": "You control a loop with a determined outcome. Take the shortcut to resolve it.", diff --git a/client/src/i18n/locales/pl/settings.json b/client/src/i18n/locales/pl/settings.json index 969d3dd454..1f3db39bf0 100644 --- a/client/src/i18n/locales/pl/settings.json +++ b/client/src/i18n/locales/pl/settings.json @@ -29,6 +29,7 @@ "logDefault": "Domyślny dziennik", "spellPayment": "Płatność za czary", "manualManaPayment": "Ręczna płatność many za czary", + "spellPaymentOptions": { "auto": "Automatycznie", "autoExceptSacrificialMana": "Pytaj przed poświęceniem", "manual": "Ręcznie" }, "nativeEngine": "Używaj natywnego silnika w lokalnych grach i hostowanym trybie wieloosobowym", "nativeEngineDescription": "Używaj pobranego silnika do lokalnych gier z AI i gier hostowanych przez lobby. Goście nadal łączą się przez połączenie peer-to-peer lobby. Gdy silnik jest niedostępny, używany jest wbudowany silnik.", "boardBackground": "Tło pola bitwy", diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index be12235a48..71fab73662 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -5,6 +5,7 @@ "cantActivateDuring": "Não pode ser ativada agora", "prohibited": "Ativar esta habilidade é proibido" }, + "manaSourceSelection": { "title": "Escolha uma fonte de mana", "description": "Esta habilidade de mana exige sacrificar uma permanente.", "sacrifice": "Sacrificar", "back": "Voltar ao pagamento de mana", "unknownSource": "Fonte de mana" }, "comboShortcut": { "declareTitle": "Loop Shortcut", "declareSubtitle": "You control a loop with a determined outcome. Take the shortcut to resolve it.", diff --git a/client/src/i18n/locales/pt/settings.json b/client/src/i18n/locales/pt/settings.json index 508924e125..a7d3cac0ca 100644 --- a/client/src/i18n/locales/pt/settings.json +++ b/client/src/i18n/locales/pt/settings.json @@ -29,6 +29,7 @@ "logDefault": "Registro Padrão", "spellPayment": "Pagamento de Mágicas", "manualManaPayment": "Pagamento manual de mana para mágicas", + "spellPaymentOptions": { "auto": "Automático", "autoExceptSacrificialMana": "Perguntar antes de sacrificar", "manual": "Manual" }, "nativeEngine": "Usar o motor nativo para partidas locais e multijogador hospedado", "nativeEngineDescription": "Use o motor baixado para partidas locais contra IA e partidas hospedadas por você em um lobby. Os convidados continuam a se conectar pela conexão ponto a ponto do lobby. Usa o motor integrado quando não está disponível.", "boardBackground": "Plano de Fundo do Tabuleiro", diff --git a/client/src/network/__tests__/protocol.test.ts b/client/src/network/__tests__/protocol.test.ts index 8ba5036ed3..af7a4ad0e7 100644 --- a/client/src/network/__tests__/protocol.test.ts +++ b/client/src/network/__tests__/protocol.test.ts @@ -37,7 +37,7 @@ const viewerInteractionWithProducedMana = { describe("encodeWireMessage / decodeWireMessage", () => { it("pins the P2P wire protocol to v16", () => { - expect(WIRE_PROTOCOL_VERSION).toBe(16); + expect(WIRE_PROTOCOL_VERSION).toBe(17); }); it("defaults shortcut actions for a legacy payload created before the additive field", () => { diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 1ed4258c2e..4288b9f8c2 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -80,6 +80,7 @@ export function legalActionsFromWire(wire: LegalActionsWire): LegalActionsResult * 4 — Archenemy derived view and scheme deck payloads * 5 — CardPredicateGuessMade game event shape * 13 — Actor-scoped priority-passing settings and filtered per-player state. + * 17 — Sacrificial-mana source selection action and waiting-state snapshots. * 12 — Connive exact subject snapshots and resident paused post-replacement * drains changed P2P GameState snapshots. * 11 — Serialized GameState trigger provenance and paused logical zone-change owners. @@ -91,7 +92,7 @@ export function legalActionsFromWire(wire: LegalActionsWire): LegalActionsResult * sub-phase on WaitingFor::MulliganDecision; the MulliganBottomCards * variant was removed */ -export const WIRE_PROTOCOL_VERSION = 16 as const; +export const WIRE_PROTOCOL_VERSION = 17 as const; export type P2PMessage = | { type: "guest_deck"; deckData: unknown; displayName?: string; reservationToken?: string } diff --git a/client/src/pages/GamePage.tsx b/client/src/pages/GamePage.tsx index 4564b8d73a..37a9084642 100644 --- a/client/src/pages/GamePage.tsx +++ b/client/src/pages/GamePage.tsx @@ -71,7 +71,7 @@ import { HelpSheet } from "../components/help/HelpSheet.tsx"; import { GameLogPanel } from "../components/log/GameLogPanel.tsx"; import { ChooseXValueUI } from "../components/mana/ChooseXValueUI.tsx"; import { AssistPaymentUI } from "../components/mana/AssistPaymentUI.tsx"; -import { ManaPaymentUI } from "../components/mana/ManaPaymentUI.tsx"; +import { ManaPaymentUI, ManaSourceSelectionUI } from "../components/mana/ManaPaymentUI.tsx"; import { PayAmountChoiceUI } from "../components/mana/PayAmountChoiceUI.tsx"; import { RichLabel } from "../components/mana/RichLabel.tsx"; import { CardDataMissingModal } from "../components/modal/CardDataMissingModal.tsx"; @@ -1770,6 +1770,8 @@ function GamePageContent({ {waitingFor != null && MANA_PAYMENT_WAITING_FOR_TYPES.has(waitingFor.type) && canActForWaitingState && } + {waitingFor?.type === "ManaSourceSelection" && + canActForWaitingState && } {waitingFor?.type === "ChooseXValue" && canActForWaitingState && } {waitingFor?.type === "PayAmountChoice" && diff --git a/client/src/stores/preferencesStore.ts b/client/src/stores/preferencesStore.ts index 3e81640d5f..31beb1c239 100644 --- a/client/src/stores/preferencesStore.ts +++ b/client/src/stores/preferencesStore.ts @@ -89,7 +89,7 @@ export type CommandZoneDisplay = "compact" | "inline" | "auto"; * tri-state "auto" precedent. Lands and support each carry their own value. */ export type ZoneCollapseMode = "auto" | "on" | "off"; export type TapRotation = "mtga" | "classic"; -export type SpellPaymentMode = "auto" | "manual"; +export type SpellPaymentMode = "auto" | "autoExceptSacrificialMana" | "manual"; /** Which screen edge the resolving-stack panel docks to (and collapses toward). * User-chosen so a player can keep the stack off whichever side of the * battlefield they care about — e.g. dock left to free the right action rail. */ @@ -785,7 +785,7 @@ export const usePreferencesStore = create }), { name: "phase-preferences", - version: 28, + version: 29, // v0 → v1: flat aiDifficulty + aiDeckName become aiSeats[0]. // v1 → v2: discrete animationSpeed/combatPacing enums become numeric // animationSpeedMultiplier/combatPacingMultiplier. @@ -845,6 +845,9 @@ export const usePreferencesStore = create // preserves the intended enabled-by-default behavior. // v27 → v28: Add showCardPreviewFooter; legacy stores default to true // via the shallow merge, preserving the prior presentation. + // v28 → v29: Add the sacrificial-mana-aware automatic mode. Existing + // values remain valid; malformed persisted values normalize to + // the legacy automatic behavior below. migrate: (persisted: unknown, version: number) => { if (!persisted || typeof persisted !== "object") return persisted; let migrated = persisted as Record; @@ -938,6 +941,17 @@ export const usePreferencesStore = create migrated = { ...migrated, spellPaymentMode: "auto" }; } + if (version < 29) { + const mode = (migrated as { spellPaymentMode?: unknown }).spellPaymentMode; + migrated = { + ...migrated, + spellPaymentMode: + mode === "auto" || mode === "autoExceptSacrificialMana" || mode === "manual" + ? mode + : "auto", + }; + } + if (version < 9) { const lng = (migrated as { language?: unknown }).language; migrated = { diff --git a/client/src/viewmodel/__tests__/cardActionChoice.test.ts b/client/src/viewmodel/__tests__/cardActionChoice.test.ts index 869935a1a9..484728f715 100644 --- a/client/src/viewmodel/__tests__/cardActionChoice.test.ts +++ b/client/src/viewmodel/__tests__/cardActionChoice.test.ts @@ -66,6 +66,7 @@ function tapLandAction(objectId: number): Extract mana_payment_actions(state, *player, *convoke_mode), + WaitingFor::ManaSourceSelection { + player, options, .. + } => { + let mut actions = options + .iter() + .cloned() + .map(|selection| { + candidate( + GameAction::ActivateManaSource { selection }, + TacticalClass::Mana, + Some(*player), + ) + }) + .collect::>(); + actions.push(candidate( + GameAction::BackToManaPayment, + TacticalClass::Pass, + Some(*player), + )); + actions + } WaitingFor::MoveCountersDistribution { player, available, @@ -3362,7 +3383,10 @@ fn semantic_candidate_actions_with_probe( let has_pending_cast = state.waiting_for.has_pending_cast() || (matches!(state.waiting_for, WaitingFor::DistributeAmong { .. }) && state.pending_cast.is_some()); - if has_pending_cast { + let allows_cancel_cast = state.waiting_for.allows_cancel_cast() + || (matches!(state.waiting_for, WaitingFor::DistributeAmong { .. }) + && state.pending_cast.is_some()); + if has_pending_cast && allows_cancel_cast { if let Some(player) = state.waiting_for.acting_player() { actions.push(candidate( GameAction::CancelCast, diff --git a/crates/engine/src/ai_support/filter.rs b/crates/engine/src/ai_support/filter.rs index e833014569..f3b72621c6 100644 --- a/crates/engine/src/ai_support/filter.rs +++ b/crates/engine/src/ai_support/filter.rs @@ -1219,7 +1219,7 @@ fn legality_equivalence_key( key.is_blocked = crate::game::restrictions::is_source_blocked(state, *source_id); Some(key) } - GameAction::TapLandForMana { selection } => { + GameAction::TapLandForMana { selection } | GameAction::ActivateManaSource { selection } => { if poison.has_activation { return None; } diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index f27f8d2e27..1c9d905387 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -188,6 +188,16 @@ fn cheap_reject_candidate(state: &GameState, action: &GameAction) -> bool { }, }, ) + | ( + WaitingFor::Priority { .. }, + GameAction::ActivateManaSource { + selection: + crate::types::mana::ManaSourceSelection { + source: crate::types::identifiers::ObjectIncarnationRef { object_id, .. }, + .. + }, + }, + ) | (WaitingFor::Priority { .. }, GameAction::UntapLandForMana { object_id }) | ( WaitingFor::Priority { .. }, @@ -1010,6 +1020,18 @@ fn grouped_mana_requires_priority(state: &GameState, player: PlayerId) -> bool { &aura_sources, &mana_activation_gates, ), + GameAction::ActivateManaSource { selection } => { + selection.ability_index.is_some_and(|ability_index| { + activate_mana_action_would_queue_non_mana_trigger( + state, + player, + selection.source.object_id, + ability_index, + &aura_sources, + &mana_activation_gates, + ) + }) + } _ => false, }) } @@ -1084,6 +1106,8 @@ fn classify_flat_priority_action(action: &GameAction) -> FlatPriorityActionClass | GameAction::MulliganDecision { .. } | GameAction::ReorderHand { .. } | GameAction::TapLandForMana { .. } + | GameAction::ActivateManaSource { .. } + | GameAction::BackToManaPayment | GameAction::UntapLandForMana { .. } | GameAction::SpendPoolMana { .. } | GameAction::UnspendPoolMana { .. } @@ -1436,6 +1460,21 @@ fn beneficial_mana_tap_trigger_hold( } *source_id } + GameAction::ActivateManaSource { selection } => { + let Some(ability_index) = selection.ability_index else { + return false; + }; + let object_id = selection.source.object_id; + let has_tap = state + .objects + .get(&object_id) + .and_then(|obj| obj.abilities.get(ability_index)) + .is_some_and(|ability| mana_sources::has_tap_component(&ability.cost)); + if !has_tap { + return false; + } + object_id + } _ => return false, }; diff --git a/crates/engine/src/ai_support/payment_continuation.rs b/crates/engine/src/ai_support/payment_continuation.rs index c102286df8..60cb96d141 100644 --- a/crates/engine/src/ai_support/payment_continuation.rs +++ b/crates/engine/src/ai_support/payment_continuation.rs @@ -92,7 +92,9 @@ pub fn classify_payment_continuation(state: &GameState) -> PaymentContinuationSt match &state.waiting_for { // CR 601.2g–h: during the ordinary mana-payment window, the visible // payer and live pending cast jointly identify the payment root. - WaitingFor::ManaPayment { player, .. } => classify_global_root(state, *player), + WaitingFor::ManaPayment { player, .. } | WaitingFor::ManaSourceSelection { player, .. } => { + classify_global_root(state, *player) + } // CR 601.2f–h: submitting Phyrexian choices remains part of the same // cost payment. The prompt's object must agree with the announced root. WaitingFor::PhyrexianPayment { @@ -442,6 +444,15 @@ fn classify_deferred_life_root( PaymentContinuationUnsupported::PayerMismatch, ) } + ManaAbilityResume::ManaSourceSelection { + player: selection_player, + .. + } if selection_player == player => classify_global_root(state, *player), + ManaAbilityResume::ManaSourceSelection { .. } => { + PaymentContinuationState::UnsupportedAffiliated( + PaymentContinuationUnsupported::PayerMismatch, + ) + } ManaAbilityResume::PhyrexianCastPayment { .. } | ManaAbilityResume::FinalizePendingManaPayment { .. } => { PaymentContinuationState::UnsupportedAffiliated( @@ -532,6 +543,9 @@ fn record_root_from_resume( ManaAbilityResume::ManaPayment { outer_player: None, .. } => return Err(PaymentContinuationUnsupported::MissingOuterPayer), + ManaAbilityResume::ManaSourceSelection { player, .. } => { + Some(root_from_global(state, *player)?) + } ManaAbilityResume::PhyrexianCastPayment { caster, .. } => { Some(root_from_global(state, *caster)?) } diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index a932575ca3..7b567be050 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -15060,6 +15060,7 @@ fn auto_tap_and_pay_cost_excluding( Some(source_id), ctx, excluded_sources, + None, resume, parent, ); diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index fb10c8f5a8..3a7eaf1e3e 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -8654,6 +8654,26 @@ pub(super) fn pay_and_push_adventure( } state.pending_cast = Some(Box::new(pending)); + if payment_mode == CastPaymentMode::AutoExceptSacrificialMana { + auto_tap_non_sacrificial_mana_sources(state, player, cost, events, object_id); + if pending_cost_is_payable_from_pool(state, player) { + return finalize_automatic_mana_payment(state, player, events); + } + let options = super::mana_sources::activatable_mana_source_selections(state, player) + .into_iter() + .filter(|selection| { + selection.penalty == super::mana_sources::ManaSourcePenalty::Sacrifices + }) + .collect::>(); + if options.is_empty() { + return enter_payment_step(state, player, None, events); + } + return Ok(WaitingFor::ManaSourceSelection { + player, + options, + convoke_mode: None, + }); + } finalize_automatic_mana_payment(state, player, events) } @@ -10375,6 +10395,7 @@ pub(super) fn auto_tap_mana_sources_excluding( None, None, None, + None, ); } @@ -10416,6 +10437,7 @@ pub(super) fn auto_tap_mana_sources_with_context_and_resume( deprioritize_source, payment_context, &HashSet::new(), + None, resume, None, ); @@ -10432,6 +10454,7 @@ pub(super) fn auto_tap_mana_sources_with_context_excluding_and_resume( deprioritize_source: Option, payment_context: Option<&PaymentContext<'_>>, excluded_sources: &HashSet, + excluded_penalty: Option, resume: Option<&ManaAbilityResume>, parent: Option<&ManaAbilityCostParent>, ) { @@ -10442,6 +10465,7 @@ pub(super) fn auto_tap_mana_sources_with_context_excluding_and_resume( events, deprioritize_source, excluded_sources, + excluded_penalty, payment_context, None, None, @@ -10466,6 +10490,7 @@ pub(super) fn auto_tap_mana_sources_with_context_excluding( events, deprioritize_source, excluded_sources, + None, payment_context, None, None, @@ -10474,6 +10499,63 @@ pub(super) fn auto_tap_mana_sources_with_context_excluding( ); } +/// CR 601.2g-h + CR 605.3b: Apply the existing automatic planner while +/// excluding sacrificial activation rows. This is the safe first leg of +/// `AutoExceptSacrificialMana`; the caller retains the pending cast and offers +/// the excluded capabilities explicitly if this leg cannot finish payment. +pub(super) fn auto_tap_non_sacrificial_mana_sources( + state: &mut GameState, + player: PlayerId, + cost: &crate::types::mana::ManaCost, + events: &mut Vec, + source_id: ObjectId, +) { + let spell_meta = super::casting::build_spell_meta(state, player, source_id); + let spell_ctx = spell_meta.as_ref().map(PaymentContext::Spell); + auto_tap_mana_sources_inner( + state, + player, + cost, + events, + Some(source_id), + &HashSet::new(), + Some(mana_sources::ManaSourcePenalty::Sacrifices), + spell_ctx.as_ref(), + None, + None, + None, + None, + ); +} + +pub(super) fn pending_cost_is_payable_from_pool(state: &GameState, player: PlayerId) -> bool { + let Some(pending) = state.pending_cast.as_deref() else { + return false; + }; + let spell_meta = super::casting::build_spell_meta(state, player, pending.object_id); + let spell_ctx = spell_meta.as_ref().map(PaymentContext::Spell); + let any_color = super::casting::player_can_spend_as_any_color_for_payment( + state, + player, + Some(pending.object_id), + spell_ctx.as_ref(), + ); + let permissions = + super::static_abilities::build_cost_permission_context(state, player, any_color); + state + .players + .iter() + .find(|candidate| candidate.id == player) + .is_some_and(|candidate| { + mana_payment::can_pay_for_spell( + &candidate.mana_pool, + &pending.cost, + spell_ctx.as_ref(), + permissions, + ) + }) +} + #[derive(Debug, Clone)] pub(super) struct AutoTapSourceCache { player: PlayerId, @@ -10503,7 +10585,7 @@ pub(super) fn build_auto_tap_source_cache( crate::game::perf_counters::record_auto_tap_source_cache_build(); AutoTapSourceCache { player, - sources: collect_sorted_auto_tap_source_options(state, player, None, &HashSet::new()), + sources: collect_sorted_auto_tap_source_options(state, player, None, &HashSet::new(), None), } } @@ -10525,6 +10607,7 @@ pub(super) fn auto_tap_mana_sources_with_context_excluding_cached( events, deprioritize_source, excluded_sources, + None, payment_context, None, source_cache, @@ -10538,6 +10621,7 @@ fn collect_sorted_auto_tap_source_options( player: PlayerId, deprioritize_source: Option, excluded_sources: &HashSet, + excluded_penalty: Option, ) -> Vec { use crate::types::card_type::{CoreType, Supertype}; @@ -10593,6 +10677,7 @@ fn collect_sorted_auto_tap_source_options( } }) .flatten() + .filter(|option| excluded_penalty.is_none_or(|penalty| option.penalty != penalty)) .collect(); // CR 605.3b: Auto-tap sort key. Tier layout (the enum factors the two @@ -10675,11 +10760,13 @@ fn cached_auto_tap_sources<'a>( player: PlayerId, deprioritize_source: Option, excluded_sources: &HashSet, + excluded_penalty: Option, sub_cost_demand: Option<&crate::game::mana_payment::ColorDemand>, ) -> Option<&'a [ManaSourceOption]> { let cache = source_cache?; if cache.is_for_player(player) && excluded_sources.is_empty() + && excluded_penalty.is_none() && sub_cost_demand.is_none() && deprioritize_source.is_none_or(|source_id| !cache.contains_source(source_id)) { @@ -10699,6 +10786,7 @@ fn auto_tap_mana_sources_inner( events: &mut Vec, deprioritize_source: Option, excluded_sources: &HashSet, + excluded_penalty: Option, payment_context: Option<&PaymentContext<'_>>, sub_cost_demand: Option<&crate::game::mana_payment::ColorDemand>, source_cache: Option<&AutoTapSourceCache>, @@ -10756,6 +10844,7 @@ fn auto_tap_mana_sources_inner( player, deprioritize_source, excluded_sources, + excluded_penalty, sub_cost_demand, ) { cached @@ -10765,6 +10854,7 @@ fn auto_tap_mana_sources_inner( player, deprioritize_source, excluded_sources, + excluded_penalty, ); &available_buf }; @@ -11111,6 +11201,7 @@ fn auto_tap_mana_sources_inner( events, Some(option.object_id), excluded, + excluded_penalty, Some(&activation_ctx), demand.as_ref(), None, @@ -12871,6 +12962,7 @@ pub(super) fn maybe_pause_for_phyrexian_choice( Some(source_id), payment_context, excluded_sources, + None, resume, None, ); diff --git a/crates/engine/src/game/costs.rs b/crates/engine/src/game/costs.rs index 5174665a0a..48dfdb0649 100644 --- a/crates/engine/src/game/costs.rs +++ b/crates/engine/src/game/costs.rs @@ -423,6 +423,11 @@ fn effect_pay_cost_mana_resume( if let WaitingFor::ManaPayment { player, convoke_mode, + } + | WaitingFor::ManaSourceSelection { + player, + convoke_mode, + .. } = &state.waiting_for { return Some(ManaAbilityResume::ManaPayment { diff --git a/crates/engine/src/game/derived.rs b/crates/engine/src/game/derived.rs index 314a726d0d..f353b01279 100644 --- a/crates/engine/src/game/derived.rs +++ b/crates/engine/src/game/derived.rs @@ -289,6 +289,9 @@ pub fn derive_display_state(state: &mut GameState) { state.has_pending_cast = state.waiting_for.has_pending_cast() || (matches!(state.waiting_for, WaitingFor::DistributeAmong { .. }) && state.pending_cast.is_some()); + state.allows_cancel_cast = state.waiting_for.allows_cancel_cast() + || (matches!(state.waiting_for, WaitingFor::DistributeAmong { .. }) + && state.pending_cast.is_some()); // Invariant: the two storage sites for "am I mid-cast" must agree. If // `waiting_for` says we're mid-cast, `GameState::pending_cast` must be diff --git a/crates/engine/src/game/effects/prepare.rs b/crates/engine/src/game/effects/prepare.rs index 9998257446..de3bbc98a4 100644 --- a/crates/engine/src/game/effects/prepare.rs +++ b/crates/engine/src/game/effects/prepare.rs @@ -344,7 +344,9 @@ fn mark_prepare_copy_cancel_rollback( if matches!( waiting, - WaitingFor::ManaPayment { .. } | WaitingFor::PhyrexianPayment { .. } + WaitingFor::ManaPayment { .. } + | WaitingFor::ManaSourceSelection { .. } + | WaitingFor::PhyrexianPayment { .. } ) { if let Some(pending) = state.pending_cast.as_mut() { debug_assert_eq!( diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 7ff86d769a..d92b89085d 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -4477,6 +4477,48 @@ fn apply_action( ); waiting_for } + (WaitingFor::Priority { player }, GameAction::ActivateManaSource { selection }) => { + if state.priority_player + != turn_control::authorized_submitter_for_player(state, *player) + { + return Err(EngineError::NotYourPriority); + } + if state + .objects + .get(&selection.source.object_id) + .is_some_and(|object| { + object + .card_types + .core_types + .contains(&crate::types::card_type::CoreType::Land) + }) + { + return Err(EngineError::ActionNotAllowed( + "Land mana abilities use TapLandForMana".to_string(), + )); + } + let events_before = events.len(); + let waiting_for = mana_sources::activate_mana_source_selection( + state, + *player, + &selection, + &mut events, + ManaAbilityResume::Priority, + )?; + triggers::resolve_tap_mana_triggers_inline(state, &mut events, events_before); + if let Some(ability_index) = selection.ability_index { + record_mana_loop_action_step( + state, + *player, + selection.source.object_id, + crate::types::game_state::LoopAction::Activate { + source_id: selection.source.object_id, + ability_index, + }, + ); + } + waiting_for + } (WaitingFor::Priority { player }, GameAction::UntapLandForMana { object_id }) => { if state.priority_player != turn_control::authorized_submitter_for_player(state, *player) @@ -6055,6 +6097,49 @@ fn apply_action( None => WaitingFor::Priority { player }, } } + ( + WaitingFor::ManaSourceSelection { + player, + options, + convoke_mode, + }, + GameAction::BackToManaPayment, + ) => { + // The selection window never consumes mana or changes pins. Restore + // the exact payment state rather than re-running the planner. + let _ = options; + WaitingFor::ManaPayment { + player: *player, + convoke_mode: *convoke_mode, + } + } + ( + WaitingFor::ManaSourceSelection { + player, + options, + convoke_mode, + }, + GameAction::ActivateManaSource { selection }, + ) => { + if !options.contains(&selection) { + return Err(EngineError::ActionNotAllowed( + "Mana source was not offered for this payment".to_string(), + )); + } + let events_before = events.len(); + let waiting_for = mana_sources::activate_mana_source_selection( + state, + *player, + &selection, + &mut events, + ManaAbilityResume::ManaPayment { + outer_player: Some(*player), + convoke_mode: *convoke_mode, + }, + )?; + triggers::resolve_tap_mana_triggers_inline(state, &mut events, events_before); + waiting_for + } (WaitingFor::ChooseXValue { player, .. }, GameAction::CancelCast) => { // CR 601.2f + CR 601.2i: Caster may back out before committing to an // X value. Pop the stack entry placed at announcement and restore. diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index 68a1a2b932..b7826e2004 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -248,7 +248,9 @@ fn human_response_model(waiting_for: &WaitingFor, semantic_owner: PlayerId) -> H | WaitingFor::CommanderZoneChoice { .. } | WaitingFor::UntapChoice { .. } => HumanResponseModel::DirectChoices, WaitingFor::BetweenGamesSideboard { .. } => HumanResponseModel::SideboardPartition, - WaitingFor::ManaPayment { .. } => HumanResponseModel::DirectChoices, + WaitingFor::ManaPayment { .. } | WaitingFor::ManaSourceSelection { .. } => { + HumanResponseModel::DirectChoices + } WaitingFor::LoopShortcut { .. } => HumanResponseModel::LoopShortcut, WaitingFor::Priority { .. } | WaitingFor::MeldPairChoice { .. } @@ -334,6 +336,7 @@ fn classify_waiting_for(waiting_for: &WaitingFor) -> WaitingClassification { Some(InteractionSlotKind::OpeningBottom), ), WaitingFor::ManaPayment { .. } + | WaitingFor::ManaSourceSelection { .. } | WaitingFor::AssistPayment { .. } | WaitingFor::DefilerPayment { .. } | WaitingFor::UnlessPayment { .. } @@ -2008,6 +2011,20 @@ fn direct_choice_projection( } mana_payment_direct_actions(state, *player, *convoke_mode)? } + WaitingFor::ManaSourceSelection { + player, options, .. + } => { + if *player != semantic_owner { + return Err(InteractionReasonCode::InvalidAuthorityState); + } + let mut actions = options + .iter() + .cloned() + .map(|selection| GameAction::ActivateManaSource { selection }) + .collect::>(); + actions.push(GameAction::BackToManaPayment); + actions + } WaitingFor::PrecastCopyShortcutOffer { epoch, route_count, .. } => { @@ -3351,6 +3368,7 @@ fn selection_projection( | WaitingFor::MeldPairChoice { .. } | WaitingFor::MeldAttackTargetChoice { .. } | WaitingFor::ManaPayment { .. } + | WaitingFor::ManaSourceSelection { .. } | WaitingFor::AssistChoosePlayer { .. } | WaitingFor::AssistPayment { .. } | WaitingFor::ChooseXValue { .. } @@ -3873,6 +3891,7 @@ fn object_property_code(property: ObjectProperty) -> InteractionObjectProperty { fn cast_payment_mode_code(mode: CastPaymentMode) -> &'static str { match mode { CastPaymentMode::Auto => "auto", + CastPaymentMode::AutoExceptSacrificialMana => "autoExceptSacrificialMana", CastPaymentMode::Manual => "manual", } } @@ -4146,6 +4165,7 @@ fn project_action_payload( match action { GameAction::PassPriority | GameAction::CancelCast + | GameAction::BackToManaPayment | GameAction::KeepAllCopyTargets | GameAction::RollPlanarDie | GameAction::CompanionToHand @@ -4157,7 +4177,7 @@ fn project_action_payload( GameAction::ChooseEntryAttackTarget { target } => { push_attack_target_surface(surfaces, state, target, InteractionRoleCode::AttackTarget) } - GameAction::TapLandForMana { selection } => { + GameAction::TapLandForMana { selection } | GameAction::ActivateManaSource { selection } => { let Some(player) = state .objects .get(&selection.source.object_id) @@ -4166,7 +4186,7 @@ fn project_action_payload( return; }; let Ok(option) = - mana_sources::live_land_mana_option_for_selection(state, player, selection) + mana_sources::live_mana_source_option_for_selection(state, player, selection) else { return; }; @@ -4969,6 +4989,8 @@ fn action_code(action: &GameAction) -> InteractionActionCode { GameAction::MulliganDecision { .. } => InteractionActionCode::MulliganDecision, GameAction::ReorderHand { .. } => InteractionActionCode::ReorderHand, GameAction::TapLandForMana { .. } => InteractionActionCode::TapLandForMana, + GameAction::ActivateManaSource { .. } => InteractionActionCode::ActivateManaSource, + GameAction::BackToManaPayment => InteractionActionCode::BackToManaPayment, GameAction::UntapLandForMana { .. } => InteractionActionCode::UntapLandForMana, GameAction::SpendPoolMana { .. } => InteractionActionCode::SpendPoolMana, GameAction::UnspendPoolMana { .. } => InteractionActionCode::UnspendPoolMana, @@ -5137,7 +5159,7 @@ fn actor_candidates( .filter_map(|candidate| { let mut manual = candidate.clone(); let payment_mode = manual.action.payment_mode_mut()?; - if *payment_mode != CastPaymentMode::Auto { + if *payment_mode == CastPaymentMode::Manual { return None; } *payment_mode = CastPaymentMode::Manual; diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index 5795d85d37..941815d24a 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -2752,6 +2752,18 @@ pub(crate) fn finish_mana_root_after_deferred_life_payment( convoke_mode, }, )), + ManaAbilityResume::ManaSourceSelection { + player, + options, + convoke_mode, + } => Ok(resume_waiting_for( + player, + ManaAbilityResume::ManaSourceSelection { + player, + options, + convoke_mode, + }, + )), ManaAbilityResume::UnlessPayment { outer_player, cost, @@ -4152,6 +4164,15 @@ pub(crate) fn resume_waiting_for( player: outer_player.unwrap_or(mana_source_controller), convoke_mode, }, + ManaAbilityResume::ManaSourceSelection { + player, + options, + convoke_mode, + } => WaitingFor::ManaSourceSelection { + player, + options, + convoke_mode, + }, ManaAbilityResume::UnlessPayment { outer_player, cost, diff --git a/crates/engine/src/game/mana_sources.rs b/crates/engine/src/game/mana_sources.rs index e9370ebee7..aeabe1a72a 100644 --- a/crates/engine/src/game/mana_sources.rs +++ b/crates/engine/src/game/mana_sources.rs @@ -26,8 +26,8 @@ use crate::types::events::{GameEvent, ManaTapState}; use crate::types::game_state::{GameState, ManaAbilityResume, ProductionOverride, WaitingFor}; use crate::types::identifiers::ObjectId; use crate::types::mana::{ - ManaColor, ManaCostShard, ManaPip, ManaRestriction, ManaSourceSelection, ManaType, - PaymentContext, TapsForManaSelection, + ManaColor, ManaCostShard, ManaPip, ManaRestriction, ManaSourceOutput, ManaSourceSelection, + ManaType, PaymentContext, TapsForManaSelection, }; use crate::types::player::PlayerId; use crate::types::zones::Zone; @@ -203,6 +203,7 @@ impl ManaSourceOption { source: crate::types::identifiers::ObjectIncarnationRef::from_object(source), ability_index: self.ability_index, mana_type: self.mana_type, + output: ManaSourceOutput::Concrete(self.mana_type), atomic_combination: self.atomic_combination.clone(), restrictions: self.restrictions.clone(), penalty: self.penalty, @@ -297,6 +298,10 @@ pub fn activatable_mana_actions_for_player(state: &GameState, player: PlayerId) &mana_activation_gates, ) { + // Interactive costs (for example, "Sacrifice an artifact") + // deliberately remain the ordinary activation action during a + // normal ManaPayment window. Its established cost resolver is + // the authority for the subsequent PayCost choice. actions.push(GameAction::ActivateAbility { source_id: object_id, ability_index, @@ -307,6 +312,181 @@ pub fn activatable_mana_actions_for_player(state: &GameState, player: PlayerId) actions } +/// CR 605.3a: Complete semantic capabilities for mana activation, across lands +/// and nonlands. This is intentionally separate from `GameAction` generation: +/// callers can freeze these engine-authored candidates in an interaction and +/// later require an exact fresh match before activation. +pub fn activatable_mana_source_selections( + state: &GameState, + player: PlayerId, +) -> Vec { + let aura_sources = taps_for_mana_trigger_sources(state); + let gates = mana_abilities::ManaActivationGates::compute(state); + let mut selections = Vec::new(); + + for &object_id in &state.battlefield { + let Some(object) = state.objects.get(&object_id) else { + continue; + }; + if object.controller != player { + continue; + } + + let options = current_mana_source_options(state, player, object_id, &aura_sources, &gates); + for option in options { + let Some(selection) = manual_selection_for_option(state, &option) else { + continue; + }; + if !selections.contains(&selection) { + selections.push(selection); + } + } + } + selections.sort_by(|left, right| left.cmp_stable(right)); + selections +} + +/// CR 605.3a: Return the source rows a frozen, explicit mana selection may +/// name. Auto-tap rows stay the base because they preserve land fallback and +/// tap-trigger metadata; then append every other currently legal activated +/// mana ability. Unlike the automatic planner, this includes interactive costs +/// such as "Sacrifice an artifact", because the selection is explicit and the +/// normal `PayCost` flow still resolves that cost after activation. +fn current_mana_source_options( + state: &GameState, + player: PlayerId, + object_id: ObjectId, + aura_sources: &[ObjectId], + gates: &mana_abilities::ManaActivationGates, +) -> Vec { + let Some(object) = state.objects.get(&object_id) else { + return Vec::new(); + }; + if object.zone != Zone::Battlefield || object.controller != player { + return Vec::new(); + } + + let mut options = if object.card_types.core_types.contains(&CoreType::Land) { + activatable_land_mana_options_indexed_gated(state, object_id, player, aura_sources, gates) + } else { + auto_tap_mana_options(state, object_id, player) + }; + + for (ability_index, ability) in object.abilities.iter().enumerate() { + if options + .iter() + .any(|option| option.ability_index == Some(ability_index)) + || ability.kind != AbilityKind::Activated + || !mana_abilities::is_mana_ability(ability) + || !mana_abilities::can_activate_mana_ability_now_gated( + state, + player, + object_id, + ability_index, + ability, + gates, + ) + || !activation_condition_satisfied(state, player, object_id, ability_index, ability) + { + continue; + } + + let penalty = object_mana_ability_penalty(state, object_id, ability); + let source_could_produce_two_or_more_colors = + source_could_produce_two_or_more_colors(state, object_id, player); + for row in emit_source_rows(state, player, object_id, ability_index, ability, true) { + let option = ManaSourceOption { + object_id, + ability_index: Some(ability_index), + mana_type: row.mana_type, + source_could_produce_two_or_more_colors, + penalty, + atomic_combination: row.atomic_combination, + restrictions: row.restrictions, + taps_for_mana_overrides: Vec::new(), + }; + if !options.contains(&option) { + options.push(option); + } + } + } + + options +} + +fn manual_selection_for_option( + state: &GameState, + option: &ManaSourceOption, +) -> Option { + let mut selection = option.semantic_selection(state)?; + let flexible_output = option.ability_index.is_some_and(|ability_index| { + state + .objects + .get(&option.object_id) + .and_then(|object| object.abilities.get(ability_index)) + .is_some_and(|ability| { + matches!( + &*ability.effect, + Effect::Mana { + produced: ManaProduction::AnyOneColor { .. } + | ManaProduction::AnyCombination { .. } + | ManaProduction::ChoiceAmongExiledColors { .. } + | ManaProduction::AnyOneColorAmongPermanents { .. } + | ManaProduction::OpponentLandColors { .. } + | ManaProduction::AnyTypeProduceableBy { .. } + | ManaProduction::AnyCombinationOfObjectColors { .. } + | ManaProduction::AnyInCommandersColorIdentity { .. }, + .. + } + ) + }) + }); + if flexible_output { + // The planner emits one concrete row per color, but a manual activation + // must retain the source capability and let the normal mana-choice + // resolver ask for its color. The colorless marker is intentionally + // inert while `output` is deferred and canonicalizes all planner rows. + selection.mana_type = ManaType::Colorless; + selection.output = ManaSourceOutput::DeferredColorChoice; + } + Some(selection) +} + +/// Resolve an exact generic semantic source selection from fresh candidates. +/// A deferred flexible output legitimately corresponds to several concrete +/// planner rows; they all name the same object/ability capability and therefore +/// collapse to one canonical manual selection above. +pub(crate) fn live_mana_source_option_for_selection( + state: &GameState, + player: PlayerId, + selection: &ManaSourceSelection, +) -> Result { + if !activatable_mana_source_selections(state, player).contains(selection) { + return Err(EngineError::ActionNotAllowed( + "Mana source selection is stale or no longer legal".to_string(), + )); + } + + let aura_sources = taps_for_mana_trigger_sources(state); + let gates = mana_abilities::ManaActivationGates::compute(state); + let object_id = selection.source.object_id; + let options = current_mana_source_options(state, player, object_id, &aura_sources, &gates); + let mut matches = options + .into_iter() + .filter(|option| manual_selection_for_option(state, option).as_ref() == Some(selection)); + let option = matches.next().ok_or_else(|| { + EngineError::ActionNotAllowed( + "Mana source selection is stale or no longer legal".to_string(), + ) + })?; + if matches.any(|other| other.ability_index != option.ability_index) { + return Err(EngineError::InvalidAction( + "Mana source selection ambiguously matches multiple live capabilities".to_string(), + )); + } + Ok(option) +} + /// Re-enumerate the live land-mana rows and resolve one exact semantic /// selection to its current engine-owned option. Zero matches means the /// submitted choice is stale or illegal; multiple matches mean the selector @@ -551,6 +731,27 @@ pub(crate) fn activate_mana_source_option( option: &ManaSourceOption, events: &mut Vec, resume: ManaAbilityResume, +) -> Result { + activate_mana_source_option_with_output( + state, + player, + option, + ManaSourceOutput::Concrete(option.mana_type), + events, + resume, + ) +} + +/// Activate a live source using the preserved output provenance. Deferred +/// flexible outputs deliberately pass no production override, so the existing +/// ManaChoice flow remains the single authority for the eventual color choice. +pub(crate) fn activate_mana_source_option_with_output( + state: &mut GameState, + player: PlayerId, + option: &ManaSourceOption, + output: ManaSourceOutput, + events: &mut Vec, + resume: ManaAbilityResume, ) -> Result { for (trigger_ref, production_override) in &option.taps_for_mana_overrides { state @@ -569,8 +770,12 @@ pub(crate) fn activate_mana_source_option( "Selected mana ability is no longer available".to_string(), ) })?; - let production_override = - super::casting_costs::production_override_for_option(&ability, option); + let production_override = match output { + ManaSourceOutput::Concrete(_) => { + super::casting_costs::production_override_for_option(&ability, option) + } + ManaSourceOutput::DeferredColorChoice => None, + }; mana_abilities::activate_mana_ability( state, option.object_id, @@ -630,6 +835,28 @@ pub(crate) fn activate_mana_source_option( Ok(waiting_for) } +/// CR 605.3a-b: Revalidate and activate a generic mana capability. The caller +/// supplies its resume because this entry point is shared by priority and a +/// spell's mana-payment interaction; it never translates back into +/// `ActivateAbility`, preserving the frozen selection identity. +pub(crate) fn activate_mana_source_selection( + state: &mut GameState, + player: PlayerId, + selection: &ManaSourceSelection, + events: &mut Vec, + resume: ManaAbilityResume, +) -> Result { + let option = live_mana_source_option_for_selection(state, player, selection)?; + activate_mana_source_option_with_output( + state, + player, + &option, + selection.output, + events, + resume, + ) +} + /// CR 107.6 + CR 302.6: True when the cost includes the untap symbol ({Q}). /// Like {T}, a {Q} cost on a creature is gated by summoning sickness (CR 302.6 /// names both symbols) and requires the source to currently be tapped. Matches a diff --git a/crates/engine/src/game/replay.rs b/crates/engine/src/game/replay.rs index 466c72d61a..107a8de281 100644 --- a/crates/engine/src/game/replay.rs +++ b/crates/engine/src/game/replay.rs @@ -212,7 +212,7 @@ mod tests { use crate::types::game_state::{ProductionOverride, WaitingFor}; use crate::types::identifiers::{ObjectId, ObjectIncarnationRef}; use crate::types::mana::{ - ManaSourcePenalty, ManaSourceSelection, ManaType, TapsForManaSelection, + ManaSourceOutput, ManaSourcePenalty, ManaSourceSelection, ManaType, TapsForManaSelection, }; use crate::types::match_config::MatchConfig; @@ -267,6 +267,7 @@ mod tests { source, ability_index: None, mana_type: ManaType::Green, + output: crate::types::mana::ManaSourceOutput::Concrete(ManaType::Green), atomic_combination: None, restrictions: Vec::new(), penalty: ManaSourcePenalty::None, @@ -291,6 +292,34 @@ mod tests { assert_eq!(restored.actions[0].action, action); } + #[test] + fn legacy_colored_tap_land_action_preserves_its_selected_output() { + let action: GameAction = serde_json::from_value(serde_json::json!({ + "type": "TapLandForMana", + "data": { + "selection": { + "source": { "object_id": 7, "incarnation": 3 }, + "ability_index": null, + "mana_type": "Green", + "atomic_combination": null, + "restrictions": [], + "penalty": "None", + "taps_for_mana": [] + } + } + })) + .expect("pre-output replay actions should deserialize"); + + let GameAction::TapLandForMana { selection } = action else { + panic!("legacy action must retain its TapLandForMana shape"); + }; + assert_eq!( + selection.output, + ManaSourceOutput::Concrete(ManaType::Green), + "a legacy colored row selected its mana_type, not colorless mana" + ); + } + #[test] fn replay_player_reconstructs_every_recorded_index() { let header = two_player_header(99); diff --git a/crates/engine/src/game/scenario.rs b/crates/engine/src/game/scenario.rs index 819c29200b..d9df01b955 100644 --- a/crates/engine/src/game/scenario.rs +++ b/crates/engine/src/game/scenario.rs @@ -1704,6 +1704,7 @@ impl GameRunner { WaitingFor::MulliganDecision { .. } => "MulliganDecision", WaitingFor::OpeningHandBottomCards { .. } => "OpeningHandBottomCards", WaitingFor::ManaPayment { .. } => "ManaPayment", + WaitingFor::ManaSourceSelection { .. } => "ManaSourceSelection", WaitingFor::TargetSelection { .. } => "TargetSelection", WaitingFor::DeclareAttackers { .. } => "DeclareAttackers", WaitingFor::DeclareBlockers { .. } => "DeclareBlockers", @@ -2475,6 +2476,16 @@ impl<'a> SpellCast<'a> { // CR 601.2h: finalize the (now fully convoke-paid) cost. act_collect(runner, GameAction::PassPriority, &mut events)?; } + WaitingFor::ManaSourceSelection { options, .. } => { + let selection = options.first().cloned().unwrap_or_else(|| { + panic!("ManaSourceSelection must offer at least one source") + }); + act_collect( + runner, + GameAction::ActivateManaSource { selection }, + &mut events, + )?; + } // CR 601.2c: declare one target per slot, in written order. WaitingFor::TargetSelection { pending_cast, @@ -2724,6 +2735,7 @@ fn waiting_for_variant_name(waiting: &WaitingFor) -> &'static str { // borrow-free match. Kept in sync with `GameRunner::waiting_for_kind`. match waiting { WaitingFor::ManaPayment { .. } => "ManaPayment", + WaitingFor::ManaSourceSelection { .. } => "ManaSourceSelection", WaitingFor::ChooseXValue { .. } => "ChooseXValue", WaitingFor::TargetSelection { .. } => "TargetSelection", WaitingFor::MultiTargetSelection { .. } => "MultiTargetSelection", diff --git a/crates/engine/src/types/action_stable_order.rs b/crates/engine/src/types/action_stable_order.rs index 27eb8c2719..016086f7a2 100644 --- a/crates/engine/src/types/action_stable_order.rs +++ b/crates/engine/src/types/action_stable_order.rs @@ -227,6 +227,18 @@ fn cmp_payload(a: &GameAction, b: &GameAction) -> Ordering { }; a0.cmp_stable(b0) } + GameAction::ActivateManaSource { selection: a0 } => { + let GameAction::ActivateManaSource { selection: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + a0.cmp_stable(b0) + } + GameAction::BackToManaPayment => { + let GameAction::BackToManaPayment = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + Ordering::Equal + } GameAction::UntapLandForMana { object_id: a0 } => { let GameAction::UntapLandForMana { object_id: b0 } = b else { unreachable!("cmp_payload: same-variant invariant"); diff --git a/crates/engine/src/types/actions.rs b/crates/engine/src/types/actions.rs index 42d795b702..aa8df815ea 100644 --- a/crates/engine/src/types/actions.rs +++ b/crates/engine/src/types/actions.rs @@ -256,6 +256,15 @@ pub enum GameAction { TapLandForMana { selection: ManaSourceSelection, }, + /// CR 605.3a: Activate one exact engine-authored mana-source capability. + /// Unlike the legacy land-only action, this covers mana abilities on every + /// permanent type and preserves the selected output provenance. + ActivateManaSource { + selection: ManaSourceSelection, + }, + /// Return from a sacrificial-mana choice to the exact saved payment state + /// without re-planning or mutating the mana pool. + BackToManaPayment, /// CR 605.3a: Undo a manual mana ability activation — untap source, remove produced mana. /// Only valid for lands in `lands_tapped_for_mana` whose mana hasn't been spent. UntapLandForMana { @@ -1497,6 +1506,7 @@ impl GameAction { matches!( self, GameAction::TapLandForMana { .. } + | GameAction::ActivateManaSource { .. } | GameAction::UntapLandForMana { .. } // CR 118.3a: pinning/unpinning a pool unit is a mana-payment-window // action; classifying it here keeps it out of AI priority-action @@ -1551,6 +1561,7 @@ impl GameAction { | GameAction::CastSpellAsMadness { object_id, .. } => Some(*object_id), GameAction::ActivateAbility { source_id, .. } => Some(*source_id), GameAction::TapLandForMana { selection } => Some(selection.source.object_id), + GameAction::ActivateManaSource { selection } => Some(selection.source.object_id), GameAction::UntapLandForMana { object_id } => Some(*object_id), // CR 118.3a: act on a pool pip, not a battlefield object. GameAction::SpendPoolMana { .. } | GameAction::UnspendPoolMana { .. } => None, @@ -1588,6 +1599,7 @@ impl GameAction { | GameAction::ChooseReplacement { .. } | GameAction::OrderTriggers { .. } | GameAction::CancelCast + | GameAction::BackToManaPayment | GameAction::SubmitSideboard { .. } | GameAction::ChoosePlayDraw { .. } | GameAction::ChooseOption { .. } @@ -1870,6 +1882,9 @@ mod tests { }, ability_index: None, mana_type: crate::types::mana::ManaType::Green, + output: crate::types::mana::ManaSourceOutput::Concrete( + crate::types::mana::ManaType::Green, + ), atomic_combination: None, restrictions: Vec::new(), penalty: crate::types::mana::ManaSourcePenalty::None, diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 14b04f9678..fb6eda280e 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -5352,6 +5352,10 @@ fn default_copy_retarget_effect_kind() -> EffectKind { pub enum CastPaymentMode { #[default] Auto, + /// CR 601.2g-h: auto-pay every safe mana row, but stop before an + /// activation whose cost sacrifices a permanent so its controller makes + /// that irreversible payment decision explicitly. + AutoExceptSacrificialMana, Manual, } @@ -6138,6 +6142,16 @@ pub enum ManaAbilityResume { #[serde(default, skip_serializing_if = "Option::is_none")] convoke_mode: Option, }, + /// CR 601.2g-h + CR 605.3b: automatic payment reached a mana source that + /// sacrifices a permanent. The options are the frozen, engine-authored + /// semantic capability snapshot; submission revalidates one exact current + /// candidate before it can mutate the payment. + ManaSourceSelection { + player: PlayerId, + options: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + convoke_mode: Option, + }, UnlessPayment { /// The player who owns the outer unless-payment poll. It can differ /// from the controller of a helper mana source activated while paying. @@ -7705,6 +7719,16 @@ pub enum WaitingFor { #[serde(default, skip_serializing_if = "Option::is_none")] convoke_mode: Option, }, + /// CR 601.2g-h + CR 605.3b: automatic payment reached a mana source that + /// sacrifices a permanent. The options are the frozen, engine-authored + /// semantic capability snapshot; submission revalidates one exact current + /// candidate before it can mutate the payment. + ManaSourceSelection { + player: PlayerId, + options: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + convoke_mode: Option, + }, /// CR 702.132a: Assist — when casting a spell with assist whose locked total /// cost has a generic component, before the caster pays they MAY choose /// another player to help pay the generic mana. The CASTER acts on this step @@ -9838,6 +9862,7 @@ impl WaitingFor { WaitingFor::MulliganDecision { .. } => "MulliganDecision", WaitingFor::OpeningHandBottomCards { .. } => "OpeningHandBottomCards", WaitingFor::ManaPayment { .. } => "ManaPayment", + WaitingFor::ManaSourceSelection { .. } => "ManaSourceSelection", WaitingFor::ChooseXValue { .. } => "ChooseXValue", WaitingFor::TargetSelection { .. } => "TargetSelection", WaitingFor::DeclareAttackers { .. } => "DeclareAttackers", @@ -9988,6 +10013,7 @@ impl WaitingFor { | WaitingFor::MeldPairChoice { player, .. } | WaitingFor::MeldAttackTargetChoice { player, .. } | WaitingFor::ManaPayment { player, .. } + | WaitingFor::ManaSourceSelection { player, .. } | WaitingFor::ChooseXValue { player, .. } | WaitingFor::TargetSelection { player, .. } | WaitingFor::DeclareAttackers { player, .. } @@ -10148,12 +10174,13 @@ impl WaitingFor { /// /// Runtime drift detector: the `debug_assert!` in `game::derived` trips /// in tests if a new variant populates `GameState::pending_cast` without - /// being covered here (or by the `ManaPayment` exception in + /// being covered here (or by an external payment-state exception in /// `has_pending_cast`). That is the practical safeguard — the `_ => None` /// wildcard below does not compile-enforce variant coverage on its own. /// - /// Note: `ManaPayment` is the one casting-flow variant that does NOT embed - /// its `PendingCast`. It reads from `GameState::pending_cast` instead so + /// Note: `ManaPayment` and `ManaSourceSelection` are casting-flow variants + /// that do NOT embed their `PendingCast`. They read from + /// `GameState::pending_cast` instead so /// multiplayer visibility filtering (`game::visibility`) can clear /// mid-payment detail for opponents while preserving the public "spell on /// the stack" view elsewhere. `has_pending_cast()` accounts for this. @@ -10228,8 +10255,8 @@ impl WaitingFor { /// Whether this state is part of the casting flow and can be backed out of /// with `CancelCast` (CR 601.2). /// - /// Derived from `pending_cast_ref()` plus the single `ManaPayment` - /// exception (which externalizes its `PendingCast` into + /// Derived from `pending_cast_ref()` plus the external payment states + /// (which externalize their `PendingCast` into /// `GameState::pending_cast`). Centralizing the predicate here guarantees /// that every variant carrying a `PendingCast` is covered — drift between /// data model and predicate is structurally prevented. @@ -10243,9 +10270,18 @@ impl WaitingFor { self.pending_cast_ref().is_some() || matches!( self, - WaitingFor::ManaPayment { .. } | WaitingFor::PhyrexianPayment { .. } + WaitingFor::ManaPayment { .. } + | WaitingFor::ManaSourceSelection { .. } + | WaitingFor::PhyrexianPayment { .. } ) } + + /// CR 601.2i: Whether the current prompt permits withdrawing the pending + /// spell. Kept distinct from `has_pending_cast`: a cast can be pending at + /// a mandatory choice without granting a cancellation action. + pub fn allows_cancel_cast(&self) -> bool { + self.has_pending_cast() && !matches!(self, WaitingFor::ManaSourceSelection { .. }) + } } /// CR 102.1 + CR 500.1: which turn boundary ends an auto-pass session. @@ -11482,6 +11518,11 @@ pub struct GameState { /// frontend doesn't need to maintain a parallel list of casting states. #[serde(skip_deserializing, default)] pub has_pending_cast: bool, + /// CR 601.2i: display-only authority for whether the current pending cast + /// may be withdrawn. Separate from `has_pending_cast` so choice prompts + /// can expose an in-flight cast without encouraging an illegal cancel. + #[serde(skip_deserializing, default)] + pub allows_cancel_cast: bool, pub lands_played_this_turn: u8, pub max_lands_per_turn: u8, pub priority_pass_count: u8, @@ -16615,6 +16656,7 @@ impl GameState { next_interaction_serial: default_interaction_serial(), active_interaction_slots: Vec::new(), has_pending_cast: false, + allows_cancel_cast: false, lands_played_this_turn: 0, max_lands_per_turn: 1, priority_pass_count: 0, @@ -18064,6 +18106,7 @@ fn _gamestate_partition_is_total(s: &GameState) { next_interaction_serial: _, active_interaction_slots: _, has_pending_cast: _, + allows_cancel_cast: _, lands_played_this_turn: _, max_lands_per_turn: _, priority_pass_count: _, @@ -21409,6 +21452,11 @@ mod tests { player: PlayerId(0), convoke_mode: None, })); + variants.push(Box::new(WaitingFor::ManaSourceSelection { + player: PlayerId(0), + options: Vec::new(), + convoke_mode: None, + })); variants.push(Box::new(WaitingFor::DeclareAttackers { player: PlayerId(0), valid_attacker_ids: vec![], @@ -21691,7 +21739,7 @@ mod tests { mana_reduction: ManaCost::zero(), pending_cast: dummy_pending(), })); - assert_eq!(variants.len(), 36); + assert_eq!(variants.len(), 37); } #[test] @@ -21785,6 +21833,14 @@ mod tests { }; assert!(mana_payment.pending_cast_ref().is_none()); assert!(mana_payment.has_pending_cast()); + + let source_selection = WaitingFor::ManaSourceSelection { + player: PlayerId(0), + options: Vec::new(), + convoke_mode: None, + }; + assert!(source_selection.has_pending_cast()); + assert!(!source_selection.allows_cancel_cast()); } #[test] diff --git a/crates/engine/src/types/interaction.rs b/crates/engine/src/types/interaction.rs index 830f083bb9..b26e041cd2 100644 --- a/crates/engine/src/types/interaction.rs +++ b/crates/engine/src/types/interaction.rs @@ -500,6 +500,8 @@ pub enum InteractionActionCode { MulliganDecision, ReorderHand, TapLandForMana, + ActivateManaSource, + BackToManaPayment, UntapLandForMana, SpendPoolMana, UnspendPoolMana, diff --git a/crates/engine/src/types/mana.rs b/crates/engine/src/types/mana.rs index be6749780d..515ae98c9d 100644 --- a/crates/engine/src/types/mana.rs +++ b/crates/engine/src/types/mana.rs @@ -669,6 +669,19 @@ pub enum ManaSourcePenalty { Sacrifices, } +/// CR 106.1a: The output choice captured for a mana-source activation. +/// +/// `Concrete` is the planner-selected output used by automatic payment. A +/// manually selected flexible producer instead retains `DeferredColorChoice`, +/// so the normal mana-choice interaction chooses its color at activation time +/// rather than silently committing to an arbitrary planner row. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "type", content = "data")] +pub enum ManaSourceOutput { + Concrete(ManaType), + DeferredColorChoice, +} + /// Exact identity of one triggered mana ability that augments a land's own /// production when that land is tapped for mana. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -678,24 +691,62 @@ pub struct TapsForManaSelection { pub production_override: ProductionOverride, } -/// Stable semantic selection for one currently activatable land-mana row. +/// Stable semantic selection for one currently activatable mana-source row. /// /// The action carries every field that can change legality or resolution, but /// the reducer never trusts those fields directly: it enumerates current live /// options and requires one exact semantic match before activating that live /// option. `ObjectIncarnationRef` prevents a stale choice from rebinding to an /// object that left and re-entered the battlefield (CR 400.7). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ManaSourceSelection { pub source: ObjectIncarnationRef, pub ability_index: Option, pub mana_type: ManaType, + pub output: ManaSourceOutput, pub atomic_combination: Option>, pub restrictions: Vec, pub penalty: ManaSourcePenalty, pub taps_for_mana: Vec, } +impl<'de> Deserialize<'de> for ManaSourceSelection { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct WireSelection { + source: ObjectIncarnationRef, + ability_index: Option, + mana_type: ManaType, + #[serde(default)] + output: Option, + atomic_combination: Option>, + restrictions: Vec, + penalty: ManaSourcePenalty, + taps_for_mana: Vec, + } + + let wire = WireSelection::deserialize(deserializer)?; + Ok(Self { + source: wire.source, + ability_index: wire.ability_index, + mana_type: wire.mana_type, + // `TapLandForMana` actions saved before output provenance existed + // selected this exact row's `mana_type`; preserve that choice on + // replay instead of silently turning every old colored row colorless. + output: wire + .output + .unwrap_or(ManaSourceOutput::Concrete(wire.mana_type)), + atomic_combination: wire.atomic_combination, + restrictions: wire.restrictions, + penalty: wire.penalty, + taps_for_mana: wire.taps_for_mana, + }) + } +} + impl ManaSourceSelection { /// Stable total order for the action wire. The non-`Ord` semantic fields /// are compared by explicit closed-enum comparators below; debug strings or @@ -705,6 +756,7 @@ impl ManaSourceSelection { .cmp(&other.source) .then_with(|| self.ability_index.cmp(&other.ability_index)) .then_with(|| self.mana_type.cmp(&other.mana_type)) + .then_with(|| cmp_mana_source_output(self.output, other.output)) .then_with(|| self.atomic_combination.cmp(&other.atomic_combination)) .then_with(|| cmp_mana_restriction_slices(&self.restrictions, &other.restrictions)) .then_with(|| cmp_mana_source_penalty(self.penalty, other.penalty)) @@ -712,6 +764,21 @@ impl ManaSourceSelection { } } +fn cmp_mana_source_output(left: ManaSourceOutput, right: ManaSourceOutput) -> std::cmp::Ordering { + match (left, right) { + (ManaSourceOutput::Concrete(a), ManaSourceOutput::Concrete(b)) => a.cmp(&b), + (ManaSourceOutput::DeferredColorChoice, ManaSourceOutput::DeferredColorChoice) => { + std::cmp::Ordering::Equal + } + (ManaSourceOutput::Concrete(_), ManaSourceOutput::DeferredColorChoice) => { + std::cmp::Ordering::Less + } + (ManaSourceOutput::DeferredColorChoice, ManaSourceOutput::Concrete(_)) => { + std::cmp::Ordering::Greater + } + } +} + fn cmp_slice_by( left: &[T], right: &[T], diff --git a/crates/engine/src/types/mod.rs b/crates/engine/src/types/mod.rs index 359b440d71..7e3c175c8b 100644 --- a/crates/engine/src/types/mod.rs +++ b/crates/engine/src/types/mod.rs @@ -63,8 +63,8 @@ pub use keywords::{Keyword, PartnerType, ProtectionTarget}; pub use layers::{ActiveContinuousEffect, Layer}; pub use log::{GameLogEntry, LogCategory, LogSegment}; pub use mana::{ - ManaColor, ManaCost, ManaCostShard, ManaPool, ManaRestriction, ManaSourcePenalty, - ManaSourceSelection, ManaType, ManaUnit, SpellMeta, TapsForManaSelection, + ManaColor, ManaCost, ManaCostShard, ManaPool, ManaRestriction, ManaSourceOutput, + ManaSourcePenalty, ManaSourceSelection, ManaType, ManaUnit, SpellMeta, TapsForManaSelection, }; pub use match_config::{ BetweenGamesPrompt, DeckCardCount, MatchConfig, MatchPhase, MatchScore, MatchType, diff --git a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz index 3788c61d9d9f4b0efd0580ceb2c3f7bebb4444a6..e681f411b33f2ed48f6d3ae06312adde2a346590 100644 GIT binary patch literal 40792 zcmV)IK)k;niwFP!000021MIz9kL${DKl=Xv6vTQt$scPxykE4Rf&>VRB*=#C^OA#r zpv0aTdMJ`6shK4k{_dx$x||aQaql-q<$6iP)5$Wjz)86Q8i>=8dbj2nf<6o);K#9Iw}L?D;OU*PhE<%eKtO zb$w&wIBPpLUkuasOlz|-?6tLiv30{|o)h^VYbe62@EHWEmJ`Uqys;?mERD6cEZ4Uj zlQDZ^I>sh`aeeP)?Xb0Hy?CzfxVmlFn{WR|%*MB72L3-H*sKkGPOH66(xl#pC9Cpu zNAnTZZwdU*uFSqQlf6kJSgn_2BUk2IQRYQnu{ex&^;-^WvF|)h6BhQ~%5GP(S%Us+ ze{TnuMQMjFtp%*-YK2R?TBR(0W#wuW6-6pW{s9BwABQYXYVDtXHNR^a`~2s2Pz}8h zHwOto`x1p2RxH}KPv@UmlD&r8Jl>_O8A0{TvezVI&sDj3#=$&uroStzA(psuQ5J_> zM3BmV(&Lo|pK5uwVNV4spZ>4*5^X?sj(%5d@`AOi*ImJm=v_=#>~ElnKVd)MOYNQQ zwIr)piR=1YvTgnmrN3yAhU>4v+_GBRq$~p3d6w?A`i*HsBG!OKlE)gK0etljR>Cie zG}X#gwmR{Vvcml}pz!0<~Aep@E2`rWtA z{w(udEp}%T_vioAK432|4CeY7HVkZrm1j8iO6<%~${6hWBwKBFb+~1)7vmN3-glPg z6meXO3dshRw}RbU)?vne)Fh)_dbCjAj;5CV*rdA{cKIgFv&-Krf@#}VN%f5Hu`4$W zM_>kyu>!IAGrVmG(-g`tt7`_5!6LUX-5t>XcAa7&3vg_YxQh14Dtu3&~> zi}AAt|JuNoZ1#kBAspqGNX336RgH*L=OMhyHm}+BVAUN7Eap!FR2-h3lWYUb$ASxzd;w?D#irA*uG$`W6QRGCr({DeJA95nu7zi4olB^A7F0b@vN0AL`Y ztwFJRXYo|bI@k^?&v8$0&tu%08E0(kJkFG?21var(5TjnoO3{9FFS2H20I=8WG4oAaP%K3W&jgO$u+YldSJBPb%1ow zTyI+MxmrjA>gZuO^e2g{0~*ya0R9O^+nF~3fVfs2z4I*s+OP*dKq&TFoK%0#6F^J4 zKWZPa1Z9-*k@FE%>H*QY1`w}NOs%DO%98b?Q&otu3tws0XV5prMk6M;CN&gTL}KO`RQ{Xo4fp0T10ocxuAutZToJXML8{yDt# zF2+mJK8_#~KMgyo)XhLrDnnnP;aLTF1OQ&CbZ78Lj92w4FQ3H%cU8}b8zi`%$bYXAnocwz9#hCPvCnvaKHzssCsY7O%*8GXL4Af-!R?l^Lr>P_ z!~dF<@7{=2;C0M~Ak`>mvDjzupOpJq2KXvfGe43l;b$c&-!58aQ*t5kK-9bpKN1G8 zaKq>Y))-I=*zOPN!|57&kG(ftEAVYSI9=9f&aD4}N3{w)4$lW$dc;|En%IyhMmP#J zF;EQ_x(Rd?*X9Cmtl#0Af}coeao6am$tX(c>H`UnkKL9o#^NR@PQlI~DtVsrx{bF9nY*M(w9AHPbncLp2-Ni<@ z*a&C05z^nnSprHYH=E&CTr}x#3fmygm!p8tt75j^y&`$r^uz|GkAxX#jByV@Qi0P1 zZU{Gan)u3`CZ>5OYopM7W41{sfJaD;TBD7%Ql?V8?0A4@PzvNz75L&n37a)66#)XuX0Pn9F`2YQrygF;@yaq-wo`##%MCt zSQCKy!s##Wk3Q2>%dClswS6L1=<_#O$ihOV+{UZrpc>6hqYQt8eEN3H`$8=b}XaSWKiT z`9-_}*OT$M;9TSNw~JT7e%w{qz;xlE{)9)r_K|O*^)7`IWYVe#njz9WzcR*+R;Dw? znhX{p#SnLb)TY22sdT;9lDg7_S3A}c zj339NVb1V$Imn^l^r)qv%#?6s_w!w=<37Q!Pni3{B+L2-;l&zhoAhKmYLlLnUZ1lJ zWP1Z7cp6mrs97&d^$T^?FXZfLg6A`pXy7^H@I57XckoBcU+6660*)z)Xlo?vIV>c#fc})L$?CBQm1I43}u?Ue1KnF#J30b z!|QJ=hHgIcb)`n#1t`Sq1=psm9v)H_tzmo!MBVLi_Wp8HY7nEW>YVws08%(EueQMWtg}%@)socswY@t41?c@?0Qc zfrN*K1U@NTU2vQgub(T>per9eH26`&F?tmo1p1@H0U2$CS7!l(1q>Gb0QCcXHvPbQ zI6!#3-~z1t$FIEwFxQ0dnESXz$uf~QTEn`ncRb%UiP=+);36Wwc>J1lTMjw2+h2dX z9HPGVWb?c4YQ;XHbcgfLKRD;M-eH0>ysOd(%(K?qPK@RPybJK&9(WH-wZ83s5r}wI za2dh4rF$|CP(;eXp36&Klc3xc_$Nq0l7{5v3E8&5ead*m$W-pOVN$4GBivz{=fza2 zZea9%VH4>EexQ<`XTuHn6upZLY-AvmF?YHpLr3Mud^X9Fhg+rY@<7}LMHW7}%8+O1 zUmEhxM)X}-Z8Zt2T~Xv^Et>%ty6dj0Ee@apysS68+G+v&a8#;TXN64%t7OZWBS8)U zT!3PPVEeL52bO9KAKzGIIjqP>n`m;#s2Rt2jt3E$rsLf=kCq~FfdIv)!YP}f=Z0IG z!xt>Iym)Vx4k=5Al&jr1crb}e*pP?`7f$~^HpYWvn2&3sffuh+#{YcGNHbcJA}K&; z%c#IKz<)QxcDAZcJ;rBl0{;WgYJA^JfrlY7Luk-hHBA;c1{rAp0z<^2Hm*!4)%k{3 zNurv=AUm&bB!IuBQTC);nwZ(Wh6pZ%RtrR?rRKb{Kpmh zaZMwBpHEewHtEma>sZ^ASy1Hn-;rRZF{UrpNtiV9@{1XN2N>%3+>EI&L^Qbv;H-rN1 z=dl(mEV{XX(?D#Q6nhdji7MZ*j|c1!ltO_=@Ih4ayEzFk--gdI!|CB4cN!Uu#v>fX z03Z!wpq$Y|w4zsOAY!r>vV}cPd65RT_ILpad<(a7-=nIGN=eT=fSpM$ymB7enr+?b zs5w0m+jm)DDD9Psjdl0-A)b53K5)Ek2vszVDzz0g6~@Uf7`0&3_;`DgbkFe(X(H?N z${B$k9czwZJ1a7?H6|Op5sSMGsW(m+u5Mr7IA2+Pp+)R~uz!Z| z!R0Jwk0E@E^*w{Q2L zzMQ2_@lvPwk?Iul&Kl&3wcHi8=s%Q~lJ&b}{ocLy zil}l(rg^Is_uiMQbzYYF=S3E_Lz;o1oTlX(O<_4vqmknWT28A8gl*fIiLm3(L)d{O zn}@I+C?PEIpON*55oqzW>=AcWZe43@23d|H4Qiq+NLwgm0$Imx;y5{xu{-yOvg^1r zQT9N|=u=*&Ij=G{m9%d*zCfOtKYoro{p$iX{p(EKTH)Rj^GLu=-toMu`DbH@+OE5z zdI3@Vf$q;yG(3Mg4S)JL9ION|K;yo;mRKo_q;1`_OSP*-{rZPdzdAvoaEJRMVBzu* zjy43scP^p$2oO;5rl{TE@}-pi0t5eeFkm_+#yWw%6f>4A=_xVmg7Oo^dAI!!MH)>vcD*Xt zCW+D~(LsnZ7k*p8=}pHZ9_bydD8M;`^*u~1rV#LTw0XZklKWI0mrhfjIveMhX_JTR zysqa5_0;T=}|}D z)g4K{6Jk4&$zlH}+ne4U9cPzqP&FXE)z??_N%a|}?_+mopQfeTCuoiwq-Ei;^}(W) zUz9~r$9~rT`0;vXV?r0DB2)HK^Oz+x`sOb$!;J2HPQpN`1u8$p9y1(l$)8Y{KhZ3^ z$YH==P)-DeP@+P=OY7fsYJ{8sLaru{v@Z_Fc~cJ}rm#&}SuJw9$mu_poI2;_^xiR^ zZSPCE4KHPqf74AaK~1Op!Zw2QpZL1R!btVK72$tA=z&SH3{n+qYi@IEo_tF4s^TWN zC#-iAYy6%xLeOQ#qY(FI#J}`}yy5&@$POh5Oa&*&G)yW6(G}NRynEwtWd#b=VN<#* zdja&LImT5c9HQ@^qfdp!9sokv2CWRDL^O{8eo$}T0w~L&udedTm${{Z;hxSdeI!i) z1P;^E)(obr)8<3AQ6Nv7+TKXsSuNhZ+{Y%W)*rjs#P~aI7fYfI^W>1^YVJ^$x_ z{9bFjB2RL)eNAyljO)%%I&;tK)8k&~aS=9dTUG#X9QkFB6natQ2&;=yO@% zR&=SUbST3>aFmk4p;$9D8M$n`GMw&?0XtEK8f1tz+e_}qk5v?)<6SW}`v zh|Z`kmTjd@({_7>IzyRTBZ{a{^Q5CrJl9tQH67zde4N?&guNa{flsZVFgR55l%5eP zi>Zi@Y3gVATpNwsqi%b*c?&PN4IlqT?-N*auF8$}maXh1;6RB)T35nEys>Rc5bJ!q zpNwJMhBBt5`>SnKq6g^}bcd8*7C80rloB$Tt!L{Lqy$P?)po6sx`Ak87Bjp}#&R$` zT4aFr{Zuqqha%eJ@eA4m70`M~Bc=K9hJRKk^Ol_4B`5c%%*kD|x14vCaIEpgGDPny z#4U1vyT%k+vN(ugx2drux?Z;!Ix#Zewk7L&;|C{d9?M*-sntasM{g@lfv%1b196yB zv;e?}1h$EFw;Z@|OP}^hCgU@hUDKUbhw6Zy=Q>L0!He-TTtCj)?i?HA341dUZU=ll ziRP-sN9c`CD(vq)o|j>owzS*qGOW%q;#E&mcblxbyeI%k;OlL)A6s&4fP>|>48-Oa zWyh}NF`y|r)RYHRwZ5iU=54`&`)NtME>aZ7KS5eaqaul6AKS0w``p$j-JB$1SZ7rw z{@66l=zzwsv6&vyu<8{bT-%hd&dJIvEm5MQUnyK$9Rtz`%}4Ux?$SCzJ2G&sBXTuq zvc~5oDH61dwRWjEr*3%NhJHLG{BUr}ijB3)1!qg^IBG;984AU`vWbCv_m)<^anF`k zC08oy{2j32=Rp`6@3DCtKMm=f?s5$y{^#uD_)L>OA%;51JpLuYLF;(;7hUtXoVXoU z??U&~lkiZ*+VB6Z68A1y{r4@x5MowhO#G%AZ5Z#|_8Mkg^EmvEw24P)nr}otO1016 zhgI@drp3DwhfBb;SKcqkzpqrj|Z|K>zg)ZVSW-sUjEoZ`^ zC69hdg8K=|7>OR9ShQxlO==+?qAW~wsPbIjQ^0c`xTk=`1hf=Z@Xk?MAEdZFIy5ZN zI7>UHM+@rddXH?~_L_QFI6g{f?D>w`Fo|)#s`h$i>5q>!RjYt3YR95>{DZ3<=7nm< z)nE|#!OwsNyw==O!e@bke{d);U3Vo*QHlbL6-5DZ)}z3nQ<&25R=j`((H<@qf0Z@G zcxZI_qzI>vsQwAGjO7Ni++ZFvErIumwcAw0j$j=qC?g<5e`E!P?BR_qMAoehJLc+} zwzqZ`MP*S`9;4(a2Ko+{3cSWt;hHN^Rd{sKLL<u=`PT=K-)hgv{_1^%~1ethAL5Nir&qJ%x$os%Ap96hyNbuO*%rV@Q~~KbeHDPbFb_ zlSkp{=0p;vW476QR7al0qsUl+0y36mO?)4#yY)s;y@g~WVA9WiW9u8f$cTgr&RvF$23wmZr8lFXEGS)C5dV-Ij|@&WEDn`g&Vw*g$! zR!7Uxr_$;j%8oI^LGS!r2R)(W4E-34vj#wYIqjFz{;>^!o(cn?rQ87M8#geIIkz&8 zc{ebRsW2;b^}t}VCB@LE8)MAL#+d2$98b5W8)G~*TAiW77~|-ZHLoMY*eO(|2& zIj&udEQ)yVz1`MZNS;DdQ=+#D+l8;Nt$2*pM9CF@x7y4DM?>0N+#W8Q; zDsrx4axW|sbqIKEf=5piTylBTJcY23YZlI!2GbW$Ac z!z_7ytADD1QVRWx8G?7TM3kO{8G5iYP0!Sb7|l|cQ!WXV7lwkjT>#i zrp+=u9N6TfzGfTKCDU8M^%N@($WJ@!_2r61kEYk&!?AsL^WEK52)5q6f;xkxooilp zcF6_)&Fc?oo)^!w^qM?Wcz-c@Jm*}2z+_?AD%d*kFFR=f=0VcGXj!gdh1fPsTk9OH z{{W52X#U8gWXLE|E=|#9bPDh>Y4;3iZmGXDetg|L_!u`(G09)T76f2a6}5iK~n;OL`OQm_K^z?nB5JUKlpI7#n5h}l7jO@dl; zShH<`>TGq>oML+ciaP*>T=ZwT=m-FD61`@5B}7&0x!!-Ha!`117>pO@k${Mk}lv-*><>`B;!Ht$ck{?lwyJfWJVrJur_i$vu%d%35p zO)hy?L>Z1)M6HUb7+L$S`TlxD+3&Mt4U=CAP2kiJN=I_FY5CUWzTwg-@Y zj`oE;QjSPF#%!cLUlr+|Os&i|b*|d2v8ZX4z`Y@l%6Q~r_UU)xT1)o9BJ5$=aA-G9 zB^#N}dI=)TOD z<5$uQFi`RZM)%ew%Q2uDJbUkF7!u1^)vRca0^SdD2}@kU5|^;VYfk~)GFo_Qz8ast za7f)&LA@;qL;v29L~GT0r1tfscq>NfCl z0%sK`RUxvJiSUW-K8k_k8Aj6~tl1o7my>%&0T16^5w*juKBk{@hp=mSK|4FVODrf< zL)1qN+;vT$Ut*oNLjej2`v8C_ju9s5;=5R%f!A5yV<82`r@&xN`=aHV=j@BuJJnp> z@K#w~Zlg5$D<_G;9amluC#YJ8a5HII&Z228n$}OKX{|xz|H{+!F$k!R(ln&cLd?|c zA1{|We4^@&Lt8@;KN?R^YJ((7@>pyt6UYNaeA`txCh$4{3p)4PqI(kv!>Z>c$HTVR z+Z1{~5@{Lyz-g#J#Y*{b?NwB6LvFAL>wE%}?Y>MRvh{f?##}NopK#4U43a*XK$F_F zH*HAy*5J+CMJM;8%p}qMls9-sfll zXtrrPy~ylPdMlb%3>cCFCsM7hhLL}R8b-D{9^E=b6xI`Mi$;}fv3OK%r0FGvOv9L7 z8_Ats8_Aen8_Ano8_6=%)JC$E)JB?KX4;=z8_Ael8)7?e9G@uso{C&6g+oHPPs#J%2AqsH@E*q^bY3A9e2c#>c=Zk z{Fc+xgzke8alD7xJjqk?_x1vd)88Ofm5XHjh_U-!(4m*bl1o)_vAF zuwe4it^MZ(@qWyK185TGZD_%f{qb%>p7FTPiZ>%fL)C-Vb6Z+Ol)QEy3bTu=qm+i%ah`hz!|_EOPVW zpOe&q5neq@zoS=={b2Jc8cX4~#nkiDRdnMT8IPkiq?f8Dj}!^ZsH#M*K7#c}RMv`B z3kFHb9uxp5d|Ae4uU0X`a#`iqqTU4I*Ey*ddH0;YRN8xt&!`9g1J7za#dV5g50M!X zWC??sFb(i?5+tHA8fX|Mt|JUZH&z*Qa8M7(WSOr&x3mFVyhn!TrwZieqqj^g93W!-o zGPDP-2T938dzzpgI=AB~fWZ|3!E67OMj!{-zaYqno`!-l!vhKq57n(|-eSu&^-i=T z$`wVZ6mRI}iHckn?}ipuLKZCv38NWtmhA5SSrdX1LwYZ_%wx~0h(J=7EERT`nM6iGdg z2Xd{}jfq~;l%#%C(%~j4-gsm_hP%b+Bi`gO7osUy25kuyKIBf|P?V-WUD)gT%+V7(M zKC<=;xG}T#+ny1=OWi@yHW3a=@vrmHlHYhhdDn8C7uR+D zw%qeLbPYj_Haj`E3(Mj(#C&^&*HTegX{cn))dMt5pJ?&`IRQZEHO;Gb3>Hdau$=|P`6CAU=0`blVClf zyqIsy0N2jdaD9y$?w*~1`+`5srEIku!axBuAJmbu90ki!Fk=>_>jf)rerwTBRGaLC ztX!X4>H5|MbUV0ENAHh-3Tguw7aUk{;3vTWCjgCrSA}n-VTxXbwc)Qq0K8-?Eeqca z1q-hZ`AgUeIgF~>m7P<%1s+UArvisB z$?Las_@H+ms5rC##>1B9(23t@e7rnmPWuFT$L*6;8gcza18jglvK+0zzv`M8oCdQj z(h(#|h~0e>H7U0~+ilnEQLlPS>vtus-)Y28Dd<$)Chr1t5Sf}BS0XvV=W(Ad@CS{< z9aH9{&<@W*mF#Skdq-5fmeB{>Q;+ev6Hk}W`z3nyA0zbM@q$~1-WyHmeJjms!|yHs zal`M=AQ*lSq9=ZVvIWW(D7zZU{F`PGneN3NX4 zce^Sew)s=oo6IkeBxYsu(F2>oEr2k+EpUCf&p=utLm>(sTLe)QDG@D)5F67{(xkY6 z3wjkdFNtq8VZ$fuDMlB5MhPwOOWxfp9SE)3rj4-J-!89?Ub6KrNn_qW?+E3@w8($- zM}Nk&m1HpH2xB{cs#4HoujrVv7w_d|7JmdY?6*ZW^w#*_8hxf1`n&7VwP`iz+h#Bv z4s4Ew8r_yf(L0c8w)z!`N&OPJ-Alcr@^vDg>y^P_6RFPI^wdE@ zXRxc)-_~%Zhu!fvKl~2Jee`XW=5-|w2L93CZ&1mpGlHKXJwSxuk;!`#72<%@+MU6( z0@A(q`NlGOjBMeky0verfz&5asv+Q18d{e}pvyMT@;c7|UOEGb(lqB=5f5jU6rkgE z21j+KIB)qTQJrsL_og^xELe%#Kr43ZRBY)-tGL+PvK*!1mp4tMHpU6$X?a}a>`=~d zu1I?7a#!KGNXKT8m+&)jG^3-aD)3H%_iU}sH8?Tqo|rX)LC)|I=QrnsgTcaW31u6i z2$WCK!_{>{!GNQ>D^TQ_ujmHX3gLGlfjmh_qz`pOm~i;fq%OtV0q=w~Q|)qvnI21M z%#yeJn8nV?-k|%9cbRUZC|hId4FrbaDI#S169_ey6hH7(^nRHix5D*abah*T`xXRS z5bR0>^IgxAE*}&yDPK)J_=)j<5A3++cv6F0;``=h2#XgfO>a0`55Tam2!6|=u!#1k zoDPJ)wO`&CQo5|q+(!Nj9@Q!of#-uXzY!R6n%Ho+2NBX%P?|uI6whMJT9IVD?E_Xz z*6?+nL`P|PSr~x?qJo;JE6`;}AlIfe@C*RV;Lr0EFesTE{)PjJGTE-t#%gZcZBmEL z=Xm!-4PjK;Ws4lPZs@%lNmJ<=STy+ry^Gu5cE|;W(&3r9QhKib#rUp&-N0=1K7cfi z+hljVa(z1bms+x6Q{+Q)T?CMGSP9nMEmCESd{D}1=o#Z0(G!YFD*_Mi zERXwLLJ(Cz*eOdge`gu}P-TDmvvCThwSdYEL8YCvCt;i5*g>n`?JEbeR_yTZLZMkP zHwt7?0aF-_zz<>Uc&1zTk$q&-c?-#zTOQEm!AB^;tG&KZ#Euxbh-XuntOz693!zx2 z`Q}~JsP>yeuh&H@u%JI-P_d8CdRQfRo-=*jme7pmBl5p9&g9nQiV><<=`74L&7n zDsYz!liu)c%Oqyy6}QbM6WP#pdsRgH7H!cw#j}pbP%|x+)O?lH4A)Rd%~UyWj!PMf z*j}=0P1r_)E(Q`@eU}uA?g(oM+g|X!*LU#%W|)Mytf|xR*8&Y%+1%)P?=@(3v8Foky72WXVslz`}Ni{sR1OZaPdeF6Mk}RiX5##f+Tr|)7@^H zFsVhF*JuW=3vg7h5(|ThECsHLgjKXDQ4wU-6!qLtte*Qy=)qaR_re&Q>a^fAXKKMo zsB9Sh4xfsGO-$SI<_>7FvE|9!JZxK^pGO=0IUkQ(%vE5`9>iqMKUc128t7(CsyBIm zB^)evbr_+ScU_|R$$Q7;T(@vDCI+^%V(2iIi3<;RRj=krCtLb?FL(CexH;3%-4o`_ z5uLJ*H62T_;ZUDOucM_{mue{wl=#-79#F$X1@#^rh1IbZ$Xg)qF+Dh*au1HJ8tA-7 z^^l}_UW85YA8v|Jntgi~fs7@I{~k&FxbV+0Tkl>q*&?1d<`I$TbxtAY8_e>DErV6A z`0l_oU9Ou`3yGA9?a0EV{SFep-PHZ#0;?t{M^_YB9iikHXn_NnNmAJH^&3daOO&Q- zfaFoiT}#)ooK>{h?6$jfWW)uZkLMA_ZmL1SDMaHC=+ZYx7l2+cUAt z4b_yw)#UB$!q+nYL}e1dq~7f6Y13kQW@Fij*#@)T6;ZYgFF2XfJc68Qx=P8p<|K=s zAY!W>lXr&PUC(H0;3M!u>Dg?YJ)8CAdazs%e#+|sh)XV7ySo9e6xo%dF|JHW(1LSV z8NtT2_zo0DyUxEfTHAIoyJ6fh&G~s)H^@>pd<8tH98X&)v+4B6uYeU`7bPF0c}bnn zFqr13%k(%d-We`aUcqrMBYxMzvWQAv>w}UysB;LM6r#7|V^-H(j?U%ie7vKxEj~xW zueT;Kc$FBw!t}y%cD5k~zBxWJC{A8^go-zrhUvHO$+M8rY=D=`_t7+i39lwhh^A%A zbU*=c+>VHb7xtqbad;e=QQHNWYDUEu^sw&?CZOQ0QAmeGARr0ud^G~(L7?7vdFCegjy{{>if)OT zr-@!$+M=Q9D5EJ)Bs-XXs(IGrP_k9stWgTN2g2$!98A;wcv4-fE=(K=detX-*V{V9 zzVmz_8pIzPt@r-y$$C8|hp)^HRDBg4c&Kr@i?*Ed47 zU(+ai(k)HS=r0G|n(0-VaHmQ!R=6%;#cY{sdRt*`hTw2tam=J+DhLignY(4#N{bO+ zr_LF7Q0Kl@zV6Wg`(Lz|U7BjN%Qc3z$eK2r@|dnfThI+w$fX9^bX+&;0LKr0#>?LJ z1MRShMHR+L**y`?Dx6=p!sbLl?c^h8A^+e^6Kq*5Q^X&nxPY6db`$L~X@d=Yj$ zS2we*$U&VsC;r4Ad;ZV=_`N2~(*1_>S05Q?C=Q(-b>}CYxoUrnX%!ViDcFkq2JRBX zB#fqSTIZM2nK|MF;SeAiwuv*O+wh>&aFmk4;h7Q1chUZ`V>@~r4vSr;ZLyX)HENr7 ziZp!-UAs+$=VgZ9qKZ9GaQ1tGiRzv6{z0?V7wKK3_x93j`O2mDEAD8zu4oMWBo~E{ zU=;0T`-O*cUFI6Rir_jg%lvZ|4FOPwZorFbuz{C%wNJ*21QiokO?|}l@1-u8mrElq4n+q73cTg=+uUndTM^7 z@gZyY(@OgUK|})}rt%}>Uto`E@JE)T!~0iVlToKI(ZW6{#rJpT?ADlF9kUt}p-b2b z51LA|REStAL_9`?h+f?WZ|0sB=^eFEDvW-o$ZnHE;a#unW;^(cV2 zLxo#Fx}T=82s~p-selugK*#Tuoe;oSMObsU7d8%GzLa@ZkK*DQ7joIbL5dq{-pjNT z1kTXi_a=7ob^;XJ*(`NEtCrJeIeqSa`V5P%;|8k=6QWU-$HbnOwTML zP7wXUn{dmr<_{WdyokCwQ3qMk_{|lgltqdCU+_AL0mNKJ=wPXi`eg%)B#$*d1K<1i zSsaPB%SwiIr{pugWt9`&I%Cyml)m#ebryAj5c-1ou?_l>HFpP_rL@+siU$E zGxnn8 z7R*wIS=0zgU<%3ap0^O`oA)-+uA<1W#s)Vl{&a?uuu5cMAlMLWMm3E|>QDXT4Atcj z-N3k}9O4u(GIZNfi$-dZqE)yawAC&j;!B!;9$^Fj>K=(x?V!#%0bsdB?=t$O2HO|@ zoD>s!mgW#7(1@Q>K!&teQh*M6)CL-3Kare}7)5o*IKK`jy6$|wh|6a^qMMgJ+ihp3t7jwgzSAG1-47cmIHh_z#sMi{|$J_x6zL<#jLTgKnUnv zrGE}TId)K$ku&h4T;6k3B8RI&86lO90|?jYR;y;yt%O=P_$u>efemPy4_`E4SD@|k zJBaE>{?5L)4u~hAW6%v$%U`I@fPk9)lMJ@u(@IuQRBO68BbZM>8g4V-be;sK0}DcH zBITpzh66b|Uiqw2fj!rz=3uG#3vzQxhAPy|sj}u>Q)U0a-u5xg8CP)MlY;hD1g{1I zNi|HOdMcs>mCsj@z;e$sWqJB+8EbJ=M{Cf7htgvy0}Fo~${$~ax?d&97A1D&l|`Un zsl#)I+r7&@MT&_fyhZ!O3ITrtrV4!UMcw zqGdD^d(i95;-^@AtB>YemGx3~b&_%ysNXnOTM?7m{y@U@-K-ZU zoU7#jDuiknx-s%CbrFn)GVITAgI_zqscLa4Hc@;OQ#n(7=a?*pHY2tk$~R(j5*=S=pvFf&dG}p5{3SBMM;iLQkSe#L9R4Rh)Al*I$ZmQ5Nnt(DbKI)-o+x-i&RUg{?|WfhT;Cs+^n=C0WZ+4mp{Sw!P_%6 zAB6LqwooJC@cJ;0>=i?ZLD(8mkQK<$oZQx=#mhSu<7V*)WPp=AuaO?s?T77}m#y*o z6Mc$2#UF0e@prAt0Rz^cDWQr%BgQBN^f8IwU_}FX2@L~z;ZamZ%FaktgN5sQ-fB=9 z9|9t1w7h#u`BSO?OQhivX{ac%UAI6d=Y}d02MY10zi~rw{^NrJoktD|Bn&_!Ldnz0 zK_)O${-)gttSh<+HUnL){zkp4yJK&Dh%qcj-&WZ3Mc9S-M}NP87rQfppCJZFsse}# zxHovr4y7gIzCf!r|0jQ%BAGd*4?AEE+(vRloCW%b*R~&PpQ1g3^VcRWrU8fTj{Jh+92;HP+6jE;i&EJ$6wx3pp-5Eg z=1L;b&gkYbX4Yj9>DImQkO$Dj{|g4D9Yh_C&;o|Y44(aX+;5Ba=F3$9{Q0j*@%vbd zDzb=S{tGNf`h*kK#-vR;>%{^8gthPBG;fdoFOGar;s#E9^@_S{uR0rCHDH_)VgC*y zTqpF)ZSkP-q>GR2?w)Tszgn|bo~1ubUJP1S_ylP9iG1OX=dAkTb1RIn?|~5|L3D_T zrD~7+vjzcY2|T|;;JKVVd8dD-Cax5Zjz6Yc_wFqlv;)*2xByels#5sXe$!Q=*_GH%wQaSR@l9q9wdgf*WUaLtQ(tf)$zXtGBK+P9PZg4Z#~pQKy9nH`q4S2c}gWlzg|l zv`!HHfNLFbSD-}rO;S+gQrl1@&Z&roRHP74c`(A^;3l^c>DX^f!*W*nn%k~UiAOcD z#-OP`ZjC|l&UIA$Bp|RTT1$rCqp4zjMA3soL;>lViXn#k=oq55OPP1JvybIm9>gTp zL0DrCphJ9lTj=PH=wbl*Q&c>|%ftV&AK0u*3~1Jv332*L>*(`gc@3#iJYlEgdj&Bt50nqUz(4$p(T2C|SC+*$yEy4&ZfVm1F zsJd!=|IKN`t1+Jb#V6&Y#cQzT+B3pt)h%!$DnkJ=>`TFEUN>)F@1Op#WK0azf7bY; z&zMWu&1NGE&}b{b3)E2)p*E~QexIn~+p@*90#Ur`!2qI8uOT7$~n4w*>d1K^7 zC{Ux$Vxm(0LfihiZ6Lx&U(vpJ#fV!_!tnrKK%l=Zq+kq~6Dj0@!z<>({%#1CGIR>z z>h_<-s&ue!Z|L@e<;%YBx$n z*F6JBgIJ+vLJsYChphxVW|`?+Y(6hMKmh#;HG?<7n3fO$S_K1FUlq}w`+tcrO}(sV zk4HDW&~B@Qmv7#Tm+5;4yeJQzvhTZG6|k~EyS^1G*IJ2FHr!*!j#fw5f_t`TC_6d(_hL}^4EAJ~s1oxb^O zJMe@U$#5^aj&pJ1;a-Y_%GUwtWMF2D(PJn{T!=(J6pjv5JV*gMw~?yPs4O_N&dAO; zZpqFHY{?F;?sd))XeXBjK=;A&*L)*V8!@zKK>2&V4Yl^V=GTxD41RNjn=A?q1cT0ArPjzhI z`pi04KOTL~gPSL_u;5G_c%zS~OyDR{K!13F)Y13mra$xi1m>*tZQY%Vew%RtcjN$6 ze5|#PzC5nw^R9-+ebT1zF)*xxV$S3}rPcH+MNCYmRcxzhQ=%(@$D3{PO4W>+UeF}d z>;O4HdwWyO!?Nt=ydfA?brtHg%hLfq# z$;24YyqwYxw;#$FyZ2!n=#C>SKaK75gYD6K(uqXo4vsbCN(4QQ-IuUvvbq-0cz z*(Qz3Xo8=kp>EsGiUurb<7-A~p1oGd=-XhvEuu`++H`D74!-#uU1g{RjU}JK>K3hQ zj33A78tZ%&MpebO>vXS%2~LxY6A_dd<#ZNhzIkW0A}+2y3m36#J=-Q{iA=&K6?6g+ zpm#G&^C%Wq8`FcE6YBm=q*Gh}c>MMIkPE`O;39n0tel6D;YvmwpDR6uoD?y!J;?+! zl%rBP1J98RI!!CH}D6NE6?~Z|g-&W3dI$ zrzyA0R*R!gClV%W2BCou=NPwb)VVUEfu)`(!paOpJ@rI=TW^WVT7}AJJM+SzXvXbzW=3G9P1GGkh=e96K@)9)!3BVU6~KMbuCwO2gZOD2V*j8RlnTR^x{ z&Co;jg@QcjV!{t(dTm7!Sq*DAjo)bj9+;DCq7 zxtYj@Y~3pV4X-zp2t@>q=mlpFNxsLQ@0tj;a9@gE}vG!$>tm zUtv~5k-b3@VCbuDRKAC7^OnN_&V8&I`wZI@kBJEA96gw&wLd7^ zIf-nq`ext_Ro|R)R>1ZzSiOrp@e+Xm#s}3fM@OI!G4qYHl~yP1rIJ??ZFMnBOydPe$(|#VFRC|8%9}ii z2h42CP_}yyNjt78(mAFkmIBaBKnZb;Y`>Vsp=6@x0k?=K()eK8!9|3Xjq%7al@Z2# zYfQ`QOt+zmc$R;XBH-GpC{mtfRE5#5j5zWr(tj(s(x*%uouv5kPbSJq#V}Pg%y%{V z(5Z$3SLy5(7J|YL*EzbYsO|%IE>T^AiCpWD3N3^w$PzzXRLv6|C4h?p_!Uvz!7XXS zl`YFrPt$8QARK?60 z-a0SK{Br{JU{30$aVxno&6{zuZng4e6L&W|C&#!Mr(5Bh>E3MieEn9Of?Mr9OTQT> z<5ufr-3%iv>t>v8b++1Od!;B!7PA-lQ7pKMP&1|ctZr&ZUL=y4N+zz;I-!gGt+d!O zS?}t`jwm8lL^s`uqFTym~}Pwe-Su z-|mbnbRnarjgoCy5~n8xOf&?27mgS*oupVoh>VVL8zMBBd|)>H$jFV9j!qls^pY~2 zCesry#WZsCbYhn5Wa1XwLYp*FrR2kyt#`b@i6w8k^pS$K?9jH2*=Re>nV_DwH{Y;5 z*Ij)83dfzy2B4&fcre?zefJFM%Uv6svaA%*DS2;DIzAP9>O~FEAL98*Z*ve?1eZ>~ zP4~eAi^pV(f)>25phAA8sUOz(;X#g*0~=4UbAy|*JBk^cmtAnvx3ijjDps$AR+-IOR_)j>S{LyHptCd36BFgTqr{XTl4Yq4%`ph zv^c0Hv+-eNnxt3$`E;6|sh(z_ew=~rsitY_a3@wy6SOGhG(F|>Y2ximIZbDVajM@- zXolmFYH$=`mImGLWUBXxAhjznr{!c}IJ69BNwQ5z*d3+eOS-GxroIOra{N6wj5Zws z=6M&d30mhLc3)`nt9X_(Bk{&^*eSr*FY7*;hu>KHi?(x&0)te`!p1s9(qMyG{2}im z#S0GX;DipKn-2PPSo=S(ZfU^tTGkjxbg=D`wff4%`xl zWkoiVn)n_r8gb<69CYMVwji$8)E!N*V@Xyen{XZB|B|tRs(o?qcfzK>5?mwsoTX7A z>Jf`zaQV+l=x$S4d4>!gKFuQpEtCowj@%6W$>E`9co5|A4v5pYWWJJJ??=7TmZdST z-Qmkf2>b{;e%C2`86S%$3ER4(ns6xs2tga7KcuAtrazxb)6><{4Aj%ax>F)Nj+5rP zI~U)nv~9B~hp(Kt4X9HUDeVUMC??R1GCUH7#AO5rN4Hk(#LU6KaAsz}gUqOOCn#fL z5y;X)tpYx-Z%~{cbr7K!pa{S5=J0J^7L)*OIqs_KgcwJzh>}tkYyq_t{t^;Y zO{RxIWh&{}`f6RW=sm>Grc-mKQ+tc5rii|?!aq@?pA@6sp=tYqWWJkLPZmqbu+5u{ z*yK5rRHU<8WCnKS)WohnBh#T`QXo#BQTncgA}VZ3DO!{O`+*sZ)Ip6@OngEG@lO9& z5q}Sda!t{_RPic*c~QY8@DA5gNSv}IrRk<#hH!ATB?a<*l-AsL_%R`v^3bLq=pz2Q zXWHZi{9`37Ow*QknB8_=f(BS_a|J1?hiu%SZ9G6J7xEIQguK<{!1D?S#QD!XN#i_Gv~mF8F-FV7L+c$uj2dsLA1 zt<^45NVi(^BnI8mS1<)wIKOR;62pJrs%eBxAFc-2fUGHFOnny1Xd+$99iYqpvP+8b zTTjxaG9pBqUt~HgCJ_b&0&{e`*Q3b}TtPa}J;m!+unk42iwwA6)qc_n%ShT-oka*q zlN5sK*TVM#)j}rvz5-B(tQG%XDi*tuBUQzejCQ8fLh=xrRFOE)nxN7{hWq0@ODqG z46hMr;FXz8@+Z?7+Pc&%E0)6Xh$+MT>v6Q}GRi7E8%1J%^Y_$~tlL`~u5vc0+bIsX z2`G^dh=^7G?PzVS$IhbCW4B{xS~p~8V_+2z>DLUtp@}^-*$=kCcC%*FFAdB~IO!|lVnlk>;hRho zUk4{1bq;4gut7WeNmB&2OZ!$wNm%WQA}{ObZ61q8GmTb3e#x9y*2Xc(1c9Q=*Vk`W zAeQc{1dA*1H#*!F~In$>H0xvABX(oki|)@{nM}J zcP(R||J)9$p-0oat9ma{n4l#^+xF@FGo$7=dAv(mGlGFQ9AXOVIK)!4+6Z5wZIaS^ z{3ktLY4E8A+B|!Lo88m@)n4G(td4(2b*TD>;NO~m;Y3|WmwrPu@h5avYjB0oc=-@m z*Jn{m_ZKbFP)E=xFk2&CuL8R1UaQ}j#>-=Az#_?Gjn4qS`UfjD;aI_uXuGUrDY{$4 zGr#-R4CL=e+n7B66%`8xO)5<`^j;N%i2$7y#|d^@B#k% zyNb~$U(OXuBh@V z>bNM=js_3q!1c}_%CzLOU`?mM6M4$2a1GP_`i2o7D)GiUhd1`cctdyXA}Lr3GrL-e z5~g1x3;U7qNKCvKlsRTLTn{ci#;|x_ARB71wV*N?)xM0+Z(uvX_QOex0t)6a#fwBBo6B0vKYp!<)Ye|75+TmC9S=ZW~}2z;(V z2BktP{1R-daL^shXdq>&RC zu|}&kMRLy*?Tam%P2sXc4?dGkLhH=hCMu}wJBf@?U~pS7jUN3DB2|6j^$JCcmvz zhN&QU|Kkg#{mUQ!G*!C07A4OL9GPS-B!~?qo?NJ3^hi6Fp`LWx0oQgMmlv9r&gi2Y z>Z`RP3xl2tEMZG{ibU}|5!N+%X&Iqn9)`5qZl{OT*Fmk1fv&vmuQd8ot?cTiH%xcI z*#&2RDx4M7E!s}krY+Ndg*dSvtgJ@^R+qBx98k{Eq#w@Ae$?n^rOd(#W|ocP7sVYr&ZQ^p;dw!qIqOrnG8TVnU0C z5eD>-v;jYfmDB#514Kuu_L-!qM%Kk&gTX~ItohSB82!vlMG7!7=N2bu-VN~P7o z@a>@0m_+D`=ni%J~j*T(4UmOj%48&_UbHoS#NS23%{*+cVsac zNzt_wQ^ZVJr4&8$3lr2hidI`*R}n=_*HTH*f|cf{{6|zK{3I$E!vDw()Y`=i-GxPG zyfL*Xmx|?#CXw`XT{%g^8zR}!FzV=?jihbqv(fhLP6{+!=`d3T=b@UmXo1rywcf=N zj*nb3RPqJ}7LGX2YQoDH*gf%9FXP!Wzl;f+X=V-=P1ht2Uhox%;%#5DA4!xAWn+wA z8{5;b<2vOPZO^>JD>}M+yH|AF8_uWWdADOmp&z_ziM&Z+j5d~Kh^7In;m=0eG|bsZ z^Oh9b0`=_P<&rpXlVTLdbf1BX>S6JU(Hq)yMlhc8qTX%!1)eXhEZ7mcEO$js6$Poo z@=>w)vGZv9meV$2;FToWDkI~^Z|R=b7R9D&WwOSn3hIW@Ud#B@%Udo-0IKjdg#q@s z8XkU}RJ_#PW1S`#6DvxGkEdx{Mj%|y;u6qiLh}H`&HDuMP5>~7=_&{N zUBu?`0+-{-d#G^}dF+!yZxN#(cUMlMYR^Iyo%^b(`f!D(oW1-7+b-j+>zz`}MPge& z%#bWYt+o6e(m)mH+#{L?+iW__hgS$d`g@3Hy zR>*VwRh)#-B(A=)tds&3aEL$?Wm*{D11{d zXc`_0YS(&TY3GZQFo))un7+Ll^N%R|rdrA;nqAnAc~P^At7iEmGye-_58|PwgO4-> zuC6JK#&*uSr>(_5z4)hp+OSS+NGSvy{bn^w({YAxQRSP*DN0hUY$~~Ox=yE`R+|}) z^d}h=g4Urv>s8Nk>=oCWs%TRpFfkPu7!wUzV*CSyM~u8<6A{X_xVzAlBw5DFE5_Sg zihP}y%?5S!rRxy8^8K4zkfqDl?iOPO{dC&CYv|j~dHR>1>H=tGnI!g+#4Hopp{B=Y zMt;Z_R}FEiou>Av9KWqQG0?i>O3E>7N`I&z?OLWjP2(Ht|A$56xcq%$mRu_wE(m_>9qI zacA}>QFIpoS3@GJf zx5b!8&^>PJpsQ}{&@IO4U0Bajlq|Ted}Hucsm1Flnuc{np~VsOv98{!GK*(xHr&Rh z7_%2vqBAp1UWA&f-neM^XLW!jm+|&GfLwbG*ZxTcJ%6nX;8`g{%|A0#1a?^wC9ziR zUS5(PQZJBeGDSBy5r{rKY8s4;L+6cB2IA0(neC>{<8yV|BW5Y=b+Glxbx&fEc{Uf|y@k}j~VxtO)_ z4fFt+1G%|7 zN1oFL{~XHFB=<4JV9)M8HUi5#%jZF2a#+1?%JDD7e-@ZpVCsrXlWlp|T$&VANb@nM za8{OWfC2{vZh|%riz0{+f9mRV95{Nht+6jgaM#hzwsRkP4~B4R#9MWGI63-PtzNFL zi8}`>r^&hm*$PD+VOc&}B|uwCIiCfff0NbOhVQ+e<#?-#Q6u0pVx~GcJ8(M*rLYNb zSz^hQXMA+xK<Dc)QqQab-2NXgr&P|MZ@G5@g;v%mB!!?qjC8C}m#%GnqkJH^v6F64QAl>ZSc z>sG8hr)`uTwYfs~)-Jb68EtrvX2llA#WAv=1@j`hF@U&pof>{e@ zT^QPDJ0{hHC4J{Bks5_0hpq!X@S2o@@D*mN?(!PnrKaOLXxi&NFl03y?_eUMgv3<6 zuC+K17To%c>RR0fd4MS>@SS3SheCDzJ*`I{8>FPWujo(7;+zFuSP6@YmOk8cVU_0f z0xS!#EWmO>w6Ex!QzQjUM27X~@?ruh z$5BNJZ$08OStmzSi-Lb{-o_KQZL@8xA!4~-5WqTJHh{ns2L8+poKDBa?oLRyMLGf~ zj_8AwpQb;0*siY6LDut(S;+dX3bM^D-gl0}CTxH^4{TYuLKgSYabLr;7Tp6sf8gv0+^XI4OJ;D`;D# zcX@R4q_+sX@qF%$lXX;7B7NGpeU8zCf+q^Fpb_gR`;tf~^ zs_rIP#mbACkd?{ntEjhY>UPugt>qz~045apGYMY48c&pj)vhS=vX*M5uj|ctIChZV zg0Msk3x#sc23~!}z>pe5nAbo-4RA>aSzv5|u`BgGNB1w&_pYz-V|b?7G(1EpopcfP zO#37;xS0YXTu076Z0yqqlWDK+dCueCngwv*I7#Dj{ZF2EJ>>j=!N?OQ-)wee$+8W* z9dLGz>90`pPyDgx|NM{NYnwb3-qtCQd4^&iFF6R-ou72(s{J+g4CFCe)PwMy?XlybFUFQK3(#EN6v9w$}`vgZ=%I0vl5KsA6y9 zrN6op+W{Zb^ylGo;3Ks4`Fw<)H7(DAXatViFTgOR`J8VC4xAx5sOL!>HfMO!$vY8S zWK^98D{xnQ!49Y$E!!ya80<19d3#p!j%&_D9*+R&bah+HI_48?@jE!OMy_gya?o@L z6|?|O@Y~5nx*HabZhy_<+QAd*an;E`pnQxY{9oepYW(XoOfAbf%M@NLuOz4`;OTZ+ zd`{n84Oz2On76(rwW(_%aCOXw7Fli^7LiUf7Ib=qEyG^LNmWoF*P(D>CJQLEBT3sZ zqcf4l?S3ig2hqDjv3yF+ujgaJ7xjC7^&2J(*1Bts;7iToC#lDxdW}&mT_qShL4fKC z-#vkDixbkQy{(>mwdO4bgSLbNt@{@Z6kAZsK_O4FnBil*f%?G^U*Koen=ceI>r0Mdx1RtGE}L`aJg{-3{97;Gj}4 z`oiVRci{@=yKp%_-GvK|$E=FZxU%(*GuxK;_8wW_GY+90w{`XLj+jF_$3s-Q-DMO-h>lgDP$qacj8xYqbCzVu zB@*>F6N!q`SJA?AwTG7$Fm2S{UjbM#j=X8H-IJ+ayl2k`a}6WM>FeHKFu5e^^8^=A zU-@~_A_P@2J*Iwt2*dOOQA%`a#@F*+xtzuWd4S4UCu zN$1g}hAahx$8S`!FskW7SymIs`eqaN$pAz46Ntuaz2kj+O&K*!A6?zNu7y0i(@>9Y4b5?l75*7kZxN;-iS3V%U9NWF zkJv_fV=U7I`TSt8eY@)THLY;^1YO-<(Y5TUP( zl1@Uqi|BrVc(~-fAEQFFOUGzL61*BcjO%Pf9R3@ z42e~sxoNN?K>shwB&(H!oMWywuqV0mR_^_96P58{9!t26I#O{B3-2V3j)>$rBptmOgUViG* zp%;*D<5Btx21%iVj%*Pcod{iX1~*gyizJUVJ_FzT7X}=Ow#!PElI26p(uAuCk0-;m zJ&|$>5AfGVuOE)`HO<$cJ-{iB+ev$&iwl1te%@2-F%1I&5>_ehb%1u#Q<2FRn>_l+ zVmI-`QPLjXjo4lp+;Gqm5)Qk^EjGn*bXIgySe$u5rv;rJ4xRd$slMm7b!lL?Ct0}8 z+XjD#pEulRcw`p1-QXk z-1aX3Zi+JD_$`chtHuL#1mxm0x*3oUL`;8$;eXsjX@shuIH9ND_J4skpdD?|W0=P0 z{@)DdgCH7sZ$XSJtviRYXf;#kfVp}49mwK-Lf9^n0z(G2#X+^E?-vcPLRfx2x@2|- zDKe*88#gfg{l8T_-W7JdR^WW<5Uo}1(&2Gk_) zZzf3!t^{W26z;rzw>((ijf^7_sHc6v$&`1U#Vt@|c}=hQt4WdA@K=TRxhKK1(M))l z&3acidWj4_=LvRS37i)3X9)yhfKmEHA9qAtLui_+qeL~#J6F_sGu;)eUlw*g?wv>& zRRVusZ~(4({sk{>x#F@MiVsJU?6e>=Y}4{rfCGHesO=cS1I--|WEDrQ3SJl|i|SIg zE5cp2!;@i?XCL`{bHFQMgT{G$B|Ne5i}u}AQ1@NGZRvsM3bLY!viIo> z4NQZTcWy4PEE5>}t#noNOr88d&*pvyVu5?@QycPd5 z#G(#x!U&Cb9A$aq;+2GDU6D0hmqR)?hi)O)Q^y_?ycw(|!|Ji?#FxE%C$Mo~|IEmS z!A5-D^U5ZI5C5n^iz{LX1kE&g;j?epf+T(d_cXXps~&&DD=@me(!8o><7EeJwHjc= z6p{5>yYTCQIb#G>aWfQ}DL2vBBCj<{)3E=2b^R(IGw^h=1pTij?4&Y^n-Ov}ZRTj= z^i-dqC;%Q*pbw_enx&#Z-c<5^SES>cCtZ=(P;KL{eN!Kqx!4j3^q^5=2y|2U_vE1k zaMaB~)iq|IYPES*JbN=G!-eRrIEj9ncx5#L(M!BiL)_J^VQHhQyecMU&_5Iu5I+_3 z39$CF^`alt)Gscnwg!Hyz7;AYEfk6%*+uh{rWqZ{fgvd^mu~ z4!QjtWGjDX*~2GyG?m(DxW@oNFF~(#ms)Ma{~S)>GtJf(L!AT?UlJTF*W3L?w_dND zI36HaV4pDVIgcp9eaBwWL+Td%hhC=jJ-z7bGS?KAjc z1vGCjs;Q09Lq(1-$&reMnunIS|094>Q|Tnh0KQ?l=d ztqHV&52BL09|%tPo_mfNP7nXMafXe66v3Gykq$;iL2WV`3o+-{G|HZIOOrEtsF{dZ zEuni<%7KM4SqWH#08E1vL)ZWR_P(sgaU@Cey?+IfFNhq;-#*=ND`p>i}KcCdRv z-Nd-|yH`BsO+RmX9h*LI<)&|T!RT!teSE%?c_*p_=PoG846|IB<;ovWu3VA2<7rIY zk*yJY+>!}lb&j*~DE$p0|DJQqF(Oil(vp@SU5v-*gUGY=PHFaLZu^Gjt>3OI+w`c3J&lg3xd(iq%A=m*w9AY~o+e6uUYz=k?t zTcK;6T8tk}?RS1PVBLa!z83*WBn0o1-(wcHv$(xOaT}V}$BEl$dT|?RmW%$~$wk!V z^EBQZiXwkXFzbK6FE?7#!^8K)U@)7tvspW-S=&ucupLWOmTZ>&?lgthDbkDg0 z{n|hviNS;xP2hF|J&IvO1$WK1#tG*Sor50IZP#rJW5P(_e#1Y7s2M^br=okdc`_(^ zkTI;7q_zC;jywm*(}iMg2f|su;yTB0&JkC49dRYsKSz%@I;grUvL1z_R5-VhvE5BZ zIpZklwU=4HaQR_hy8L*zc|Bq!n@b1HBIRFIq?i{($^ccUBV3$!h07f=g{fT>TVWOo zvrzaKttVzXdh3bV-pAGxi$1uXSR-9%>a+%BzRO4G%z|YWEFUUZq#s?Rz#{LNym0?k8+1FlbSuMYbmkdAL-5#24PAH(#aFoT{x zWdq?HUn897I!Dzy2Y~p#=V<_m#`h%fd~-YqmiHSEV(9?!#}8}}MdLw2)3!B$c-D9j zJ2V@mVA#S}3zdhX3YA}`rM4oUi9Gl&dx%_qi`oT_6L+Y*XWSZZwV5HqQ~db*Jmx_i za!Pefd4-$l9e-X=;2>C#GBV)ZcZVhDB`vK6W^KK0SMiZf%-p9hNlwQJDCmPy((Q98 z>HUGiGpS{|9ie$#A9g!9EjzRq+Y;veYkNY+Fd|^@ zB&dL|4@El*-EBCf4fKki)HjVink%1uL@5nrgZ9R5%+WZnlKp0rbdoJ1;*qtIixve*@L)F>Q0npQoz)uuSU5PDf#)91P_em6N#McTedokEh#U$ z1O`7zzqU@1LI8kbPxkXiV_O<)kaN$*bDqLmJWv1a=M@ml67A80ar!B#P^zrq8_i=I}v2#`4cn?_S+Q#b*+P z7hV4S;4YYB4tn*kVcOKw+O_4QwX_ykpt@IwZM;nOt7l@{FS7Ha_<D6b8Sc!ybaEvT@!e@LubdXOrcpyga?%{RJ!J2RDbsraI-T`db2>RC24WJJm^3XEWpKauUZ0>K?M4Ouz?XUO_YR zQAUeuW0m%&Ism0z-K*Wazw!35KZ{uXKf zoobYW{b^R9&7USA?ZAE;@(u(YXMV=CN>|TX?-b1U#9cJ&rL$i8UFoH2;c^qVcT>2$ zrh;~bC?zk$WihRBu%>94mfdKkVxQ8GpRWHAZMbb4Lg6Llm)99-y;P{WEQL6ccw%=p8tWdK%3fHmgdTji#V=s18z>q0Gg`2UGr3`!I@Hc_Pwi~sF1i9S%uuQcu zd?peKEa)K!q2o0Wm^8G9!=T)8pYG{&!4BWl?QdOircj{WV$t?^hig^!PcCj@~XA61I-%l_cy0kqdtJjcfqWz>=SoqgL; zO;9{JemFOcTbwOu?^n|`FhslnP1wL#P}o;nJn+~wh|wJg61`lDCGpwV!G@FYpN;N< zk>?~SzRHH6PS!sfzuLy;Q16ANjya6-b(@|Uuuh)gXoIi83Q1mII?5_}%8VEIRzrkr zXT2>P#}7rEX^}l8tB#UY7v;Xz9g=@0$TYMshL}zlDc=~+6>QKt{tXUV5FGCat*_aZ z6CLyImyQRz=12bioHcca9thZIe`Sa^jGWaizHOw-34k;5eVr9+&||It{e$uIz7`X1 zB*H!3KEBED$j0TTf^wO`3gMi`rU>($ z5<|>Z$!wL}gH@t3%lVck{VnHq()GG#2h<525F(X{0M-r8WGWpBC)vXvYHyRoB6Bud4kn&UmW&byx69(bN-o$JvD zo=r_PL7hjEZNF>ATUVb+LUSmgk#olr`i5ZpLw|1`T5;O5tHUgQYJRE);XI}p#xSKq zl4>}$u5933;DsDiwdN$q|EC=O;;8cy&x3tl5i~|^BfDEub6W+|rmO`}k`c_bd z>}2?L$U&8TsREwT(;=@yah+)H*M2gaBO`R!(K^te9{St+aZfY+Z+Dw z2_Ibve{~sz7CZuLLV}~WdfTlJIDiMCm$ZAUr*K5oNat52&@oQYvau*jmGAgc0KKR& zD>u-|ecf*o&%isuoAB4f{+&Vft!q0ik`wwi5Ki4d6inNI<(U&VfC|=h3&)DADdBC? zn-t!TCWUt<9edleCx!Rj31k9Bx=er?`ct4r(FCZDnl zn?BM4e4LtZdpcn;z6Sg1OaK~b9RLu7c`?BVG`LP1_dSft{{pVt_E+*tC)?L}d%y&x zab?mkk!}Z}BTFH|wkZk3Orjvg{i*6`>c0Hn#9*ujwWL zj^eNR*Rrbbs7z1A0a;0ae8`@)m+A1e$v*)-Aql5Q!jk{8TH`0IcDyOADnUGt^skh|KGR3okc zMz4i=2~Qvio{(XPbJ|zW+3E{6$alr!C14w;860ml^d6SeQ&#PuSNpL$SEhBt0a@@3 zrGqvL4T^{m6xBv*iog-iU(d^N+JI|0M-9M=gzb@UQ@UUs1MN-+?M(@+B_H zByF@r(^`lPBUqzg+m3>az<|a-WBLLXU@3UOMj%Eb5#{N#O6paTGCiFW$rs0@Mp5*> zKVln(#$=#j>*Gm^VhA94y~r&jQ9a9T9Y?$j@~z-m=-)uO{Y?~eL#}d8=@TEOh%1kp z{>X65?Qt}}WLvIk##FFnSx9R^p8378(U}icL?r}}j;VXj@|yXDEPHszcA&k0EEjHE z#|(x;9eJ-KcQjPvSM_iU2~~l|3si{T51=E}IOSqd(RtIqu{z5rFwYNY{BoG;gLtM-YDsB>(87}TDrba_{h{K*{5zo5PVFZ0m+#^A0RHRV%MS|unH zd2LXGKZ3@9nUUKPBaeSI4S7KZIyl<}jlTH9_0)`xzL9u=B?tne{!T`t>3f~5DtvEL zszcb*K4M6g(}N9vAW!WLhQw+RB}V-!azz8!ut`m>pv(oP#-Ml7oGP zS*CN?Im`=kgdR&{AUZn}&9Bs?15zlSO6SP9&W*;0!`NT_=CgYkoNYgS;O6H9! zR`F)>JlH6&b&30S z5D%Pb;iFuu*f+Wn(94yikr#S)uxfI!va-Sx{(uvXEJE;8f)F0oKbniQg66i_h7D7b zQU}qj2IcrDz5)rA))j2eYutbxR1kKw`O!NUr?8G%*9RI{$_5*%dL6snJS@w}E*%A( zBBS&*;jw40eooN)bm1+oja!<)aoEj3jhv{uSiQ(1Tm$SmlKu9X#HZYkpv!mumxLW@ok+w$~K&q7aUflhEahj&A^quZjkE zbH>eXJyEnjOj$*4gqnF03jB|57oNt-UNcMn%#wd^EcuNOFT%^^BNv`#PPGe;JL{6; zZaP*qRcesqA5mthF-whEY7Dwnu*n6-w+cj7L_4dAix?L+IOHzC`pCP0bHXKFzD`gm zTcem!r*!ewWZvM)@o0H{Cn5#}H0(}==Zxq30U#b|APpQyVc*aWrHzYLPLZ#(eZI*Q zGl-rggPRHe(gE`1q(=mb@6bd>vIIltTLRWR5`1&7|5t6m(%qM6dA&hX{>C~HFIqQm z`5+`INdV#ZnnB336CxUvnzA2Evg-PQa?$ zmpp#7U9=0jz`rj+XWx^cyW{YQS&N#rsQcHV+&6{G)%1IcQRjS=Q5Sx^QAc+$ljvQu zV44Nfzm#AK`)NIWM3f(VeCU|@ky@1QqEzm?8KX{cwRozz{?w6lVrK8=w(n*FO7hCS zC6$=ZS@vy=9J+wil{~Z|WEzk`9TXOhD3J7{A&7w~(hAnNfww3t6ha`O(3D}}ML&9A z(ZGEiAYx>Q1^0kX59Yi+lQ$}aMI&dFZb)J%4YEXKwhsKjX_>7-va=)^6kR27sfvBg zHKJ{&rN<`;#gy64uT}OfPnwi+uB0`PSoEaX$a2n7tYG_G`D-w3N1F(>=M3n~o`Uk(lPXd~;O0b0yFRnDcDy8gf*bVM?cYiVI3QXn0rcp>I+je&*3H>OBU z5eEpX1|}5*`*BGFj-0HIgN1qyOt#BcJSu^%u|^i7G=JLA3m~Zkt`Pa5mSqBR1WV8H z5)2lC^cKW#j=_xL+mx5<>E6CJ+Or$U<3$0s9!cT;#Ymp(F%{Y+Q>mjtJw+7EiXA8v zTI|q{%tZ?4T)fF8CF5bmLRxUsi_>7};%S?58rF~_=(yZri%Bv5b)3jraN(xoHh934 zvLmD4#|4pgv$mYuwdHxiE!v9HYH*BG4>dM5x(B@?ai5X zu9iG)aRtwGPpUi&8!@F|L$~C^XgDXhT>oJtIP3#DaySHPc<`$%v_Xr-1PR4HrIYQ& zDe{uz;M@|K)ULEF$6gdTdSnyUF(pN_kyFCzO+i(UpQ`d@|J-wLUTV&{Wpf^!blLAQ zj99kY6EGT@nxL(TpdHil-naFy6Cr0C2%|D5F1NsX+fF}__|T`MNvj11<-9)J0d zpAEsOI?`|Q4&ph_?OyqbGFI)cDP4|=JZ*%7l=-1MDI>}+M-*dW4>G%trB_5jhK^t_ zWdlE+6k(C>p5G7IA>*1c)nUtl$mj{t>ringbf`GdP=`ui0^W|3z}D|1a3^vSM7^8@ zJsDetL230jD2FuUz{%C$Oc~OWYX!Gz$+=Zao)=Evl4^+AR~uqiH=-IU{#%C10L0KW zud*u!L0__fu20qNSQM7uRxZRu;Pi^E_F?%CBx@Rr=PMS64_EgLyue(Rqob5D+!_DDvZ(I`{meA+cE$W8eVtLpI z=WDa0bVt^n^~icr=$!M&emIS$sBHG>U1}Ko9H)RLDwc~N7W+ckDTF9)Z%9#wiC-Uz z!l27JV+TEeIj16UuqN3zE{Fy2CG*t-am;|?wSP7+SkY)!9FONYA`SXs{6yTi_`^@v zS6`^I%$I>P)eOnP-7#;jjF1oKsbQ$#*EL#bW0Tpbj`FIbZ?Oz3v){;&?h3JtlMwvq7?rCq>XgQoXHop@Hp63XfH(J3=XNsO+gz%wMEdj?Hd81(-;Ah55rj{ zV;=`9X4NLAB06;pn%=zLLfPJ4E>@$ysi(?^92=`qc4xq@;| zaAWp+zXr}SM~OjI(^FutJ>^*TT6#UJY@b)`^=$l|vT*TmCnsAdqOI4rOcmKlwpWgw zoehGveSdmu>$}t6+O`ff-#2Ay(9xDIaDB8}dYH4N$rAE3X9;PQWmU5J0st)2Vee{= zRC%|Gx@FG(l@IW*$kCI!^&>MApxXt1OiXi%tTW0rsV5@EC)=aEVkPB|>^&iZ<7C$) z6S@|Wvut)4dJ^de{d`eEJvEU% z+qZ96SlSP)P)45fi^g1Z0~$*9&4DU~z#UaQRi+hsgmvFR;{Y*puZ0ih({DcgZhQJ& zK9d4%XHp|}1NWRrXZ@e|rG}Hk_x*4Iiv$L4BNJt4X|x4pMm3{|_}~o@|A8WJTjzL( zd}?iL)%yK+wSI4QS@QiF=ztiN6%-Rpi?* zq9QKFZ0}&ix#FU{*`UYjSg_zxo_(Aic$S7W%zfrtTM*3E0i+VXXt`!@zububatYA#V^mrxOBAB|Dwtc7$fMNYXTZL%|r3Fnggb$EUhCZCg^*A=CeFM@a~7|7NJxl8xB@)9ZQ5KK5+U;c)^h5usKKO>8`^KLm2o_4 zrgwKYC~udE`-lLWUy^^ovNfu6aW*nj;e3Qm^YuING;(UMi=^?djBDzkE*3bh6}lUf z^!3wKvb7d#ZIWsTI-_T1u{uJmqO9~B*^(ajd3t>x!Xb8+x?t*GiTn+nKuIj ziyurYSo}K{EGR_MY(#sJi2cdt6b9O^!Yx=#}7F1G+*Ac0sYF#ERI7|Fh0 zu?!VXNEzWkSnYFUp=5Wjcxp(Z-{iuexZBENvlN)6zzw8;rB@30--i?c1?ZT8W=?jS z_x>My@5@lJ3VQ$>p4JnUzTgbFCT#2;w&?bNaj`cIh;`=uIq%Pp+@DQ;m}NS?tSe4I z2xP0%-)&sifWmg6HF;sXZi9j4O5SL>K}g_CSkI2yo+6oDV?g~ZKW6zc-T96Szun-R znR7lQyw3m8Cu05#uzRdD^pQr6dt+(j+*}$Rwo8x>)Nv+O77Tf}P4$_tGd|)v0~w|s zsg{f(s}XP&aUpj0C55f8@V)~U45s(|evtH^EFdQ&l@i?>$Uo#I#`&TOHsvm2 zSUm;WUf5TCZ!<2m<@=&YRNK&T35O5PsOfer&bGMitNKnYrdh7ca^+6tiWQxeE1g}X zORBinN|gcVu4$d1!<-~{F1U7ik=>NCXjX=1q4oVMLsV+wywJiOrAuIW-;Wy9)Fjg> zO~Uu2i8o7=S(<$JDo_wEHc9p6jctBK=FBA-;!LRYI8kt&(j%E02PszOccNJ3n;q9K z!DP-Vflkv;@AHy-mWTS}p$uz!+-I-^gu6M-lc#N23yEZOO3wT%kjF@Z7c**0U7cnG ze{ep_OOn%_9*l`rE69A`c@ti537w-qVL~p!N%s-gR^55Ng7bt%^O1YZH$)nEPTl}L zNoUz7Me#^S32h7ByI2NciMK)wk--0qzcvH>H>|;8tSh`8qr)euI=m*IHD9vPPK24F zXCJUWt|6*;i=?zPuYW6Z%pt~9&cuMFNBcfC+Eu~*9Ux=&Rum@thL9S|LE7HJ?mY&JsiS%D$JZQ@$uAWQSt+DtvP6YD^{Pg;pKLt8$D6qp)OOtix0amS+?Vg)FqX1O1S7(kCp`g zvSr%dVwW^^=ZTqFa4io(gY~5k4V^98&|EKCkd$B;z@-hvEU1n7*?SSo!w{yrW)>gU zUaa8g$3Wa_^BNOD+yK0)A__j}*%mAySguru%o%X;-$3p`7Cv(|v#=$#ZR8k#KW-G7 zCR#z$A7z<6mj$!;jd0 zsKj4O&$R^f=Nf8}n1Jdy?@eF#TlUW|f74pA&^h;sbNtc4+9l7E?gFXm5wug;wm7Rs<_HK3D5%#kHk$7UPB*1HH`pOWeEHs@n;HaJnm_T@8KGkd3@2=_6-i&Ds}l?g_%Z~umd1e{ zx?}p*;5ei9lY$dCdT=5=IId}r!@*0fhO$HhPBdn|s^mp3Cj582f3C{IQ};L}!HZh~ zG?HOWY!y;9RyEi!G`!=A5yYwtz}8%W#DaIxWjG`4JSFc@f2{?VB>)|gJB}V8K4OJI zHKc{CdU_R%IH+;(2O3U0?kkA~Eb>S+^nB2b@ZT~h;Bf}s7@)Q!8(tWR2@@X=6W$7@ z?YUa|=D6)mq3oLo;^+;#F7h2L)|?AygyibOJ5)Yb@Gfg4rtdE3?=zctBy!~GM6NR7 zW^Vi(oF6%2%AT{F-DZ}p1tkoWX~%%(-dGrB!81L=r(5J}ajDw&=LxLD5lC?Cuv&wk zj0D4qdmY`7FpILThhw6J%9b$3b)bYLQ1<0A>ab-e8*nYVbz)4^hNswfFdPGB9wR(i zRrF+0L*ZPb*w3LdNWumq+_iuKuL;{XMXGWx<}m^-XU~K`Vw;FiJKKb#4NU)-<1x}~ zomEIRnH4Z>%L)#d#t<8E!6h5B;J|JhtFm`r~$N$GJs2PNW3I3|?Lm zD++XBc`g_LDV7kxQwI(BG%hIs1n+i+x*sLD*D9fx;Rn0NWbr-MbU47B)hC8yzk3}o zUxnR%ebCJ@zGpjB8RmRpzhQAhCj|s;^W6f1L%{=Ek(W)F%k!YKNkT@7D}bQlWtqMX z#tSXi){1Ahmr+{q952*~hr`Fmu;9_--N9x=e^g1M9X}nix*mNQw@Y$%2-1P>jsdEY zG=JJOMdr4@AF>19Uahe($2v~7uR~DT$7PvgdiE+iH5Oxxoz~dc z?#kVv5LqwYn6&}DNQ4z=+RAOm_@jS^+;v+u+R+(vN4={`R2VCn>I2J#4?5uxRle^X z+IXoAEb^_uYBA`r|C)MTUFw)G^3B2j8E z>`gF4!J4bHq~Z`^*KXaP$!eAD_V`bY4fuA{G%(`uyNDk;7F!=8cV`H6#mbJ^>csdN z*0rqn`Tl@b8D1m|hql24V^5YbiajEk( zqZC7$@licQof}3XI` z!J?PsRSSHp(CC2Q$5jbqkd|~a&AA)?7$LgLR(Vq7|H#A*{TQU8SeE6Nu?A9osJ>== znEE45JuKWz<(pZpY;AeA1x5lo*11fV=8?e;u5KfNKQ8zJE90P7>}ytxy$_;vBXQdq zE<{ac)QmTjYRvW^K7$fx^*o{he@q-ZcL94t1L&B-=c4p)Ool-h-nZJsyaxXKK^SwW zRElYvqnmZSk7zb@Z`CYYU1QZ=bSxaig}5Cc;R*C~o4v&8Vf&N~nSBKjQwWuq2o%cW z@L+FtZ>6;K67-5L>cEbc`w=N-EF*Bk36<1cBumUe-^%zpN`yaUAsEqy+qR*k@dye5 zO3rKx290P2%+S?yw)(=X?ifHc9L&ONRDEEPJ}}Vvt{;gr8aE}; zGBx+iO(9V}W|UtJZVs(CDH-I8aGEBI2~IR32_YQvU_JGbL`u;ITSkVEcL;#?&!8&R z&t*ZR3<|Wa&Ks{md0i%}FN4tB5U+Y+`q@m0*ZDV0 ztr{_iwhM=?3dymC@e{m0j^-F>-}2>&%S;g2NIa0eTJZu)D_&rC`iHz9Et;LgL5p6aIVXhDz_^Nq|GWeNO2m5y{;SDdI3EQ5axrR_oZVC z;@Ar%8Hkx-msOrV4O(lS>uBhc8*1%SXzGOXea{~KioPGYqd@{Pc}9acIzYm3Jcy-X zU;_^%{dkaQ{3;I6;WQq^(gEUYnBu_JFeZ_PWgnWhr2)h<$AegoqXER#@H&>S10>Y& zI(85>^pC@j-^gx#pxbp`ZAK{PF)=_3!mlP*5I!3Cnk}B|SX%KsPYYf*99ATKn^IeC zqGs6**Z)r8jYjNSG!ovR5j$+c`48J71D&~l2=irhhiCc(*iCb{p==`RR8>XjP%Tx#LVCk10LFi8h9$L|K;8A;k zjs+Kchxb)Lu9+SBdHA%CcljShrZ*+JH7UA1DY_F(iSAB`9)PMNv=B9kL!{94WqLZ>hU3`Ny?`4` zxaPfR!l4g5Un_Xy*jM#G%OT4dc{VU=BVHy&Q8K@~h*#0pl6R|?Y&%qLaJo-uYs2*> zZ^Ki?#zbxS#1FYby+_0vuPL~uZ&cn}jo4=AN^O!Cu`x?|>NY&}?4PKO!1ktZBa9|* zBQ&QUtI)TmZo_dMbMiKnTl=Zna5465>Nfm_cYQKh;s);I!rcv~mnCixO};%2wTz|5DrdkOH4_UOP{Rc zEReN`qAuzCem>GuNJpu3rF{DubAi8Jaj@smU5)5C!|S4ak@@*zmYTS2r54{%Sd85j zu=QbyVPTkkxGa+@9Sq4yl@y7(%=Rx?CJH8w3`yN&g~&X28g`sbujECEfPrB`dualS z@w>i04jDnsxkQhbTO=85Vl=mURP7Wt|z zdh1$?`gOY+MqG3Z7TSv~Obf^FVWqs)tEllbtm4_Ro5U0+_n^*p^2?FgT;wP>7kVQ| zhwEh`vP+cuX_)DBsE8wa|6C6M^E`cENJhwMA22WInW+waQXM5s2>LR^aQZOj z=rs|Hg+X`>B`R{l;8VOk6JhkglP8&Fw*royp^wuxVrG6iY-#@cP#voc1ZE&pnbHgo zlXT=b2Tm9^J~C01L(Mgz^Gh~G^h7liv-NoaSlX=HAP13jw-;HmJ?vzZ#1J%$xTWau z-X7Kl18L2=vFpUgtY{gy+n->2)C17npf`G-O3iO|B*{U!+3m_|FDch7(+?N*22~Up zk|t6B>TFmpisk9fMKRq_a8#9)S_OtmDk{GY3a+<92Ttd>$iKGQR}51Cs!xJtnAeA* zv4%!O4@Q2ZC%1{JC6o3nl0z}opui)VOK+n|@HNkRRUA9UbWQ!3@cz0nu|$e?%s@Y; z4Q8KqOjrNBJJ#iQzgjsx#`H|xm>>dlzq;*cpCK;{b-%c8YTrXX@~Z2F#0)pxkoq@z z5L&t+LreR*i~{XriHQpDKwefbwxw`d&@WX*@PGpVW0coqePb$ORc^rkYkyF_pehlAng!-% z)7RM$wPl^d=5;#1S&KTlR2dT~ps1D8d< zqW+4O4eZ5H&oG_T5PYKW|C#Y?k!&AK$B-lXqp{8Kx5AmhuY%Sb=-+7Ud{UMWpzN>L zrH||M{U!-T6awB2Xww@|`^t2&JAiqE8Quenlevyeo@(=HnFUvg`y1OJOyptNFvA98 z-$S|?nXYIJXwQm!F~$tiDy>!Y9A60jzRD$r+@ti`;>OHfFrrFUFtqE9wFc_8CD;V0 zst#oS>>XP;+Rgg-aGUExJHCD;iD9R+o zuQrBYLp0!3(*sTH!>UYC^5Bxswky0_pt<|h2nQB%M~6vVXCM;y`HIB!ucom{cC|rA zfbpwstP^z8q5KkMus<8{rp6|#vf>q5&mN>?H<$>`c&T#QhsK|aqI?0lvqWOKEPdbw zXF#t(birFDRc4$XW1sN>s;OR-HTo`TQGA9ML#sB0uPcct1=1(+w;~3`Cq`a_G^0z| z)PR#5Brc4X505a8}!P^480U^h$a^&h;|rxoPTk4JYba%gM|DR zoj3+q%`ey_xqoQ^!hwtHr`*D%7Y4m49X{LA*d+jj8y%NyY2^DKjQ@i7|94pZf1~GM z+A=y)w(yaG4lYobK#sr{8#ovY(w_=k@w8}hvluvf$rhVl!WzYi#<76qBo2<|Ea@KH zdFM?2^q;W90T!mOm2v!Gjf?z|>rb{{{S96&!Q)Hp`1YH>C%Z?Y!F$ZU0otR`OPcS0 z5{F0o$yxQ`->T$=>Y>Pbb6jq#y(AEH%9J+$s_pyFHtk;Et`*-9%dlU+S zoVE7rzwj4uw9}>Sqj3DG-6#6I@=&XnACJQ6Xz+=pj1&5;o7Jpz1G*09=mt(T66|uT zDo?Oe^YNGe_}LJy&JKd;2;n|5=@zdxWkj+R-J-PoN?5HAyR3TnKO|?A;s1Gy7Z|*H ziOmZJvHkW}azhy~3^4tFQe^lUr3emy-w%03BZ9l@Cth$YewwYFrTM=8(V(mB2iE=! z(#}z6*Cb}Yf-rbBVB&u(bKLEuLh>#mt?5w1W_>P;?4ePa4D~9i+4mJ_BuF^gs>w%P zJQa$x_i9q=8p&K|$!389tr5VWMnjvhiG_lVvMiyWVjo|OHHbbMQL$B7AfrSf{o7#u so0LwnR6xgtqb6a6JaC+crauobHC6uY(f;(OfBxzJ14DD1B==|r08i9$KmY&$ literal 40716 zcmV)LK)JskiwFP!00002|Lnb6kL${DKl=Xv6vTQt$scPxykE4Rf&>VRB*=#C^OA#r zpv0aTdMJ`6shK4k{_dx$x|mJ>UX7sZ}!>2Yj)`_6y< z5^a-o4+M5u-BN`wqBPAnQJt6Yq2U?d<>%~!Wp&85NmV6z248xH-h7#4FL}9*YW|Jw zHD8q3D?aKpAAFWc&EO-`32gf8?_xl&(Uw(pRI~6UVQE~^Gp_iuWYOj=TBq>kF54g* z{NDF;!RcGId1KoMCt_22mi1KVPkh3jn>ViBA|ObQcwS({alBr~vFE$YUVAQcE!#39 z*Y%B!F&p2S8TkK*V6!&#Ij#0ONt1dXmaNLt z9nD8rza{XGU73ArCVP`cuv#z4My|}aqRflDVsRMl>bD%$V&8e1CM@i|mEEpnvjqLu z{@xBQi_#8VS_@du)e4t(wMtq1%F5L$DvDH$`~wEUKMq-()Y?D&YJS%;_W954pc;B1 zZVnQF_9Y54tXQ;dpUywCBzq0FdAv(mGlJ@wWv@xbo~v^6jDvaROn+BaLo9LSqAU)% zh#-~!q{k}_KGpJU!=4IOKK)yKb+W;60v%l2U$g>; zRemrSc+3h0|H$Y?`0MW~Mx%U9^L3PJc^Tt&(g0TAt@sN?FEtFcfZ>-K{I*P3^}BDK z{aNO_TI|jw?$7_JeZXE`7|iuEY#7)KE6;H3mDrh~lrh-#Nw(VV>Tt_oFUBk6z3(i~ zDdM;m6_O1sZw0%ztiz1`s7Xe<^k|{J9ZfC!u}OC^?D9>TXP3WM1k<*!lIj`XV^?k% zj=&5YV+CUKXL#EXrYV$PR@V$9gGFv(x;vo%?LzqgTmfW%;Fc1T3M;YZCH zo1@o1@xAw56Vv&Gi16c|wRTns;86An5?~UvG+Tk0Kh;( zTZ3Zt&f=+)p5vb0p2xT~GtSu7d7LR(4Ul?Mpi!+CIp=`JUV!FCKyz}&goaeL z7*usYs1G(>SowZ#kbi>B^bGH|j%u1X4R$*G$xaOL;OIY6%m5~ml51v9^uTDn>j3GV zx!$zibG48L)X~Fm=uZ+?2Q;c<0Q?h-wli-60CBB4dgogNv|$f^fKcqUIH~@eCxDi8 zf7Cu;3CbwrBj+Qk)B~b(4Io~l$m#c2!{v=GT|{mQ7GK9;=$guRizar=jPOa{tvuv}MB&pWeNg21G-iid9l@HhD!$ zy)yK`Ggh>LlfSYMmM98~Xpb`;ekS!B?$90VVU(uV>;kYl=3toS>>L93D-Olmz61>= z%HT4cCkR$km^qF$irF>5JO^fy!T`hLhFds$@>C^W`seV{yBII&z&nCS{50&G-ZukD zdH#NdhG%uM5de6lo|nNRG2RZVynGf5+|_m>eh22N2@^UK+;KSvZ4Jv5N9BHsy{+Z; z%K?J@I}GlRxN|jetEL;URv?zciG#xhl>xQRi59gq*~0F^A)vIn06lRY%1^IA8QN`P z7CBH_D|x*O(cF)lCYDc(r)#0b4-~gnD8s0}jvV2VuLnoE;Rq_ee~OAKy|T~HGjyp> zN)gB1S&kUH=YCCUK6UhEm!@HNVO8R)dk$Bf1y>hby*aMx%5YVm2UiVMxO!_u1R%c; zj3sd4DI+pugjJgNENPQr%rMpIhn<10R)1T=&8Ro_=7--wOpd;#Hw(#`7xIt(egoTP zX9Q;uPh5LxqMwmpQERlK3hvTh5ba~$7ENY&4{a{1t>tnP6~eBi{r=yo#!b?IRp(VT znryAut<$6e(MGI{GQ?Z7+wn&x;A2$hTVU7@NP1Mdc-g#x^qa^*!xt|MKH0D*vW4>T z@auP(b4+cV*SqyLsnH&QZ=}JhpM}(W-Sg69UBYzYnOp*#Diz{DoeU2d!ghRpX?xy9 zBkB9&A6`o9Jw;T|gU>G|sxqBf1F?Y9gB>{?m`0C&hmBr+Vhjdtct&c=1YxZ6;)#!8 zKy4vO`h}t_{?M#6=q;Of4Sz>+_AkHwzHj@2bGxd`9gbVPB#b=YB7*RR(*EU-e^M2K z!NXGyqzBJq$F$)*W<^x8P(1Vse2T8Yn;Q}@P%9d4L|$?uHg+IGKU7n8%^}KU^<Se- zMmdYcK7)UyY;%IIQZ@4Y59Zd{WgN1Hl>%_IWz#Hp# z_@>||5?b6fI%+ZsjJf(i!sBDNrHiq+35rv&Gl)vwr$JC*9Z=5$UB8A?M8k>Ic3Dza zvp8M!*_|$za5(ZFk95B{w{*Xl*D;&qA)f>6$ToA^yS2O62p1dS>^4IBTR2NV>EvcJ z{ECYv{Y_yT#QAa*5PDV2*1K0EZ=0Ujp!AV2F7w1P_s9x=PB^OAKixm8orG(?Ia>h{&Q zbBcuCd0@Ru(m3Sd-G^#KQKEk=CeoB|Azp#&$#`6FuJQWY#j9XH?ka3xy6{kc!lPgN z$T!h?m%<4$X;lQx5NV!Y8RJGP(-~t;28)nlh&w^*RS5b-tWv?X&b_?31gSrGkh+}I zZXSsY8IM|ts1uRLxZZ0?U1`Fr9cu~3kK@rWXL!0Cpw@HA!k zo^pyi_@m`7bQW^~$E-NCHIjAmw_Hx)2sLcSGFQb8u3$0~kB{+pGh>W1Lzv#=+G;VwO~#FUyv|a` zP<8{CMbvXxJm+h)R;$xBVzzk?(>y{4H8*SpEBrI8-l7tZS*-1Z-iOWm6~NKAZUvS< z7%zP{!L2GB0UG<|4G+%bnDMI%C5pw-HhN|AlrKQg~(9K7_uGFZz0EL*n;M$bc!$ZoVHH;5| zsM{ZPkpXeITMMqH0&o0A@?E2xWcU|`2W*l8r2RXd`vN#fXn%e+NAft`&>MUTefWe|4pnjmwrXN@j2MCWBT!6*q__enH=9=&wb02q{RJaR7-e?W$ zw%+l4*Cb|7HG+$X{NnLz(rr29&~AVI?Q)3v+LO)izN;1ch|(R-KmXvI+j@ry&hV~E zBQVceb2~Ab3-B($dwbwLFxC3D`$Zt)Rl#Kh9W2Z&XO09p1aJY05rXZ@E*)5^F?@VumF2J^A8n$^A){s- z<2fEgWSWk5-#l81#03Hrp9-gJhMpU4Z4O_sy6@t>S(Z~&{;K27B~hOX#fI4#G*E?Oeod)hF3|Vn!_MFuWuxPzot?4q+6Pp*}aAcI|2j5 z?~!PP^gw{)+u2 z1e&bCK=k|*1?);y=M?GSBGU7yIdV6I0`2Fq7Aq{exq#C^Y?%~$5;ln{-?4QB>=2Yf zfk*H`RPwty2{7M=&oRU4;U9N;=ZwZ99K`@24Pv02(L=PNS7{((vKF$1Jx+O%26mHp z0SSByw{qX3s*FlW&pd$5FfP1u9@?61-RY<~JrUb?Szsvbm5PmZ_l^~wd&kyen+VR* zlq#A=mD-A$3gcuKj9M^ie7rqLy65Bud`A7+#)q7H)(SQ;?UvOJB`CsJol1ZSNOadKpdr>AO{y3CBewYl~ z-WaPePAdElbTmm#GhFXysN$s1thV6!g6Aqi5KK?1g+dA@#T^*>KrQu$F>?uNFQd=w zPIt;BwQ@0=|NOnI4B^XDc(|U?)WhU2$0KQq z9cDZ=WlW{~-n&2ojJ#qQKQ;jF-?57r9nkIDJ*Y2dsZ+evDSo6n#k{iyxneDMMeX<~ zBVqIb!wL38lMWoeDMvgSxOMLgaNiA%a3lZ6n~)ma}*8FpH9P{J`M*f0SwT% zudXFl3L|M-H|#lLpmjtTZaOm$9b2}ZANn12A`&JH z2~rvmdKSfuB};lr%(|fbL~-72|3i^R(~VuP3bsk2^htCOqRfTgR&aXLF^NZd2P+D2 z&R~5H6N@PXd>w7xFOcLuRmY{%l&8+dIcD1A;X1GDd>iI3yGlblBL`_&cx-*JDCHMrQPi=Y^*?^Rp4phtMXAV?z0^Er z35~w_%gZpMJD-y&o3{YUa_Fn8{PJaPX<)dgb4wpd699q3w6ryY>FTukkZlym)26mJl6O{% zcQ5y`iK_L-ZZQR_+!ui`5(X6+OEixTy0-d91`QY^OMe8wZA40 z8pGKKj^w*&f7!7e zJq~ic%d{=JdeCaAx=?|M?j=4qVHIsk6bsgrC=jAEs*7b?snfLGUZKuV=GKTJD%3pb zs1wii6+unMxDg*`c0OURhf&~DD<}*Ol{}?qgvw$n;$xcn89vuW5 zztQ^y)|{(yqrGJ-dkHvDB9YdWFcEKTn-au2-|i=4Sht~!Y3crI8BSdErw2v z%(rdHy59J~iJHeU*J^5Y5y#QnN>iY#W5hrl<`gXeFd~6%V%;qVF5J?meUi!e3})AK zr`4f4py#=c5_<4r{0!HRbGAFj#(2WsOoZD3Ur(aBYVi?zqmv5zdynU3n5Hf5HoFX~ zbBuV^)70H2t1d4JKoaGw6hcv8u#Ru0m<*ReD z@=8mT=;&7p*H*`XG(z){e7C!_PSB1FTZ1{N)hQ@nr9>-5ZdZ)Ww z!-)Sm`#3(+wNpR3Q-u*?_JT510ht<2#J@q6!RI&E^f2+j3OIH7Vi!g+k zl^7GhsYV;dJGZ@tS=T%c|ByEEC{6Q?$VaL68T_zH{>rp?SK@F9nD)xsrZ`Q2T4#6l zO)1yJthOm`!2@*D^X?5jySC6p9LDSgouK7RIJD%^FG+AeK^Y^_!xM|vY_~}*#6y&Y zi4Iks>w5}#&I9)pkeGm$!V2CwO6!9Zw?~JDB^qaG=k#bnJzejSt=nEx4-3af35`A9 zQ5z;P&R5l5uPpuXv8HMjkVWlS)Q*2}wZptn?YJ5Y0zdc}uz=T^TT1vWQ1A~91*Yq+ zWGPBffU%+|K+bv;7<39#8s3T*uprvQ#p18BrWg;6E}spL`zEL_6ID+7@X0hlDmu3A8y1 zpv_PPZKgg8wD}JSZQVLo`)+x`8`zQ-v+{yt_r$UG%4X=6YYQZ!a%?gwvMU>pBC@>{ zaiwLR&SoQ--jX4{WQadphIlK;p6m7VwyzED=R|yS5|P0i1j})U8~l;YdWNJ2%kgi@ z&~sGfK%C~`+lqM%ml3gi$DO-Hz2(D=er6u7(Jsn-JbX(5kAb`wv)#V}KmhxD88Ask z#j&R_v0T+msFs3gHsiI#^I{AMQ}-v6@b#%AEN}8CJl&i~!gS0wdyneKvv?F4D^Nhj zvaE^kV|BOQCS@N&B>J!^K?rYF*vqe zCC7Fr*yuy+heCW;p1bpX;C}l$@a- zgK^dXs4u7ea@s$(0nk%n0JM}F0Da>I<}v41<}vRE<}np!rLGH>>PeF-v|XN;(-}^}ni5T{=CNl{O=A*)We4!7rb-*iL8rx! z=h@zdUE9?LpP1Pmrip1JC;t0qkWhquW$8F6;Mi{e=$Swj+ThhlQ2UM zcBbi>8WE#e3Uli6SD4QVXRp;2oYdEBW4dH|E4ZFw#R2(g zN4>sWvFOqC+Iu**?{2=ky9&Y9yH`+Wu(Wf{%g!#jz`uF@A-R0#)BuPX9Xwe-3Ku{D6vUUYYuC+El{1UZkkhUFF*daqjeXoz+W{;v#We!~~>| zgnjm_be$c^6mWe9k;qe}pcqj=XkIKZ|7wV2wXpYHL=1 zGL}6Fo6zR{3DxlrCT2%W z;(y^W_I2oPV0;m}dwI`nCcDRas{(si;|JZB8FTzfngIq%zQE|-x@0*9RD);l{R~55 z8LOHV%~8PnK`vp5OIYF(mU!(cpj$=@Pt8~3(-#h@+bXEHrQk5{D-7TKa?><9LtgdMWWD%s6P?oS0OwH2)7M@$r~L(#$x zx>3gRU6t+?u(3>xD%@fAS-&w8w=j?Dxp2tBb_9zfprMOF^OmuE&1=o)y(BvHUMfDRu+*NknBvd84Z|e+Ae~`8`NZ zYQmNF4OvE4^8a6H3kI)pWw zgY0s0&nV#G+bg1WxYft>bM6p!4KHYChj)nug=&cUsDZn#3G_>>^L8jeAz>c?5XCXV zBwc(L>of2=%X=)O!1xpx%xPb=T=SfL@p`A4s~g@b%gb$)CV%B5F}UN(3*rP-3lVN6 zP0Lv{twq!N2{o-Xi2Pr9nmz^r)lr&;^jV0Rn*HPDQio4ey>V!3DB?%s2}*5{L`fct zO=SXkponj~3daOq2Vg5rdAsQ3ew3Lcx}Wj}?z}Thls*@qHWQrk}VdGs*NyPC(`zHS(`zFc(`zGnlWQYchML+)wvyUN z)5}czlWQXxlWQYQE>!K93Th*{-eq-+t|SZcN0kQJOHpf=eTe!<@h*F1+<{N|-6}Oa z@0^0?F3Bl(NKQFQ^Y7;Nzlh$!T)E?p7*hRs1&ZHtdYaIEQ0jNWEMm)YA5u(ki5S{; zdPZ=(LSLa>bvd|Z>%S39RFU@E?t66}mU-XLHG#6}2U7b1l?Q}LCGn1~AU-|S`~5;5 z)jAUXIZM`g5Q`}${=nw(%Idqug&q4rmEO9~ItLa^Ub?maydd6>IdA|?;=BzlII=(9 zO~^AI_gV2~glMRG@Oo}btInlW=Zr0fO&7cKlGAKtr{KW%PxUOGxgwkIoT$ilmi>7A zD3XmR0bV_50jDKcdCIxxbkXX$tJ>aibeK1E|G{I-~S ze!7ZoTqEOgw1)Ij)#Q;PVHs7GsMSZX{)oz2v1-8}N!fz}0EI8h`0UjxW>_w({94qT zApANf^&;<{)0awnkMSAx;D6v*jiGs0+c++A)z@zzjIVDb(KwlHExp4#1|MexATW5BXqPy{l?2vE4s=N# z6TrijI0pa`@m&<8BkLO288)#fFfHAWiNq9aVu5t_4@PC$9+eFrha9YLKTxb+S`h)Pj;9i@#c`~WF9 zJ^bS-1xc^b5`Imi>`AvYIirVq0=-Hj^pzs1=kY+U)w(g!OPZ3@k4ie+B*hz#%*SxI z7=6T>Jmx|)CCi{Kp~8pU2^@;jbX8 z+;=b`n{zk}&kZpAXqWLIsuS*9HLN@%2o$nv5mqz{pdcYSZ{Cwpxd(62j@K!$eAe2In2a-4)BsQXC|Mct_i^sG_V8>JW|lW(G9$lGJ`kp zt268P9)kO1CmTt2nFVG^HZIA|zHO3S(6yBUd1Mx&bd^~8hRK3Mcg}4A<*$swG~Y*Q z&8Oyrw+~#J=PseuOK9~-NPGqOuEUeE#lxwZ8#BPQb2VIFqlUX@C*Z!|Pje|- z?S?Q=0L=$=q%23laum#%Md^CMikshB^b^%4J0UCA=T^GDbphQDF4WQcBcOuX0LBFe z7999VaKH&bBj8oxTWOf0S7B}Vs}KM$*-Fd8H$%ZfZo(!fW#*_Z{k5;>85hv}@{LBL z)k{)Vb+|zXN?b;t*^;2SBxo+RxJ2o55D2;(+OCWjW+ zeD1{4CG>uYUj4@iy?4CemZA4X6MElDv)b@`%YWSP`!fiJAB5<3bsQ_0uH3l#DG^Ko$gB5NvuLr0(6W3W#m~6!s?b3nYnInSAuXW^fB2 zOm7QZAMP`dmdH?uLdOg`ZJE3;dFI z_euvstF~z)EcUm{tD~1}y-U)V_s=^*IWaBrKl-CTW757!C(EM?;Nn%cAHVNHtsiio~RTiFY5{ zd3Q^^k0unDgM{v--ck8Fk}^?&((uchCQ=*Y1oE^zE^>A#XE;|RJ$1RO@LZ&0v&c*MnK+u!QB)Op zC&7ERR_7X=7|xx!43B{XKq+kMPp=VWit{l>dYw^5X>G4%!l z!|)Ulvi%8!8cT{Fcq)3o%#T~)dM~=VEx~;Yf-MMkC4%{`=Si0j3Ye6yrXKvn_`e5s zTys3BK`!xq^D>0Ril}dsI#b!r$62Zwx72)@N=b{{@d~6^g*~ zL7LwP3^`3~xZ8sWX)7pAph${mF=nkuGT!z9t0imrI!~gbw7e{gKmt)gP1F_WvLldd zQyO>%0A}##c?uYmOb&m;0Y#Z?*Jxukx9v8m!{&3md!mLgD($jG4qG?$UX7%w^b9PT ze1hJ^?Qc8e0z>KWOkF8GSN~#s*S~IHwt62x8pm=8YEw!3`dp+f*O8L1pd>)$jI|16eC}cz2=Dte6`GvZ#P5j7H#xFm^oCt^3G6vgy2q zP8Yer3sxBx zC?F7p%OB5kLACxCm6z$F6DOc;VgmL@luhYTGA*N3 zVtCw3n+P_UxNYjfetPIk5y8$Q%xtpcr)8K@@~GIStnWXLVyCLEFksPs0^n(4hTv3* z+2;Lv>zUL5l2o{Oq=yMVIW|R(RvyuT6-7P~r((963nQT*h+<8rQBxET`z z+gUMm7|XSPSGWkoTA#98b9i$5st=-lKX*(mXH1ruYvxMJUa_J&QoblEisZbz+H7{)UCOf;JCIPb zVls8_QtDSl4K|U2vTgzrF8Z1-LEN?Zs;2Fk*yV<5O5tkqc6Q-wnSY`(31CuhcJ;Jr zF+H=f?8Iz?S?`J{+lCjMOlclL&NN-6jhfLDs_%F!5CrX*;=IjoFe<63+NilbfUUmC4#JDA-t?wIEM zJgggJDI2~59#oE}EtJ`GdgNEY3b2ck57NA(PG}fRbJS&e92f5lmnpB{xR(*X>tR_$ zC9m~CNgdQV1WpRk+wn20Yc5CUa&$i4(b*QCBjMLulNh{83}0b-;W#_n5Ch*F9~l%U zuRKD+xO&I$Y?ge%jNrMn!$uu6DCB{GG#iT061<(M8gaFQI9x0j?Ad-0!%fd zVw5NV9v-8#ES~pJ=`wtHJY7<0^4s~cLFN^8&UqVSs&g*S1ztTbY-=*z*yh<8)WwF+ z-?zVMAa%;Y@Fl$=LnkI~rTE@WtEcB^7bj@Se1^My)jKGn6qYXDYx!EXhvF>6{&2Dn{5srHM9tGguPtrS&~%j1lqZrMOh45;Yx42o z>7K4WE`aY6zGub{QS3F)eRv4IrHYj6Po_lHc5Z04)e*BhcR`^FMcH0-eZQVOci1$6 zVF2yrhW4`uGY~U$&|s#1iX~3*gpJC$nzHPt`T52r{I0HZQ!&ta@T4!2RK#~0UMKon z9OtC2$x*=`7*PrV!>C`P4GVV#zws(I#aOOu&uzl6T<;4L#%M$Nsi;e-mhx>7zTlDE z4GXW7vkY{3KJ|FQar%*b&Sbs7$)>3%$^u2mN%js(5x6TRzgonqg4K9KSo``Jc;mnU)a5dEZJ=*eG6 zHRuLy$a;*=K+ON(t&z{=6i^u=Gj#I?94>RbMAL0mOv(N`+^KPAVE->3S%Du!W!7n^ zM-`o)V}{eiKb{g*Bsgn0ii441;vk?INcrm7(lHeTho8*dvTUWrh_6%Uj60}vUn^htXn_4M+RH9YHQMDG z!&+obn@xF4SE4QG1}o%JgKRpk8+Cx=2S4LwZ~K9E*uN;hFk~i*XVpx|n zG?~x1l4S4jdjL;B>-0T&&^9Kb7gu$Zdm+9EJD#hX*;eGB&YTl};*UN5=YRZO6K3gt z!}+U^j58F6PLI0tlg?bVzs9tRilG#2MScT!31SjP(>Ja2OX8wJh~&Fyf7!7eJr0M(F4MMH%bXguO*=)JzJ;#cCc^VF!*5Z=9w<2bJ;6lv z&Uyc!+3Jh*F4B8@>9u_2()$&6G+kFT27Z!@LP#)*_OkuLL%A+<4PHfXotI_)Ig5q> zC_^{kMKxH!WdRonF7Cst2C&Kr2^N*vuNLuL#CH*2DZbt#itk{OV-3BbG z*sW9WlKg=Es;EAJ?HRKdL9k2ag7VP?BF294K?p&+6e+@=9d?Z zcRziGMb~kIRfP%BsLEqv&r9-p_xQeiAEoua%`zv5{@_ixWm)qF4K`jxU7e_dtZ4k^ zic!j<#QraM9mN1*E+cfXR7d@?fkl$X8lQph{rfDAMB8O0!@5)QncuR?32&XT>N85; zd7CQInj`XV{nU|BO2 zWLRT^n-zaL!%0{rvM>;A2sWdd#w7KpesYHDa)@qVTvHBl3K$u>?Wjc~wMfw_To2l6 zmk;qJ%|DN@fq!+6#Hn^r=bQkr+@g0G{ZfPN3x7_E2|Y`52oh+-PbnZn+AAqQ2R&*7 zjj^9dPDqTRx?`MQhZ9|QKHu_`Y5xun{E_0Nd-omXcMAXE?i741wA)V{aR# z9^XUGB#b_|i1967w?FEN7#@k;yFpC~KODJL4wnMj4Dc^FAd*c|z*+j8Typ#zqyuuw zsoWF5omN;2vDA%(l2J2`zPdZwGo!6}TQ*+na@~V_ZV^ zzK{ajrDV$iz8v5Wdw~B2yyV;HN0?&PSXdwg^sdrBho2lfsLIG0_)#wJIVzFERiTWK zO2+|&>vXGCv*}hsts8umd9%OC8E`sJg42Nop*4~6QFFtA938KGR;j?AYg2QuRQv_G zIVD3CYUWf~^RB6~e_(I>nC6TtxbI0p`znH0gMp+PCQ&^VQG&|nD@b6u=b5rReYT9X zII5#H=)ptjv6O*@KMv)OuR`6gl4Oe#yYk8+P_R_uJ-DJr6`Sy@FkYX-RaU+VIw4JO zhPGGQh!5%^T5@QY9NPOSX|3SoF>F)#C#&!Pub5~Vjl>@GIUe{FICQFY-S@{ypa^`M=7c=@<{^(EMpGMgNs=6HK^~ z**Ca}ew?@s(T3vn#0M`%ND2m`pK37i|K0HsF*tcRF$`Sz-wuqVMW*ny@C$A z8tEND9Y|iqO=|Hq4xOCzU}u_mp?u8h!=ojCh54*-_FDV>q?Q1k_2_y82CQ_415;ME zX^kMjLb0cL4#J26*u2n_XcDpV9e)+)q5O0LA=euubT7r4j^+(F73UztqLq6RKj^Lc zXsE}?00NA$^taZrk4JW~zWuMwPoF!EvgeqgC0o`6RNxD^kU<|ur06zQ|Mo?nM7iO{ zT00@>m#fYVr7v`7xxW3P$TIo2p`aotVK`#Q3+z=C$HE^?ZHx}r{vpH~=1a=+E4+8H zNcAGsQmX&;51L`PzcV*0?MT22bnE3$uzm3MOw9-3Jf|(xNI1Mcj3ax+5MmIvMigWP zax^EmHEHqkj>WiHJOUZuB+qN4hjsg5yXIwUy#7R=B2V#$8+H6$t8&1AHE2qxV$g^& zN&$UL;x|~)0A51FKwfwhm65VDQq^GLx}LWhl*WgE2pTQ#-ctTl>i-gHxI`K%N^I9H z(8;->io}6J{ONDp5S;(`pg`x5g8~Ty(1=j-v~rLM%#^=rcLM8*Zi3A~SF68K@9OT@ zn;&8f%h9(L_Iwd`A^y?dZ{Wr5jNoU80g|c!q5|#>9Ej|gXA>2|x~Bd9->UJaJ^)6Cl+XY~eRY-;a7PeBL*B0pkU_ZHO;~`E*Q87lX;2CFw zUkzun=!q@X>dein+(cWxQu6+Ub>$m159`QJErRzY!C`3>wdUwI&lR=d>J*-XgG@>! zbHOx+1g`G{jOoAB0wI4PzZxI4zM?M(yWqd^W85$^R{bzDumxgZ7}+z)97y|eu%hDwM#$i=h{OIp~rj(sDFl+p2b zRMg^P!kr4EDVB$Ah%K67u6`QwtdPEH@df>r6%6JBSf624_ z6Rv5%VY?&0pg6}ySGINn-}$0c_6$WdNJ}UZ)w;QoNVGG$d5oELSwy;ZFFfP{H1Yp} zfoTU(MA8XgulSBfGojTh6c6?3HKf z50e*z))hVh8h#>QxZ^pizWCeS)I;Lx!ZPYuFhwB+_Hv%4Jz*8u9br@ma%5@o) z`%rGPd)ropEt1d;J0~=KBk+v06&RN})r>vZIgP1CRrXbj<~ z7?1XzAcj(jLFv>$e`3^WA;Jx|P4$6kRR<;C?JlhoL_gqKN8A-C5q^^t6uHzk6p3>x zq9GM21XLc3a5%WhtwcKZ8`H3yRler7t5f1pO{_6!>W^DvP`q;;6+a0GEQ;2W;rD2& zSRYaJ;1E$jx~5`?;XXQssO?hbo$c&nIhO}9iFFXx*aPSgU)~lvx+A(6K>idJ&+zi_ zPxb?wb%_DZ8Z#kIUuhkEJ}j>xHHs$;b>Q`ivR$E(6g9;#4houJp$swY&*Jx8c=)c; z%gphOLOuXGo&&Eff6&cYc_9;ya)wq)LBeas$Xc^Ker7;_~^Ptd?#g_*PkKK z(D)+!f-Ea>%mV=ogi5ME=NNmq-gm_%P2kqEhX8&+fxm6|%r^nj_|=4^sA?hof8}vj z95|{kE8|Sgu`gi>GPdQ_zYL42&?On*@L|J(S1eDryCkW;Pt!3eD4w#3!x+F0U2^NZ zuJbL%CRFe&<;M0~b6vr$u0=#=CEr097TRw@Sd+0YvwNk2N7&oJaL^IHMzaQ`DwBu$ zEwAcvq}ORu|COvE!Ahi@g!EZh!tJiC#!>A?spz_A0BH~_)J({s9q+J}V8<*oor}%q zg$D?rU!i93CK%HaB0#HP;OeU)+H?Oe5vHk^_3ZKJh8NmxmGJV-oAEMz?|>KO!Bh5q zm#YF+7HHSEV&z&Zamt3f45P3y=!e45fr!=(>9Eo^K@)% zE(#Y2`mv~cyH!BfY3QyQ+$BC0(RJHq1A^|%6Xg1hREY_inW1mBrxjh?XJ=^pH)R+Y zjxL?f-F8h87GK6YGK_%ZBi@`;u=8%o&h)8{4P2jD=jz9!&v|h3WEK{ji34x+5tRuX zB?{;dFOWL=-rV$Oo}a*+mA9mS%6>Um%CGdE&OX=AopyOT zz{j|Dd6T&M`W?8Mo_!Cla0c9g>*zL!Q@C2Xbq}tFeHX65J-C|p*iE)~50tR|dvJC1 zdq|FRmp$xy_rPJ_@>c94N_WB@zlpGbah_Fm86}w*1Dcmp`r-CN8DsZ8j04?qgypBP zoqn)AdQUo$$lT#Ek|h-kBBcS1wdj>gFo=|lN-^7{Q5j9}b2QX#+gZ_o1#NuID9y9i zDj9tn%(q39iCUYEZOOqmpQEb`wV<)&Gg#fCb&c`k7+qtXufnLR*mj-n)iA+nl5rw} zGNYW%qRcn%tX9OuwP)cXcCBaImQH5 zejjo{I2T-mubP$fFfv@psN-{`r;w8(CblP;V1{y3Drew1l0nBQt&oA+U5{4&RC95H z<~68XG1|X{WoL%958(91Kly9GQId;jv60lNbUMEy8;2VG3~flRNhC?cz24X5!t zpF^V!%j)I=lTSaC@y8Rn<=LNiB8k#5)S!F6#*iNjQN4bfwK--h%l8kW5t`H`RP;r` zoQdzyvh#HFrtA#krtBO!nUW1C0Et>2z7ZVo5IHv!S#UKu2F2l(9VshjplCaP$;I-O za-o~>Rv_F=lx=H8!8+ojY1CsDrDAS)^0AZ^F<-m}ZfDliva{@4vU3~YnTs6;sq-^h z&Ehvz+H7434D+*xQ$lEJVBS$RU}8|`1#cLshUhEIYACWdNCFIfwT;U6kZs;_IKa7& zHDjM)o8mE%WAairM>H={(3p(qJE{|>ng>Y3+&nx}(URm5*Ax|9DLJCG3ZMwkosT$P zhKq8SHAg~x7n9es<+jPV<@tw>3GV@XwWq-oBXDbb=wbWOuhN;d&4{)}i3+KTpI zl5KWtQRGLJ@{1M9vnqMv1J%_K-IAjRv$XaHWjiO4?N#3lyrJrwQ_c$5{spUdQ7Wpt zDH?QJ>Cw=ejSz}PY`aWWd{oE9v{wz;Nx^SJ#Nk>w6KT_E6D(dL5Wx7L8s_K-^dV-x zakkRxq`g$~N}{bUhKXss04dpXWb#GzhDmvoC-H!pZ5hgT?;&Z&RYf|-)WlK%nh7W& zu958*(>Rn&^gQ4e5k(pwY&*D!u(B~8Ii@ngm~V|~d7bGtR1weePf`S2TNOphvy7@R z+LaMU9!2_Z1y}l%iKCMgU;fENIjI!IqGS8%?5wohN&m)prqg^%W2yEC|x+Al( zI5!#Qu*LRr2|ClF>Gr#Zoo_bAJAStmOb<;TA`O+Ei7|m)0e{jTM1Oa;_s+Z)sYtO` z%c(gy=jLQIGdnvc>sGU8*f-PH{*0Gd^6pf&7QB{ic@f_y=Un+<7C`wovfQ-gk{}~)2+@{+ib5CMag3J z0zZlcR}pHal%Lg24ati{GE>RKby_EMvA>lTTPEvW-PjRD#ER&qJCSs+Did7{6j>r# z%DX~|&4gc$YU!5I9UN$#V2KpbHHcS_2&tA{xbEAXafL2q)U;8uElc9`q=1Qrpzp#F zL#C4yO9+wCF>XVICX)}$rXLx(k9^@Vcwq6EY*EmH_Z3vg&ouSJ8b3V9 zadKee33hI9Q+7u&gY&WrZu)jslTXF!b=b5CP{_hI-L|Pjr-8zRl>QTptoaxXw#x`C z|8%>Iar`LyBTcgRN_NO(KMGWLU|o__)TiK45m=Ehuiu3G3xkP|)%hzlAkj3k5(Pvg zfMiMb=UrXxroaZdSu5djAcqTOh+u0zUfzNGVVf2Q)nqn4tW1;i%0Hh@(=*l64AhS^ zuszi@O&#vU%4vcYrJSaxd_GOQeJQ8u%rH*%dkM{OJW>sgBFxgD`<+bnJ`tpL1?IGz zEDVR1;VenEDG9ryG<->S)!Wqfz(bC|2Zzz7BfvcG;x$3*{KM`GO@0;6a%LpnSPnY{ z`1)ntC-d+dYk$#pj!|HcYFXG=r$`!XFpEFrU8H!yfgPN%!d?-+Bv=G(Y&1iMqu-i{ zFyQkItH{YV%rVQ7D4qURf!Ptps%FJ3yVrqR;;^j9W>OR1!$l*GT%CiCoXQr&^_se) z33e>Ws$>(cBm5^B8>reB_kJgA`YXXTlFwNh6`~%o2nLt`Sqa^3Dl5;B!NaF{grJ2| zA;XcIp+7l1)C>=TJl+9u`j*UBvg`e*SK6{P=CwO~83}(T^a2#& zH{Kk+&C7xkpe@H;b)697$Q4mi%7QJRmcm~`f~v{%FsMu=JzHO`OBTI{_}O%7&U9*T zQPmXDcUJf(YV?z0)H^h7Uy#gq)9T4$DH*nTlM$OdXOfC^c8kowuAG|K)n{ZnR7?uQ z=`%{-l~6>5O({i-5@0_tgONI@k&1~=s36|y|0?3|;ZUwAx|b?m@bZufdL z*?}ua2fC+t{R+0BD0Ptm7p&S(T45PU8>_PjA!(9AFr9oVIm2pn>f@688O%*IrSg8u z$JTX~!?t}#B{?ifsFIwic67GVxpUe?G$rJyVv#y>zS@@!ES2Qkwt9&Ya&0{fCFE?S z+t~F~j?Qnx_!LkJT&2`}C7_yY-ld<@kOtoF$(7+X0u8(}vq}DBIzwBRnq|dOI36)& zn14Nvc3nnUg=eEk%y0gldXjZ}Yr|E}26a2d0XG39@&OUC%D)}0t@YShRC?@o>`d#1 z>}(9I;(=V0o7d6~Y4MI?7p}0mclkJuL$`};gV7*xAtuXM<4=;0j(nlELwaC*)gk?w z!8bIqhbH^MHrS4q$9A4-F*%C7RZhXXIpnw4Uv&kGB-{5_SqWA+_ zJWeHu5#f^s8-4#35$2_Vc?l<2bzM?Yzbz;L>}T zT~Xv^{k+X%(P*a8D#$OH^UB&dCYc~ml==Gl%?iZQeU)Hw1^&je_v{Mp^Zdd|B`uQ8 zV9?}cb3Duh3lOuv!0c{VQHQT({u!Ki3ChGN(gUj=$$dn(o!~;xBZ+<2yoyg>cMOCuKFqmi`zM-)-!(DaIeOeJ$3;hnw zt2IS-RKJ`>{S9Ve)8>yDf$Oih;RGiPG$meh_n0L+6;*s$r7Q**Uo~An2<_vLe;l$nskMLl)%>ny?DL=7K{fPfns-(2B?=R?q-fhd zoquN3{3efgDQiYB5QjrdVI7B9idGxpOSDZ=dXN94$14p!)j*qPPjIt)`oG!>9Glhg z@2Cz{{}B9J^Iteo*U_cl5Ka6Eoz)s#Av9h-1lILgl+yi0i!{^`Gz!etNY|@?Zo1d% zH>UCOSQ@ZM@>t_DfUo|+N=-Oca3tC;D_M%}7V*sQzBL2+`_VQg&woY5fpXT@;>9bDUAv;v1!elQq#%sxS6{A#d{yPu^Mfm@yox$5%Cw`wLpgB0^M^7m`7Bt|Dey#| zvMOA|bick~#D_|}@y_9meKFq9UAssMmcq=gR-%OI*T}+tBs>xmF9v0fnGM&2OOG)u z-dD)-Kk%#u@0s;r`)VK=A~S>=u&GNpOk)klsL86X-$5jChrruu{8YgYqLTamNIdyw z13AIz;UBl|+Uo&AaMo}X2P4D79*}@`7a_v0X_P(bmL_NPmp*3kSHVbt2)-;QL&8xv z9yd7K9*4`_^s;Gi>%F*a+BHehXV{w7oCC4z z&~!j`GfdCAM0YEx7WW)*+EiGD!)(i9l>lm<15jg8*zTaP;Y(eU0iqLr?J~{%QYmh;y&iSM1BNQ0i7EGf@zk^6spLo4O(c)!YwKc3`9F_YK@?7V|6CXn(*`2}Q4eu8c z;6F4g{YUiIYWO?4|NYCazvp5uRVUs!c8B8@FR5WF2;TqrLTUf<$3IP#?yg12vjRsZ zSqlkbLy0FB>K8rIj%BDP-FCpW9mnN`rlm9bD2MuLt;oWlrvgjZ5}qPaJWqsmOtmoRZ~H5a{!}Zwy6FwmU2t~6*`Eq$MRkj|)3s^K^j{%P><26B z(SX&Z>^ldPvoz_4GqWEx`dKNnu!5Oo6r8%XDPo0&gxZ`+=kD@A49;hh%+y zCDLHoF>n|NS7l?m!``@xG`~OkZ2cU^I}d*TyT*cZZ@w*(_IrZex+A=>#UA>IVy?k z$m*~oDEi8yECYhv3V%CDYVTeT4@dv{hbP$WT6dnFMWsG%)1f7iDc05uHX(yw>@{?P zmKt6I$rwk_zzYkGz#{3bXxy+wU*SCCJy?zfkNp4$>ATLu)M7A zw(Y*y?}j!*hHfnw)1TgwN=rBz56qNS?Mh5&(J;b*9+EcTC$VzcpL2ldDAhibG}Xwu z_=}jR#yd^;iNP?MoOpO(4-cb(ukJvzpjoN3IvBnkv>KBLT@l^EF2>F-LDzPJ6*@(ve%I;9K&9nB`@nuj$`4s74MELCL<}jmST#SDXWyCXMSOV8b{G;%j+tl zi0N7?DO#}7{FMKQ%7mXp1w;5Bxq(`{n4!C{=!`d}7Ufd0oY5qbo~|n=X?Q~NNSlT^8)@E>Vq2h|-Md^82X0b~;+XC;a8W%h zUNL$@o6ZQvQ(n}&Ex*9?rIiIcLYL*PsHvhLbyz+s7C&|#P2Y0bCJeliL|bKK{P->1 z^V*`=RING1J1ZOaIR%UN6k+DvF3fVg>|K;8)e1~FabV84slJYL{(Jb4c_ZX%C;Qs^yW^yBWz zX;kf5sG@UUHB}$3@RYNczhK*Cymh@(in&N^>xUVVWoR55?`t+!zX5MCQHjbSa$EC6 zd9)0UIej}cG4gU7?bp~}V^?v5FJW+3D6jC3_1g-0j=zfYP=3m;b+qxko~ADIaTAdX zLApz=Y1pNLny>rnsJVJmz*zw`&$k_w)B=TX>IF^1LqY9Y4=n9`Q4;3RJQLHmS7ZJW zW#3dw`9!k|+c7U{c5&4#pJe8L!R$di)O7HXhQQS|rP0{VS@*QH_@@{D^iLbsi47@* zfTQ26W@$Rk@GYu*^EgFGs+CP8S5DXI^wVlH!;$_Zqe9R+)MvfwS&qHpdQ%l`N(3gR z;sRr$K}(E(fbfWscWfd;xfXX9nvx{TSb4>Gn@f?e^Rn5Xj=ppqVpqO@a|^O``P$uL zte~Gx+jk9p+c{7F@>5*^tt^woK9ZPaB0JRd_{_);`QoY}Zne|Y9+l&_bteW|cU(z1 zW=-i26{KCu)Te2DL;e4-XdIWnPt1~QW#wvL+E+~c224(H8LgxZvGZFEZI`839DYp! z0AsbzAs#_12nReSh}m&-3&R6|;Oo=*a^J_{%Oj?a=$mkW5SkrRZwlZmc@ZikBqr>b z*U0%gN(|6DR`-F#fQu*Uh_LU;!GE1^Km(sKx{TZk!_j4DuO5?c6{2uJ2l$nSy&7jP z7RD0zx?C)NvOET{k_GBQAVGK3|vl;A~cM1ujPob0w3^9Z`fZ5?#gZ5_JBIK2z&S&EVc z*OhM!zACkNJw?;7t|+uPfs~}WvKaQhKj&0E21RUs@=;=@UE`bXN

w56SZ!gcd{^6Tk#Mu5LH+96&fp+mO{MRH=5*Y=H#6CHoifhY(DPT8{vN|(3q1fJbO-0Gi+L} zc`>6gFJkM#y!5Y_C^c?r6Z^`j^b}v4iRjE>E4sEWx8WPUzUmMwuu2-uM1l?Pur)ZA z;KPeJXb{bqE>uc+bF;>?^PqKN7fp1rMm><5yL03@ZSc>bEKPDBQw;X(?qegcyt8~B zBqoQ|>!uw4Qv7FusRgF4xHQ?8cg>|qL4`CQg9>M5*#;9Vgz>`-E2Geq4!`2r$)S0r-zfHf7R;c`kJ_NpmLh5OOUNl)Df2Dqg4X5wUqN& z0Qxssoo)Eu>sgMssu(o_J|kwTgR=v-lTZqq0GB0}OnJsfCl2KP*s~>h=cUQjJ5yYB zjRzLsS%Bw}!YS(foneq{vFDBex2Y;oU^XFsdDP!Tc$g^)AV!WO?0kI>xn(z(XU>=h zU`$Uu=?8GYUAi$Nc0BR7vLhN)yXRg(XIm^NERPTL)s^DSRU)PH4}z4uoeH&VT@dph z3o-jkzcOsQv7FKM?4+EH!Ld_39pgft*GKsuv9fN(%5&OA=~0_2bZ_l)o0QRp_h?pZ zaa9zDZIB0;f&$+u26!k` z*Wc56^szxoy8DX$lq}9!;Dwd2sA%cKO&3;aUN6A10Lua_7exEouHj6ol|T_7uHnf` zAZR%sY>&=FOba2|OsLW634blwDaD@L0fO0gnrEG2wC}%HN|XU5*?L1;Pbq8r!OGAfb`F0 zEN?0py50;G469AaBhdy6uw03Xl9<<4y%}3R9D9Q_wZjF{p}22%X&q%O-&N^;G;zoA zSJ4`lGH<~Yqas2PA+crKwdk!NFg9f`^=NiJ986P6p%2yZ!=^BpjSaiAi4I{ul9XMU z0XH1E=e3n&Pe<-uU6{wmYBWVsz(izNk1j7Jka8SVr0~`wK9hBFM71dR&&}I-!nSR; zjWt9p_X`48r^^Nqn8LuHnSs;k*x20($+k#G0L2k~kn+>?M-SW8^*P9To-qqq-&H}j zxyAd=aoEHSf^E&lJYsi054#+qJ)Bspd5HOh0(+)3|$ora>w_ ziuH3xaj=}k%Srrmox}$wTX#m2t*@KYY^cMiWH{yu<_E7f0|20mNiaVXJj!L?sAEgu zSKh#>jD71u#%f;F*6lC&kr{ukDjl}jbQPa%9Z>-|%j526WzjT*D_OiFJz>duT29O5 zw7e+m$@4t_DC=oRrW9p8nN6b>PMUSoo(}s8j>viP9zW-)s#y_YCk!f%&9ByK6P}!< zXwd>$ztL)G{onx5f=&kq97+BaP-5%GweiQ-Q1>p6Zl3fOfj6Gdy>YURii%vfgp+XK zlw}gL+v;{p4#onMKe;P42z;CO#lj9@Bul&j%RtrLB&%3?Q4_K z84t$}@>>v=h+&~nuGzq=&lng|g9!5)D5wE0 z2_XxNEiiVazUS!vW%}Or6@CoQG@FKpD5aAwqMm7=BnCHAK!oea*@umN`d~8c)jiL7 z{9CgC?i(j*T(1Ag^R9=SA21ks;^dpnt}I!$VYdU$&N2NJYW|5o_WYm!@q2BPr^4Gh z1v1Z2?BgW|!MgL4&Rn&>#-4#ZW{Y|dzOy~{9NhPN5O#hEmj!^kdJJfCW#E~MlI+FU zQig%xC?$hlzeD&slJBBXnKOJGFmRV?TQr=b;Z0H82^x{;LdT6?TvFknX6#48wZI)f zHK2LB4aX<7nTF-8u*mkB!E>;`Us7O0Dj!wsZM^hXcVau>W19Xvd=7kswmzSa(6grH zSrCoDar*@rrZk`P&A@>(BnS08iNod$Pda%gVvCHb^I!$;iZ9p!wWDPlMIM7)<|J>= zO5SnJnaJZ2Af2vmYgxy9qAh+0N7l$y?NAPy4xxe;zzKdkxkz`z!qM%oSzJ4KLOrfJ z`3IDbafJVu_`DkbIt^3Ha?Ub^7t1RNY6^I|ofe=fp$uSsp{S_oVn^PxqS z+lED?(~Je39%0L{S8-Am6v%ZbT$srM3hhYJHq7Wuq;b1nO8Pan(VaQC&7Ikp^Umx~zBBu#?_YS!ophPD{NU0X-6TFX=(jiFW8H=iwo0Cv zPv_!l2VY-_uV>M@7x^mgMW#N_y-0V1_BuGI6pX%bIrCk(g8437&QEvYg5xo(XUP6B z)MfuH`rD$v-B*9JF4Nye#LyioC$`>KPDYSy4-Z%G9Ufys{5tUF_@Kw)0TzCNA**#> zmig!Pq^yvJN76)2Pm-VGzwwaJ_%S#pU){mn$8;`CLt@LcWTr3FkIwf9#5 zR*WNWT5R`Zsu%Cs^TAxh$Z`6*_ZLhqiTXUj1=Lr5UbF~7RZNem-ygy-y+D)_U7E2h zxV7NcC02L1p_=_b!pm&|O$fSSdqQ*KQV18oYPSY8Tx{#Rn@>^web^HIG>^wOLiqG9 za3GiU%ex>v^URA-(~mURg7D`g95_p}4+9xI5^Ht)qpskw81H)|V2A^>VPXxgW>pTN zPul_h6_p^bNdaQ}ovw{kc}&AVl6nLb3;@94o$uda%zwnOAiGHWe9Kd&@k`QkzWy@` zHyUmtSdUVjh#R`)St5yp4tR{t3F+^)J;&8ilzh^8bg3ar!Qk;5l`M>Ex=@zY1hT%_ z#C^j7CQvrvH@9QJi06ERA1{0??vDG39OJ#Z8`@JWS7~01JAg$)dm9 zMf(-$z&l4;zUT!}tZl$pz`7IoCUO?XOB!VncQuu0e}7n~%K|JTfQ8?k`5Hg9YpkJ4mcP7ch%|sHjZfvZe-Z!r zkppzu_b|<)SX4uETw{fQhSghyDM(`b<71brUHBumk=_`~G(kQ;7;N9JI(|(noIXKU z_g8f7J`~N-1Z`_o!0e+^-{~>M?-61vjHVsv^4@Tc^xX^x?HVhMv$7O6JP?(#j{vS| zw*^YT?a)ndh4mfcd5m=b$)DHALH5jr;kkO-y}YE8(C#9-UmzYXdGE)l5be@28j%FA zMi1jU8xerz}P@%+a|^AK&`eC8n33 zy7V}AJmuMjJr%5c`oBCqa?+`L3EiPn_nqxE4q@m8q}zCu{(?bL=%6E8ghnSq*POu( z6~H3NV~x+i_x^>^2H{PKC;+NJaLq?hj$~kR|Yp6w1kAi?s1Dv zaU7i$ofH;lUeIYlr-wtQerBrgxouq<*zHLcuJd?5qPX7K=24CjGdQ)|usr)Jsh)AZutF>8(t%HXVbJgu!g7T2%j!0f_XCLd87VC(;GF|RcLBsZ z03s#$2*4t6*gw;P^yBR}(XK*6MjI4AE4X!rldwtzcp;JzCPy`mNxL*UbUMpN}q?-9d`Xsn*5~41fP`6_0m?9j_I*q8g6t zD*h*@y3ZaxOURY>P4l!Cv$T$c_S-2WT|E~i9eW4P&9kHVw=(*ChCkBhIH}N({>*+P zDeR$WQ=*WuXC8)pVw8M%Pj4Ws8g`A-wrslwaeafy0>R2&S##+C#C&}dLC^H4*jd$8 zPj4A`2reYpRz)`6Xw}`sq@}TD?`G^5E$@`m0l7cIN89IeKZ{IBs)^{W0hy?0s zA8<0|U1xC%6j@%=EBv!_Rqw-B$vqMf_O;K^S0^ zKGDY=5!Vo!rs^nB4fD2*O1?!iEosWAb5=NE4-xnN!Yo33>OIxnEEQjL5kt91U z$PC-G{1xB;pEPPahVVdh#{*f#k*k6i#>t|(lT;JJdVXyUl4pk`e_J8Z&pHyAdW(yLtVWjpFcV(7jtqjM3slDbBP z12mDT^V0HgEzh8-2Qf^9Vop=TL!#*bxZ)K6qM}SDrv|#54;cmBd_Q*h7`_n|jnG=U zjR6sW|EN=D_wPaX5cAfaLm+u5N4(y*9*)Z6MuX|qEMDXDsHE3}~41u7TCNF&U z4O@`JPvD*g*J;(`Z+Hbpmsgrs)oi@%psiK|jF=*_UTYVAJuqjCpek;LLNnzi8e8PG zMrj)Mzpt)eY7BvH3jaNMXaO8`b5M1S8K_!qo)ypDOv!K|dMi$% z-zHvJjX?AguhbBCb!%AK=qj&@i5c_{1qH-U#eBJ!1!1XTXkzNtO&MaVXT=QZ4#hRg zM3ne6{h+-%R=YL7VqJ_9m>(l=wz|k|BQKlCm>f6GE>%-f#hRy6h*kv?YNYP1wo!>n zJL{B{!mI35(w0M^<@EPhT6IDRoE#D4WsjG2h0%(y6%hAr83w$-&ywX$sJ9lHX80RK+sFj zE8V458}UDf6ZlNCwZ%{;fy9>t2g~(#f6=YiD<_Tz2o~5UjC;;wiU1L@doA7CkrWI|TCP?x{QIL@Wn&0pnxf8l6X!uI*P~32 z@fno)9||<0K%^X_93_`Re8iOOyJ2erZQz5bzxB34W29+h%np-ff+79jxBAjQyi@8?iC znh-mXUQjnNuKnQ^k45Mgp;saFo-KvGk%Gx$AKX9RNq-Pkg6A%Dk{K3US#aeyh$|PQ z?l=llcO*9=AGXDW{{QyAtjBRAN%Osb1(7d{U1U`7K2%SQn#G_nHPeOeei~pwP$Ct< zN=j6bQbtA(`oFIo?xa&kgh)CS%4!0Q>I{kc-06q8nYr0#Se@f+JW79q$iL?tbBu^o zqO_zXNEhQV`XKTwy;GXKncIHmw!aH*`=cbuNS3g8dvnzoFOjd+bxYUtS$h4oj{?qN zv3^s!?xgXRvNQ&_5c+|&5J*`EKHuz$F|eTy*jDITrxxQ!Q~RA?4Oq8epYKIL5(&Zk zN}hnqJ&Sn&qN@cXAPR`86Kuy4l_i^HzdKD~y`IwZ2;Fn8K)*H+NMbOdMH9H)K#yV=QNdlat#QKn zL+7A}blY{?!k91;xZm(kA!>$D$f@X_ZJrE@9%KwFCTT4{yd%#6@^qn?+ktS_uei=J zoO8t0T}NEW_0Q4cjSi~rimXTBC>73aWNdenQO-C@dhKP_FI;}umo7ivZC;O9$>!2Q zvqIfI-UEy*^Okrvl#a5Vw!Yma2MeB*#j^27=w)e61#G((bC)P+8 znmVmPneXxuI< z_@QHSF1Fa+{h^U`@ldf16nIC2k{-5C*^myasNwNYS7o{BTV+_3TK2V0 zE4P>>5Z542_N&8w2&CiNSVVWr>BlgAD9oUzPuV~?$JYqwxz16w&H*64?|B+PqVYWm zJl`A-g5~|jgIGF1{P6=DMA3MV(6nt0Af7cI#173yDHyi!)k5Xrs6yqJX{oKqXCe>2 z%N`<^-=cPbK!B_uXL$ zdPz&GfmvIx+f{s|6EpYeOOn%Z0t)(|lyv)CN_u~w@Jwo%ZbxVy*N5E>4mlZLV3~ff zs5jWEJp9Cg*ykOTW6KWh#kPcb|Jt6=F^mYqF7bLU$WZX#>6DC-qHZkLJo} zA5ls}-l;zBv(2s$wnmyKPusE;RegBA!nPc9Vr5s?dZ_eH;|Jr|HEK?Vo( z4e)Dj{4c8nvLMM{3YCBXw@`9vZo`VM#sOd8=zBzo5kFor-)Vo*rQ1O`r;YF8!Ke!9#n1f#ZYnV3mw03RzXf3S; z7O3vkVH+=#{py+6_KWQND1P9DX?ytTzt)(~zxn+8Bb0?G5tUvW_#9! z7tXrTr;=-FJmH3@BCXp-tX zWk;INtNFaT(Rrm@!-lB(a|ynU6^`*vY!PTq5qh*K)W?V{XUhQ9-N{dalB7TK;F)&e_?SvUi z&B~xq6Lz$H`g}=%@rV)rr|aF`Rkl3jMH+Kz$g#Xpp%+ZD-)Rji$#9ETPmcAWlk-dR z50Ds!cxz5`t-s_u460edPxBPL(dZriCh|r(`&BK9X)H4kHlm`UOolr%FP&UTM0|Hz z%qyowt!dN{k(@L~Zco|4Pj4TB>W??FcHS<*8VYw>+I-FhpL43 zOZKcf$J_j5o0W&U=o21rR8PuNvb{wG4a&*o1ld@e(X{XRQn$#~`@zj&v8hh^s{WQh zCzaf4>rQpj)Y;6qx}3yug1U!nClm02m{-t@e3a3m+E``2jn_rKyHnDR`QlvP#d&)% zjuM`(a+Bgm5N{-W`HGsnziMDHhYUj#d6^nK0-w7aZ=lZabdgYfa+x2%VbuhtnqQ6Djf2SJdV1JqwX!EB@NIS6KhP(qo$C;lo zt1a9K=i9IPo?re!ypso1A9 zq3~hERA(`Q>#+S}zr2{9x*i)p?AVJP z6)w7+^2gw zU9iJ9b^BXaoGBD&w^+3OTr7^{Oydm_!R^!1Kylz-+nw*>G?AHYjYcd^3qEDqcj4n> z;0ZzC$48Z-{IWlIc>wJ-70>bUL>YCYerMlyR1*|WjvvlV;}&NN+WXaX4Ga-4Kod4F z78Lf?77si&4PtZ$f-qIn;ZhsbdbK zeBGvJ2CS23INIQAutJg-n2xeao-*SFzSR&R+gWc5$MHiEXIf-W$*QAd)kV3lb%*4i z2{H}siy@}dMannEa|Ii;j(>xL76ivTLhEa` z4I^iDi*Fn0asuFtd|zk98uVD}fB#_oysyQC8;Nj_w~uc!JhJn8^P_~sPvM!3@lqa& z)L5c^RjePXY@Olq&65Hlv0+%$Y4#x9^rR~BP%9TJ^=H_3hwX|C*o5mIbu>R-=CqO< zJuE{pkQhP)87yLc_;k%AprBl4utGTJu_?lQr^FDmRWe&8_h6N%%yPcvNq@_^opimf z*#UI|ndv1zzQu76xgT~M;8s(aUb))hbNpP^`+F}mI7_2h8r>0XKG@;i^7O+Pl#gLu zR}bu4K7>eRB7k*+Gnq<hgO{S?CLO!pPHYlK{$_ThA~X3kfa(;tt%Ti7kD8DRjoM*^8YD^zc}i= z#PeXER|Jhw+sN+L)ZCUq<^xVGj-?Q7y}lJxAv@VUquBYKvM0yiHPZQ233QB8v}`QOQsq0o6hJR(%*qXPa$onG#53?t@Fx5`CE$cLJG!kuDRU zhW-?&Q8WRnV>#EL%7f=UrFg2WNc-J9etXX2pYtN`EiY1}s4;nY#Ds;4>2oG8^6ENN z3bYG)?-kJ39NA~t2LfwUVQGc8IbVbB8K<}o$fl3903WC3+n!EXjIY7IIun3KS_c3G zVO~ry0u8Ry#(fW?^1p!Vw*8g-(#iHU-X1VPX8d;Ye z&R9z3$_w`L*;e~<%>hYl8M0UC&KlIFLMP#SlhY&xR}mNWtm|-@W5T>;bH49i)m+cH zxx{W^y6(HYOnTlduq^vVO-1O%p^Yv4*=xE9fTQ?p{1CVJmh%fn z{5+;<(_JX)hL830VZAO!ZMv&)yBiN(num>=W`H-jxa)SWl+9A?MHabu$;S!ooBQ~Jb*DdNhbrav+qb9)@kFWHu>nlTk@Sr*b-kY|2x zY;@*>6;TNRq+{xyv%F@0A-KT3e5Av;|@<*t;4!uCgs~U)Tye7CFjd^+^T)zBI+F59|pB& zDqY?cB!4mo^Dn3`z{@-|zcIM0MosyYlvW7}MP3`!;E$j&U}ogD#K_}cO+#Lgfey}g zL8CAJa6L7nqi-Z$U3!s|w#6mFlblJrM~XV{D3Qecfq36$MXArpJZ6 z1(}u!fVOg{;dhMn|HeyLK4!<53g=+XndD$!VHR_0(B-cfl2^=;N7;c&x2R?dA0(Bq zrqM3opm$JSaCrFRmKd(TNg!G_uop)?LwA}Xn4!p8__av352n+wiXPYIrB`XfH%Hs@ zYQ%F}@&v7rxY-BCQ9kifrOCJ^4$`#DlahI(idDQ>JP$U?Yn?M?0L~++h*1zNM5=gP za`8O+vhLH7!S09}BlZu4td}iZL-lucO)*#co-2Lpa2txn---fKYJ>AJ2t98z&SLxC z$UtmYaOP*ISH0qXE95P^@)PzMQ6G z(pMj$I=7wrUarxp#}5=G(FrrBlc00aN01|^0Qk_M_PQE!DfHl0IaYb$M+c90<(eOu z=jGY}nc11`h3z#3y(omETTc}24^vi=8=+>NgaZGg+l8mG zve(R#KeOcD8%uuU!;A28`N)N*nN#h8M_B z@vQ=p717RW;v&X{4Gy^rus-rG;GA%Ym#-5P%GM}m)G1xOHJLZKay(jI--(C;0S&uT z;W^{^egKFE8b|{NQrI`NLuuopl~d&FY@cs3#SEfn$>3(fzjS~+Iq4CB;yW~vku1T` z`Idk+j|AV`>;F|7uypq&T3&C^l)te~#EaI=TRsR$N)kYM)1UroSXS_Z)fpam^M-gW zBZmVFMR7pcN3m4KOM)EM+1H%Pt$}c)kQ1=#_9c%WZ5Qo=F7WS5(AoDS=ZqZ`9Ep%p`i(ESP4&^e-it!hTv$9}(pTA0Ikqexw$q zyC{|WZpNq+TrHkzu0M4ootW9Xx$V1|fRenjZ%HNQbC!J@BZn>^btMmN2$=?CPzQyD zBMKz_Xb56pinM|?Zs0A-3WX3zC^ThQc+rm@STt}S2Z$IMV!=J2(}Ov$&*Y5?VbRDL zr5lnMN`ov>nXLmqa9U<-knAi;21Qp1T&iMUbB$=*Y3cDvLNR6b^J|rT%abOhoGWP! zBo;ksHnN!fbCJS17jJS&$#__?kQUtZ;xrh#c-p3% zhBf2}IxctEVp5EM9VfCDT)64D4IVJ1?8xZ%aY3ZrtS#quZFydBi?-r4TOR0~bsbYE zZ1mB1axttn0Z1OV;lbG>3Z1{mL*(Sy|t|! zMoENt@RsZKz=hszH!vevR*XFcV1%#te0y`IovS5JTU^02-IFR0!$wRg*w8KcFdEJY zF4un;2@d;!jvNkw8Xo*A3vJM1F+oDHPw8ZPaf-a;I5@XNCbcUq%dr;)jvm>BbxcXo zY~+-%dQ(u<B5237y!UPW>qN-e z27>5~IoCJ(LF8y|hO6+Bup>-nNH9Srwya?&ak15&dug0&1oYP&?fb4%aeNiAck@xK z>l0F%(my9Tby6d1X^d}|R@aKjHzR}jvBzKj<7Y!~s*d!Vyn}eobGui5qKsAhYf6`+ zB2OFPAZ32&PRfY#%Mry`*n`aOW9b!9kf9^kOWDAWCq-DKyXW^qcF4G9Om*0DAToMF z^g2}B2^}gn9B(U{63EYXC1W_+1K~Kh3VNhEA4ay-6IdF3IH&ceRezN}c5H_?JGR4{ z9ox}uk8Nk#V>{4(Y+F+u+ievUEyuPOVO?JIBA(2oa5u8`q8yQfES4ixA^5ITg*_EB z$?NWsHD^1`;c#;}+#Ay;zm_d;Bx1tCQ*s-*Y$+CbQ{EbI{eb|yFg~>)d&h;BpfI3qwVdh!j zS@QIN)A|nG_ao~B|gmDALw}=oe#T}n3z2_Kk;e4cdu%fMY*&-Q2V?VH6=9jl7iaQCEj$jk z71|3?ID<;ZQq~X+WPMFx3;YV&G${28g#U! z3tS)VmLBG8X|jYo%~?WPWm%Q1z5oErblAI^BURq5qHdY9f8_)GD{}OtZvDv21n73b z9~0A@BI}HDP3nnA@yYfmuUJX>BYRJX;5gYe$%L*&4-%GBL7g=-To zPoQ1%J?P=xj-m#ed<5EBpn65*dOu&3P)|){&-U#b7MAt{E0mGv{Gu@z-GGLYeRH5n zA#g_(PnBtf9%0>g&^SQM+-u>3`ShDlzuTUEm(Qd?+nLme-M~F3(pmrKeW~H(@O?jA zz#@Tx+sH&2S{iLZnNiIsB0hLS#DAd3+txYWA)i{?TD5-vU9I1nT^6&;;`?)19A)>q zu+1?#OIBME%Yd&ovxGvkcH-VQc;G21&Bajv*1Z#L+$Iukj zsN>|W*3*02HbqlB)#N+ErZl$Uxz=eLm*%%eZmQBkvwE&*VaOYV&gq0eQ^`)}IQf=b zr_UvFwPR|nCS(vGjGPc&>xmoP$R?|vIAYYYgyIp?XS@{zc3%>mq6VE906vsEY-TYlZH{Bz^sKm29oWTAQRAg3jofS*(r_tLQ9# zOQxbU{R@DBB*oyB!IHFzIu`76Hxr?5biD}uaNqgq#+&t-dE<>zVbpN{k5Xe?zxN*O zrN_AU90_uMPh%WRT<&(z_I5vK-prXdbLPze!Quzg3Ksv41q%w1G+R-kQR6Ddk}&|a z+1;zoFo(L&q3)A}y2~xV7f2u$3e3K3Hb%1VS1dz?6H-Pv5LWvfSt!}vE1nvX=r_4A zDDJkh*enHRDR2WRVCj_t{`VmTKmj^tpqZ21=Dq*N-up6Ctim3^hNtyJr7t)Gt_d5v zhb_82U|j4?17e+df6n{!Bll;MA7+`3FYAg^5CYli^miNAHK4FvXiZ+&uG?T>xso?p zZV(bU6V|h%wx>vD*BDSg%a2)pOn1KH!f!V?XXcy_39s{i^of{11MD6v4Sl4Mr zIX9O^hwT!i19hBb>x_@M&OnB#N2(=b$Z7;!MO=uTeMw>KE4=T31%v55 zzaJ$1C(8)Be=NX?Zly%`2J#PiiE+NDf=#)L7*MWEi)ofCvs}4Txnf0U5?k$wNhmOx@%e|=rAYAoeQpAUSv0= zESi;}S!jL#$`F;BI4`ttN9ht+-uI&hH8sg}N|W$CY2wY&WR@o1y$TeBi%n8}d1ISj zkvVfohBy-{Jx&x{r}RkX#zBge`JE_M`DVwpOE8(UN}$v9)BC*Sp5>uFc__o09`_k6 z0pV^=^Wb=-CIXk86l3-XbY2&FkOF9CL^CwJVjdoRVe+S5zeYtz!cRh2rCOW`e0b=mvxRd7p2v5F1hgCwBric{xm)DH3&%UYY zJMH@1Vr~_59kf^hJ9 zDB2u!RCvV8%jAoKSOt#OA`vVPim)CAA4J!}FUzVbU#^Ca(`1u8iQtjQ7IM1tU7sFz zxks(QY^ckW@8ZMmOP1~UB6Ue+oDwej)uScBzigSdx7a03-FaeW7F^3i&|rP(Lqlha zHZ<3Z79=GY25@OZF$-#Ae)e9(@-T#{u9?NhwHGTm`Y{l<+Pua@5H|p?s)&LQdbR}% z2$n0=A#(;?{5Oz$kcH1&%`9w5Z5uhp-;W!GrioV2^ha4{&t<{veIs6XS>#X8wEz8Z z1WH-dvedIV=AA%?SY@K*feskQvUR|?k=0>#>Q$1m1Ox#nh+!#k?m6EN!b2{h z-7B$!B^9lMAQ=9x1V}Ym8am6!Ux3<$N_74eH!AVh(sOMAMGftErm3^rLEtaAlT;rN z)W4$OkxCC0o?q==*3n!D>y(=VY%pR(iAf!8F}tXU9N?|xjToseS!w7+W3W)&qR}3W zLdsghGKwQc(#ST0iI5!+Npny$D8b?Z_5P*LNqJQht|OWtGk|Sc%}scoxk%El2?!_* zC#4v*zLB>_4M{N)nxjWvhP`~rw*+a8k>&!E^+ZAKNK0h=3h4?w%hC&IyPi}aXnt0J z@Kjc>f>v8kBVH7A8bNo;*p(paC4&|TOG{xLrX4?ib>KNtt|>p;Qu{stut@4>W&jOZ zBLF`fgXOyVusll}mOEy>a~bhC`-=3lD^cqp(hKNdj7N!H{(aYe7y`fD=3lv+AdjTM zNFTzHm=~iUkwX+q8zLVpsB^+!03r)LrC8&p9t9sE_a^xdWtWG*fyP403mY`nBCV2q z+v+npQDTyNyNVkkpRw(P7VCWUG5WGdn;B~8h+{w~FF`2gktjFi5y5nBig7uz8Ox0p zodyJxL~Fd#W#PzHT!(l)FSRieJ!-8M9;_7Un)BsQO|paSHv(L!$YPQ;H)K0@*w#g7 zn$;Y?WjEnC@F)x=b*siHV1IcU0Yemu*>FNZ70=^8vtfg^ahwhlZH8{?w{iNUojvkyy4~}cv<8bg& ztD!8>fD?_GuPS+wiwXZ7@1Lvk@YFp{N$}!U0F7i=6I+E;ja3cy3k~nMVg#`&1F$t$ zAhFuudPz`AztDas3BMxdD{DFqkj{8cY0gF5m z4Lu)pBmB1v3V57BHwLIJ$%Yq3V#377!-ThjX?w1gzBz7tQz-i;f;f7^u8VvJi#6v0 z8X>v*@D7#F6}-zDiRrrw`uof#9*G=zI+3ePxS1P22j@qQn6l?AXSbPUYe5MEW!f>I zxi=PuS@2Ac@aY!$T3o8O{doc_aRd?^JFM2=CnLeI;$BBLB+R0$>*1Jap|T~6aUCdO z36y=gj5=)D$p&1@Zk-qtwc#oD9Sp~Sna2oERuw&2)KECrDE4!x43eZKrZL1uTyV+8EI6>+ z#;R;xfTZ}EowU&Rs{Xhg+i`BujuR?duR! z_HkL}n4Z1LPL0JFW2ZGXHaTGh=?dDZ-7hrQtGjY{C`8taH)d@>FA`w|nznM=G5+Y^ zA$Q$Yjdpa#+)?kU5*5Zurux8g;e$>%M3wJ*hc;en1B-ksuv!c{EO`z3hwuu3Vl-k% zx=>@3!WJqnD-eeJeYs%=-f&D17IEjY#rE`pMI*FmP;=$QPNo<$q7>m6Tyokab(|ck zL`LI|fcuKK3uO1BRl`?3qwyrvsL;e?tTB;K$5@d!8msl+1))ALw+$_AV(hoCg8p&< zJP59_F(g&K1KV6&_3D5b6E)c=w{1Pip-7Y(40{s{QLyIfEU7p|*tJ{tXR=ylyFLC> zV*|b&H4Th7{4U~$j>Xo8$lVzNU9qxbwmLC>hIK9LeZD`SRfZP{!=Y_3!Pr$rz$bTk zq`;Ji{m5p<#8^v=QR|gvMNSf5V|t_EU|i}v%_zl?W_(l+QRhb28I6T;!-CHRwy#&3Yi#o8Q<$gqp8OsRV za6%<@7s(QH(6=(ajuPQdSqMh7;kIokX*_~LfRZ!YfqKKYR>|AO=sn?nJXRaPGA;&m`MI=7|+_mCj^022>zMgEjDcibP6SBHU^PUPt;B%uIfK+LKtU$&jj z4ub-%tMkTdP+phG>dPQBH^i%6n0_`>;&uKFQ>#V{qV2+At3q`G7zu=K|$$iUYO>6q}=-Htfi?VT-&?WxN9fW~3Vei`KgypA124gKTr<2SNfALw?SSDO*ac}xt@ zg7B-!6@-rlzGjQ(I+j*E&(ng}4Tlv;-=@@7o2XfK!}Y&Yc%u>f7L9~AXv7YiaQ?%# z$UtZAAHsYY9r+n1M0cGD(Y@Amd_mdx`}{Y&Y0FkO|(JSI>!oxVdq8K zRy4j!WI2+?OiVY^rD=VW2y95aCYHsz`-hNR#71(SWc+X$n{+X&6+$13!#soQW|$DF(k<<@?xHe8H7 zo4O6Z;a#6hmbigCxo~%b>1BxD%xoKL|b7TDK9T7$ zgG@8p7+RZ;NZx-=4#Ji+2ZVzY+Y(cf?SeA$vvpEo&0iSHWxX{&4u0w(&2iUi0l%jei~*v9V+68-app^z&uYM z7?Kfk+6T-FdSh1*R+YD)`ZXMyKgS z(7x-7Sm@)ljhLCA4qKZ4K2*nQ1A!UHRHih;!z3L!&Vdt#jgL$eB()PYRRNMi{wyDH7M|i z=F;0}5`4|GUKPiVFEH>CcJ9)y-|$k5WhE~7yESYo2WJCK(ZjBP2L z7W7M1(fEJ#>_7P@|091$G)h=bUdb4)+fp2DrFN`9SEId2dB^w*QQj<#o7(is)7iE@ z)Z9Mt7s3xLQz`onrggF`vhEYn=eAoITipUL#Mmts_;QN{^;SkrEf%2Lg1%9;?Ki)q zAw1v!z!>EKq7Sd|;F|JomvFQ`hypk{%2*)(@LRh@=mwv<<9V^9z>AjE&evjz|8 z>0_jScQwHOY5s*H@FomBLJ<6Bp!OJ`yj11aEH$u?h8U99&qlr#EcC}@uc*JGWdnP0)H6&cH3Xk1{C{TrS|r;C z(=p_T{%CA7{H<_i@T;IT2l_V}JD-##1StFKb?M_eeZNUU5ru$v1KRWk)V?xZ><(bw zV21a=;$*HPlc(BzT4upj;{L`q2orf&Hq5ZW*!PfbMy4xT1KP9VUW_q=v`T9gJ;xV< zzprwMA@?Y~wzx5K7mTQq6%6fqW37R@Z3#93s_H`?83mN+81xxvAf;aG962+UlC)7k zC0{86zhncAzO4In-lglsvfPv+WA+ z7HIB1HNt^K+|gkY*BOY!eZC?w{i|tgl3i`k5n%jk8|wt!bSS?>8SKvnys5Fts;qd0 z*0TpG*$pN_GhV8k_M!3Tq9|WL?ktg5E=wPH!5PqN5MA(=NtGF=$Jl3lfNH82WsSZ| zS`?q*#n7rv;p<8wN`drA{H=&V@rjYwAkFAfHZ|ZR2Z;;gFHQkG0zZS0gxOL!yqBQMJJ8{R`UxsN$y`-fN4iaW zN{7$3G5Y zS3E6R+$;u;Ub4lem#{{0qH!!>If;X#IZL_+ciuUZKm8}{aDavBYh@gNSmPppAZM-p`Y-$i9PM;z`zRcLYWImguRPT1 z<;SCNIvRXpDdU8G>t;18-GHvcIl6&UjRd>gs>&1W)O`HqKYliZtFwb3IzqUQOuEIZ zO&O6aMYkv|zYMk#^= z;P*pb(TL#g`iU1Di=Sp|XKB8#e>CVS`+>DTgS2xL+BJ#UuOJLw4Vd`f${cq)sgS&j zNNYOOuvwqWB7105CPTf7YW95v8VM4PwrcWG7f*#E?Y)|mx<)eBS+ZH6Kx+grsL{|S zY+|8cqby74r`X3AV-2E@MpSH77RV@3NdGoi|0boAEEUjk;iySiArBnqq3O>9Oih)4 Ud$d3O>7Rf4|0vnwtZHWl0I_322LJ#7 diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 446cbd2097..f708b60fb3 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1134,6 +1134,7 @@ mod s07_coiling_rebirth_full_cast; mod s07_create_token_sequence_middle; mod s07_malamet_castpath; mod s07_this_way_conditions; +mod sacrificial_mana_choice; mod sakashima_of_a_thousand_faces_retains_other_abilities; mod same_is_true_type_statics; mod sandman_reanimate_self_and_land_s25; diff --git a/crates/engine/tests/integration/sacrificial_mana_choice.rs b/crates/engine/tests/integration/sacrificial_mana_choice.rs new file mode 100644 index 0000000000..12ef9a6510 --- /dev/null +++ b/crates/engine/tests/integration/sacrificial_mana_choice.rs @@ -0,0 +1,341 @@ +use engine::ai_support::candidate_actions; +use engine::game::scenario::{GameRunner, GameScenario}; +use engine::types::ability::{ + AbilityCost, AbilityDefinition, AbilityKind, Effect, ManaContribution, ManaProduction, + QuantityExpr, SacrificeCost, TargetFilter, TypeFilter, TypedFilter, +}; +use engine::types::actions::GameAction; +use engine::types::game_state::{CastPaymentMode, ManaChoice, PayCostKind, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaColor, ManaCost, ManaType}; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +const P0: PlayerId = PlayerId(0); + +fn sacrificial_mana_ability(cost: AbilityCost, color: ManaColor) -> AbilityDefinition { + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::Fixed { + colors: vec![color], + contribution: ManaContribution::Base, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + ) + .cost(cost) +} + +fn any_one_color_sacrificial_mana_ability(cost: AbilityCost) -> AbilityDefinition { + AbilityDefinition::new( + AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::AnyOneColor { + count: QuantityExpr::Fixed { value: 1 }, + color_options: vec![ManaColor::Black, ManaColor::Red], + contribution: ManaContribution::Base, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + ) + .cost(cost) +} + +fn begin_sacrificial_payment(runner: &mut GameRunner, spell: ObjectId) { + let card_id = runner.state().objects[&spell].card_id; + runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::AutoExceptSacrificialMana, + }) + .expect("the production cast path should stop before sacrificial mana"); +} + +fn offered_selection( + runner: &GameRunner, + source: ObjectId, +) -> engine::types::mana::ManaSourceSelection { + let WaitingFor::ManaSourceSelection { options, .. } = &runner.state().waiting_for else { + panic!( + "expected sacrificial mana prompt from the cast path, got {:?}", + runner.state().waiting_for + ); + }; + options + .iter() + .find(|selection| selection.source.object_id == source) + .cloned() + .expect("the prompt should retain the sacrificial source's exact capability") +} + +fn generic_spell(scenario: &mut GameScenario) -> ObjectId { + scenario + .add_spell_to_hand(P0, "Sacrificial Mana Payment Witness", true) + .with_mana_cost(ManaCost::generic(1)) + .id() +} + +/// A self-sacrificing mana ability is offered only after the real cast pipeline +/// has exhausted non-sacrificial payment rows. +#[test] +fn self_sacrificing_mana_source_pays_a_production_cast() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = generic_spell(&mut scenario); + let source = scenario + .add_creature(P0, "Blood Pet Witness", 1, 1) + .with_ability_definition(sacrificial_mana_ability( + AbilityCost::Sacrifice(SacrificeCost::count(TargetFilter::SelfRef, 1)), + ManaColor::Black, + )) + .id(); + let mut runner = scenario.build(); + + begin_sacrificial_payment(&mut runner, spell); + let selection = offered_selection(&runner, source); + runner + .act(GameAction::ActivateManaSource { selection }) + .expect("the offered self-sacrificing source should activate during payment"); + + assert_eq!(runner.state().objects[&source].zone, Zone::Graveyard); + assert_eq!( + runner.state().players[P0.0 as usize] + .mana_pool + .count_color(ManaType::Black), + 1, + "the activated source's mana reaches the pending spell payment" + ); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::ManaPayment { .. } + )); + + runner + .act(GameAction::PassPriority) + .expect("the selected mana should pay the spell through the ordinary payment reducer"); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::Priority { .. } + )); + assert_eq!(runner.state().objects[&spell].zone, Zone::Stack); +} + +/// A frozen source selection must preserve an AnyOneColor activation's normal +/// color prompt, then resume and finish the original cast. +#[test] +fn any_one_color_self_sacrifice_selection_completes_production_cast() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = generic_spell(&mut scenario); + let source = scenario + .add_creature(P0, "Gold Witness", 1, 1) + .with_ability_definition(any_one_color_sacrificial_mana_ability( + AbilityCost::Sacrifice(SacrificeCost::count(TargetFilter::SelfRef, 1)), + )) + .id(); + let mut runner = scenario.build(); + + begin_sacrificial_payment(&mut runner, spell); + let selection = offered_selection(&runner, source); + runner + .act(GameAction::ActivateManaSource { selection }) + .expect("the frozen self-sacrifice selection should enter the color prompt"); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::ChooseManaColor { .. } + )); + + runner + .act(GameAction::ChooseManaColor { + choice: ManaChoice::SingleColor(ManaType::Red), + count: 1, + }) + .expect("choosing the source's color should resume the pending payment"); + assert_eq!(runner.state().objects[&source].zone, Zone::Graveyard); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::ManaPayment { .. } + )); + + runner + .act(GameAction::PassPriority) + .expect("the chosen mana should finish the original cast"); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::Priority { .. } + )); + assert_eq!(runner.state().objects[&spell].zone, Zone::Stack); +} + +/// A non-sacrificial row on the same permanent remains available to the +/// automatic planner; only the irreversible row is held for explicit consent. +#[test] +fn automatic_payment_keeps_a_non_sacrificial_row_on_a_sacrificial_source() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = generic_spell(&mut scenario); + let source = scenario + .add_creature(P0, "Two-Row Mana Witness", 1, 1) + .as_artifact() + .with_ability_definition(sacrificial_mana_ability(AbilityCost::Tap, ManaColor::Red)) + .with_ability_definition(sacrificial_mana_ability( + AbilityCost::Sacrifice(SacrificeCost::count(TargetFilter::SelfRef, 1)), + ManaColor::Black, + )) + .id(); + let mut runner = scenario.build(); + + begin_sacrificial_payment(&mut runner, spell); + + assert!(matches!( + runner.state().waiting_for, + WaitingFor::Priority { .. } + )); + assert!(runner.state().objects[&source].tapped); + assert_eq!(runner.state().objects[&source].zone, Zone::Battlefield); + assert_eq!(runner.state().objects[&spell].zone, Zone::Stack); +} + +/// The pre-activation prompt does not bypass a mana ability's own sacrifice +/// choice; selecting another artifact pays that cost while its source remains +/// on the battlefield. +#[test] +fn sacrifice_another_permanent_mana_source_resumes_the_pending_cast() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = generic_spell(&mut scenario); + let source = scenario + .add_creature(P0, "Krark-Clan Ironworks Witness", 1, 1) + .as_artifact() + .with_ability_definition(sacrificial_mana_ability( + AbilityCost::Sacrifice(SacrificeCost::count( + TargetFilter::Typed(TypedFilter::new(TypeFilter::Artifact)), + 1, + )), + ManaColor::Red, + )) + .id(); + let sacrifice = scenario + .add_creature(P0, "Sacrificial Artifact Witness", 1, 1) + .as_artifact() + .id(); + let mut runner = scenario.build(); + + begin_sacrificial_payment(&mut runner, spell); + let selection = offered_selection(&runner, source); + runner + .act(GameAction::ActivateManaSource { selection }) + .expect("the offered source should enter its normal interactive cost payment"); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::PayCost { + kind: PayCostKind::Sacrifice, + ref choices, + .. + } if choices.contains(&sacrifice) + )); + + runner + .act(GameAction::SelectCards { + cards: vec![sacrifice], + }) + .expect("the source's ordinary sacrifice-cost reducer should accept another artifact"); + + assert_eq!(runner.state().objects[&source].zone, Zone::Battlefield); + assert_eq!(runner.state().objects[&sacrifice].zone, Zone::Graveyard); + assert_eq!( + runner.state().players[P0.0 as usize].mana_pool.total(), + 1, + "the selected ability's mana remains available to the original spell" + ); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::ManaPayment { .. } + )); +} + +/// Returning from the safety prompt keeps the pending spell but neither spends +/// mana nor performs the irreversible source activation. +#[test] +fn back_from_sacrificial_mana_prompt_preserves_the_cast_without_cancellation() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = generic_spell(&mut scenario); + let source = scenario + .add_creature(P0, "Back Button Witness", 1, 1) + .with_ability_definition(sacrificial_mana_ability( + AbilityCost::Sacrifice(SacrificeCost::count(TargetFilter::SelfRef, 1)), + ManaColor::Black, + )) + .id(); + let mut runner = scenario.build(); + + begin_sacrificial_payment(&mut runner, spell); + assert!( + !candidate_actions(runner.state()) + .iter() + .any(|candidate| matches!(candidate.action, GameAction::CancelCast)), + "the real safety prompt must not synthesize a cast-cancellation action" + ); + runner + .act(GameAction::BackToManaPayment) + .expect("the prompt's explicit back action should be accepted"); + + assert!(matches!( + runner.state().waiting_for, + WaitingFor::ManaPayment { .. } + )); + assert!(runner.state().pending_cast.is_some()); + assert_eq!(runner.state().objects[&source].zone, Zone::Battlefield); + assert_eq!(runner.state().players[P0.0 as usize].mana_pool.total(), 0); +} + +/// An engine-authored selection is revalidated at the reducer boundary, so a +/// source that became ineligible cannot be activated from a stale payment prompt. +#[test] +fn stale_sacrificial_mana_selection_is_rejected_without_mutating_payment() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = generic_spell(&mut scenario); + let source = scenario + .add_creature(P0, "Stale Selection Witness", 1, 1) + .with_ability_definition(sacrificial_mana_ability( + AbilityCost::Sacrifice(SacrificeCost::count(TargetFilter::SelfRef, 1)), + ManaColor::Black, + )) + .id(); + let mut runner = scenario.build(); + + begin_sacrificial_payment(&mut runner, spell); + let selection = offered_selection(&runner, source); + runner + .state_mut() + .objects + .get_mut(&source) + .unwrap() + .controller = PlayerId(1); + + assert!( + runner + .act(GameAction::ActivateManaSource { selection }) + .is_err(), + "a stale source must fail before the payment reducer mutates state" + ); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::ManaSourceSelection { .. } + )); + assert_eq!(runner.state().objects[&source].zone, Zone::Battlefield); + assert_eq!(runner.state().objects[&source].controller, PlayerId(1)); + assert_eq!(runner.state().players[P0.0 as usize].mana_pool.total(), 0); +} diff --git a/crates/manabrew-compat/src/lib.rs b/crates/manabrew-compat/src/lib.rs index 4651f4588c..8fa9ce7fe8 100644 --- a/crates/manabrew-compat/src/lib.rs +++ b/crates/manabrew-compat/src/lib.rs @@ -2545,7 +2545,7 @@ pub fn convert_available_action( produced_mana: None, }), }), - GameAction::TapLandForMana { selection } => { + GameAction::TapLandForMana { selection } | GameAction::ActivateManaSource { selection } => { AvailableActionConversion::Available(AvailableAction { id, kind: AvailableActionKind::ActivateAbility(ActivatableAbilityInfo { @@ -2567,9 +2567,10 @@ pub fn convert_available_action( }, }) } - GameAction::PassPriority | GameAction::CancelCast | GameAction::Concede { .. } => { - AvailableActionConversion::Skip - } + GameAction::PassPriority + | GameAction::CancelCast + | GameAction::BackToManaPayment + | GameAction::Concede { .. } => AvailableActionConversion::Skip, GameAction::DeclareAttackers { .. } => AvailableActionConversion::Skip, GameAction::DeclareBlockers { .. } => AvailableActionConversion::Skip, GameAction::ChooseUntap { .. } => { diff --git a/crates/phase-ai/src/decision_kind.rs b/crates/phase-ai/src/decision_kind.rs index 7b9d1b7e5d..82ad9727e2 100644 --- a/crates/phase-ai/src/decision_kind.rs +++ b/crates/phase-ai/src/decision_kind.rs @@ -20,7 +20,9 @@ pub fn classify(waiting_for: &WaitingFor, action: &GameAction) -> DecisionKind { WaitingFor::MulliganDecision { .. } | WaitingFor::OpeningHandBottomCards { .. } => { DecisionKind::Mulligan } - WaitingFor::ManaPayment { .. } | WaitingFor::PhyrexianPayment { .. } => { + WaitingFor::ManaPayment { .. } + | WaitingFor::ManaSourceSelection { .. } + | WaitingFor::PhyrexianPayment { .. } => { DecisionKind::ManaPayment } WaitingFor::ChooseXValue { .. } => DecisionKind::ChooseX, @@ -58,7 +60,9 @@ pub fn classify(waiting_for: &WaitingFor, action: &GameAction) -> DecisionKind { GameAction::PlayLand { .. } => DecisionKind::PlayLand, GameAction::CastSpell { .. } => DecisionKind::CastSpell, GameAction::ActivateAbility { .. } => DecisionKind::ActivateAbility, - GameAction::TapLandForMana { .. } | GameAction::UntapLandForMana { .. } => { + GameAction::TapLandForMana { .. } + | GameAction::ActivateManaSource { .. } + | GameAction::UntapLandForMana { .. } => { DecisionKind::ActivateManaAbility } // Default: any other priority-time action (PassPriority, special @@ -338,6 +342,9 @@ mod tests { }, ability_index: None, mana_type: engine::types::mana::ManaType::Green, + output: engine::types::mana::ManaSourceOutput::Concrete( + engine::types::mana::ManaType::Green, + ), atomic_combination: None, restrictions: Vec::new(), penalty: engine::types::mana::ManaSourcePenalty::None, diff --git a/crates/phase-ai/src/policies/discard_payoff.rs b/crates/phase-ai/src/policies/discard_payoff.rs index 0330d1909a..8e409884a7 100644 --- a/crates/phase-ai/src/policies/discard_payoff.rs +++ b/crates/phase-ai/src/policies/discard_payoff.rs @@ -198,6 +198,8 @@ fn candidate_discards_controller(ctx: &PolicyContext<'_>) -> bool { | GameAction::MulliganDecision { .. } | GameAction::ReorderHand { .. } | GameAction::TapLandForMana { .. } + | GameAction::ActivateManaSource { .. } + | GameAction::BackToManaPayment | GameAction::UntapLandForMana { .. } | GameAction::SpendPoolMana { .. } | GameAction::UnspendPoolMana { .. } diff --git a/crates/phase-ai/src/policies/draw_payoff.rs b/crates/phase-ai/src/policies/draw_payoff.rs index 1523e59c75..bd3c2d682a 100644 --- a/crates/phase-ai/src/policies/draw_payoff.rs +++ b/crates/phase-ai/src/policies/draw_payoff.rs @@ -225,6 +225,8 @@ fn candidate_draws_structurally(ctx: &PolicyContext<'_>) -> bool { | GameAction::MulliganDecision { .. } | GameAction::ReorderHand { .. } | GameAction::TapLandForMana { .. } + | GameAction::ActivateManaSource { .. } + | GameAction::BackToManaPayment | GameAction::UntapLandForMana { .. } | GameAction::SpendPoolMana { .. } | GameAction::UnspendPoolMana { .. } diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index fc84ed5f34..70bba9440e 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -767,6 +767,19 @@ pub fn emit_trace_for_candidate( /// `config` supplies policy penalties used by selection escapes (e.g. sacrifice /// value ordering); difficulty/search knobs are unused here. pub fn fallback_action(state: &GameState, config: &AiConfig) -> Option { + // CR 605.3b: A sacrificial mana prompt is an explicit payment decision, + // not a generic pending-cast failure. Pick only an engine-issued source or + // the exact BackToManaPayment escape; never synthesize CancelCast here. + if matches!(state.waiting_for, WaitingFor::ManaSourceSelection { .. }) { + return engine::ai_support::legal_actions(state) + .into_iter() + .find(|action| { + matches!( + action, + GameAction::ActivateManaSource { .. } | GameAction::BackToManaPayment + ) + }); + } // CR 601.2c: A spell's target step must use the engine's current legal // target list. `target_slots` is a historical snapshot and can be stale // after earlier selections; if no current legal action remains, abort the @@ -781,7 +794,11 @@ pub fn fallback_action(state: &GameState, config: &AiConfig) -> Option Option Option { // These are all pending-cast states — the has_pending_cast guard - // above already returned CancelCast. This branch is unreachable - // at runtime but keeps the match exhaustive. + // above already returned CancelCast. ManaSourceSelection is + // intercepted above and never synthesizes CancelCast. This branch + // is unreachable at runtime but keeps the match exhaustive. Some(GameAction::CancelCast) } } diff --git a/crates/server-core/src/client_message_wire_guard.rs b/crates/server-core/src/client_message_wire_guard.rs index b66fc9f433..3fe9c116f4 100644 --- a/crates/server-core/src/client_message_wire_guard.rs +++ b/crates/server-core/src/client_message_wire_guard.rs @@ -304,6 +304,7 @@ mod tests { source: ObjectIncarnationRef::of(ObjectId(1), 1), ability_index: None, mana_type: ManaType::Green, + output: engine::types::mana::ManaSourceOutput::Concrete(ManaType::Green), atomic_combination: None, restrictions: vec![ManaRestriction::OnlyForAny(vec![ ManaRestriction::OnlyForSpell; @@ -328,6 +329,7 @@ mod tests { source: ObjectIncarnationRef::of(ObjectId(1), 1), ability_index: None, mana_type: ManaType::Green, + output: engine::types::mana::ManaSourceOutput::Concrete(ManaType::Green), atomic_combination: None, restrictions: Vec::new(), penalty: ManaSourcePenalty::None, diff --git a/crates/server-core/src/game_action_payload_guard.rs b/crates/server-core/src/game_action_payload_guard.rs index 29442cfc55..5844a3c693 100644 --- a/crates/server-core/src/game_action_payload_guard.rs +++ b/crates/server-core/src/game_action_payload_guard.rs @@ -565,7 +565,7 @@ pub fn guard_game_action_payload(action: &GameAction) -> Result<(), String> { bound_list("SetPhaseStops.stops", stops.len())?; } GameAction::SetPriorityPassingMode { .. } => {} - GameAction::TapLandForMana { selection } => { + GameAction::TapLandForMana { selection } | GameAction::ActivateManaSource { selection } => { guard_mana_source_selection_payload(selection)?; } GameAction::DistributeAmong { distribution, .. } => { @@ -618,6 +618,7 @@ pub fn guard_game_action_payload(action: &GameAction) -> Result<(), String> { | GameAction::ChooseAssistPlayer { .. } | GameAction::CommitAssistPayment { .. } | GameAction::MulliganDecision { .. } + | GameAction::BackToManaPayment | GameAction::UntapLandForMana { .. } | GameAction::SpendPoolMana { .. } | GameAction::UnspendPoolMana { .. } diff --git a/crates/server-core/src/protocol.rs b/crates/server-core/src/protocol.rs index ea2b06b7a0..4e9069a6f0 100644 --- a/crates/server-core/src/protocol.rs +++ b/crates/server-core/src/protocol.rs @@ -627,6 +627,7 @@ mod tests { source: ObjectIncarnationRef::of(ObjectId(7), 3), ability_index: None, mana_type: ManaType::Green, + output: engine::types::mana::ManaSourceOutput::Concrete(ManaType::Green), atomic_combination: None, restrictions: Vec::new(), penalty: ManaSourcePenalty::None, @@ -653,6 +654,22 @@ mod tests { } _ => panic!("wrong variant"), } + + let GameAction::TapLandForMana { selection } = action else { + unreachable!("fixture action is a land-mana selection"); + }; + let generic = GameAction::ActivateManaSource { selection }; + let json = serde_json::to_string(&ClientMessage::Action { + action: generic.clone(), + }) + .unwrap(); + let parsed: ClientMessage = serde_json::from_str(&json).unwrap(); + match parsed { + ClientMessage::Action { + action: restored_action, + } => assert_eq!(restored_action, generic), + _ => panic!("wrong variant"), + } } #[test] diff --git a/crates/server-core/tests/game_action_payload_guard.rs b/crates/server-core/tests/game_action_payload_guard.rs index 5a2c77fc86..9aa6d4bfe6 100644 --- a/crates/server-core/tests/game_action_payload_guard.rs +++ b/crates/server-core/tests/game_action_payload_guard.rs @@ -31,6 +31,7 @@ fn mana_source_selection() -> ManaSourceSelection { source: ObjectIncarnationRef::of(ObjectId(1), 1), ability_index: Some(0), mana_type: ManaType::Green, + output: engine::types::mana::ManaSourceOutput::Concrete(ManaType::Green), atomic_combination: None, restrictions: Vec::new(), penalty: ManaSourcePenalty::None, From c363a34ded16ac92de6d8d680504954da51b973d Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:46:10 -0700 Subject: [PATCH 38/63] test: cover stale optional search frame invariant (#6820) Co-authored-by: matthewevans --- .../cr733_resolved_frame_transition.rs | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/crates/engine/tests/integration/cr733_resolved_frame_transition.rs b/crates/engine/tests/integration/cr733_resolved_frame_transition.rs index 8142d06d1f..d17b22ae1e 100644 --- a/crates/engine/tests/integration/cr733_resolved_frame_transition.rs +++ b/crates/engine/tests/integration/cr733_resolved_frame_transition.rs @@ -6,13 +6,13 @@ use engine::types::ability::{ }; use engine::types::game_state::{ DrawSequenceStack, GameState, PendingContinuation, PostReplacementDrain, - PostReplacementDrainStack, ResidentDrainPolicy, + PostReplacementDrainStack, ResidentDrainPolicy, WaitingFor, }; use engine::types::identifiers::ObjectId; use engine::types::player::PlayerId; use engine::types::resolution::{ - FrameKind, MultiDrawFrame, PendingCoinFlip, PendingCoinFlipKind, ResolutionFrame, - ResolutionStackError, ResolutionStateWire, + FrameKind, MultiDrawFrame, OptionalEffectFrame, PendingCoinFlip, PendingCoinFlipKind, + ResolutionFrame, ResolutionStackError, ResolutionStateWire, }; use engine::types::resolved_commands::{ ResolvedCommandOrdinal, ResolvedFrameTransition, ResolvedFrameTransitionCommand, @@ -140,6 +140,44 @@ fn malformed_prompt_coherence_errors_atomically() { assert_eq!(state.resolution_stack, before); } +/// Regression for #6805: an optional-effect prompt owner must be removed before +/// an accepted optional search installs `SearchChoice`. Parent insertion is the +/// first boundary that validates this stale-owner state, and must reject it +/// atomically rather than burying the direct-choice frame. +#[test] +fn optional_effect_frame_cannot_survive_into_search_choice_parent_insertion() { + let mut state = GameState::new_two_player(97); + let optional_ability = pending_continuation(&state).chain; + state + .resolution_stack + .push_inner(ResolutionFrame::OptionalEffect(OptionalEffectFrame { + ability: optional_ability, + trigger_event: None, + trigger_match_count: None, + })); + state.waiting_for = WaitingFor::SearchChoice { + player: PlayerId(0), + library_owner: Some(PlayerId(0)), + cards: Vec::new(), + count: 0, + reveal: false, + up_to: false, + allows_partial_find: false, + constraint: Default::default(), + split: None, + }; + let before = state.resolution_stack.clone(); + + assert_eq!( + state.insert_ability_continuation_parent_of_active(pending_continuation(&state)), + Err(ResolutionStackError::PromptMismatch { + frame: FrameKind::OptionalEffect, + waiting_for: "SearchChoice", + }) + ); + assert_eq!(state.resolution_stack, before); +} + /// Missing parents and wrong active kinds are typed failures; neither failure /// changes the existing stack. #[test] From 6c79e41a5fee318f33cd701674856d00d226a2a7 Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:46:50 -0700 Subject: [PATCH 39/63] chore(card-data): refresh MTGJSON token & subtype catalogs (#6821) --- crates/engine/data/mtgjson-vintage | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/data/mtgjson-vintage b/crates/engine/data/mtgjson-vintage index 763f196f14..1f2c340010 100644 --- a/crates/engine/data/mtgjson-vintage +++ b/crates/engine/data/mtgjson-vintage @@ -1 +1 @@ -2026-07-29 +2026-07-30 From 56509356cad8d749e3df66468262a67bf300b885 Mon Sep 17 00:00:00 2001 From: atoz96 <132365140+atoz96@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:19:08 -0400 Subject: [PATCH 40/63] ci(ai-gate): create target/ before redirecting the nightly reports (#6815) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci(ai-gate): create target/ before redirecting the nightly reports both nightly monitors have failed every run since at least 2026-07-30. neither failure is drift: bash opens `> target/ai-gate-report.md` before cargo runs, and on a cardgen cache hit the `Generate card data` step above is skipped, so nothing has invoked cargo yet and `target/` does not exist. the redirect dies with line 1: target/ai-gate-report.md: No such file or directory so `cargo ai-gate` never executes at all — the nightly gate has been measuring nothing. `continue-on-error: true` then lets the job continue to `Open or update drift issue`, which is what actually reddens the run: it does `body="$(cat target/ai-gate-report.md)"` on a file that was never created, fails under `bash -e`, and no drift issue is ever filed. `mkdir -p target` before each redirect. the decision-cost perf job has the identical ordering and the identical failure, so both are fixed. * ci(ai-gate): make the perf job's redirect comment self-contained the comment pointed at "the win-rate job above", which reads as a dangling reference when the job is viewed on its own and breaks if the jobs are ever reordered. spell the same reasoning out in place so each job explains its own mkdir. * fix(PR-6815): preserve nightly infrastructure failures --------- Co-authored-by: atoz96 Co-authored-by: matthewevans --- .github/workflows/ai-gate.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/ai-gate.yml b/.github/workflows/ai-gate.yml index dafedafec4..81eeb0a627 100644 --- a/.github/workflows/ai-gate.yml +++ b/.github/workflows/ai-gate.yml @@ -79,6 +79,11 @@ jobs: id: gate continue-on-error: true run: | + # bash opens the redirect before cargo runs, so `target/` has to exist + # first. On a cardgen cache hit the `Generate card data` step above is + # skipped, and nothing else has invoked cargo yet — the redirect then + # dies with "No such file or directory" and the gate never executes. + mkdir -p target cargo ai-gate --full-suite --games 100 > target/ai-gate-report.md - name: Open or update drift issue @@ -87,6 +92,10 @@ jobs: GH_TOKEN: ${{ github.token }} run: | title="Nightly AI gate drift" + if [ ! -s target/ai-gate-report.md ]; then + echo "AI gate failed without a drift report" >&2 + exit 1 + fi body="$(cat target/ai-gate-report.md)" existing="$(gh issue list --label ai-gate-drift --state open --json number --jq '.[0].number')" if [ -n "$existing" ]; then @@ -162,6 +171,11 @@ jobs: id: gate continue-on-error: true run: | + # bash opens the redirect before cargo runs, so `target/` has to exist + # first. On a cardgen cache hit the `Generate card data` step above is + # skipped, and nothing else has invoked cargo yet — the redirect then + # dies with "No such file or directory" and the gate never executes. + mkdir -p target cargo ai-perf-gate > target/ai-perf-gate-report.md - name: Open or update drift issue @@ -170,6 +184,10 @@ jobs: GH_TOKEN: ${{ github.token }} run: | title="Nightly decision-cost perf drift" + if [ ! -s target/ai-perf-gate-report.md ]; then + echo "Decision-cost perf gate failed without a drift report" >&2 + exit 1 + fi body="$(cat target/ai-perf-gate-report.md)" existing="$(gh issue list --label ai-perf-drift --state open --json number --jq '.[0].number')" if [ -n "$existing" ]; then From e71861e87381d9c710522665ead9d99c94d2f17f Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:28:39 -0700 Subject: [PATCH 41/63] feat: add scoped BO3 concession controls (#6817) * feat: add scoped BO3 concession controls * fix: restore BO3 concession engine imports * fix: complete full-session terminal wiring * fix: redeliver P2P terminal results after reconnect * fix: harden recovered P2P match state * fix: reject joins without full runtime * fix: validate draft intergame submissions * test: stabilize P2P terminal and sideboard coverage * test: narrow draft launch fixture type * fix: remove redundant Full generation allocator * fix: scope legacy persistence helpers to tests * test: audit match forfeit state authority --------- Co-authored-by: matthewevans --- .../__tests__/p2p-adapter-multiplayer.test.ts | 290 +++- .../adapter/__tests__/p2pDraftHostBo3.test.ts | 335 ++++- .../src/adapter/__tests__/ws-adapter.test.ts | 60 +- client/src/adapter/draftPodGuestAdapter.ts | 40 +- client/src/adapter/draftPodHostAdapter.ts | 26 +- client/src/adapter/p2p-adapter.ts | 579 ++++++-- client/src/adapter/p2p-draft-guest.ts | 48 +- client/src/adapter/p2p-draft-host.ts | 596 +++++++- client/src/adapter/types.ts | 20 + client/src/adapter/ws-adapter.ts | 203 ++- .../components/multiplayer/ConcedeDialog.tsx | 42 +- .../__tests__/ConcedeDialog.test.tsx | 55 + .../__tests__/useConcedeHandler.test.tsx | 110 +- client/src/hooks/useConcedeHandler.ts | 40 +- client/src/i18n/locales/de/multiplayer.json | 11 +- client/src/i18n/locales/en/multiplayer.json | 11 +- client/src/i18n/locales/es/multiplayer.json | 11 +- client/src/i18n/locales/fr/multiplayer.json | 11 +- client/src/i18n/locales/it/multiplayer.json | 11 +- client/src/i18n/locales/pl/multiplayer.json | 11 +- client/src/i18n/locales/pt/multiplayer.json | 11 +- .../network/__tests__/draftProtocol.test.ts | 20 +- client/src/network/__tests__/protocol.test.ts | 7 +- client/src/network/draftProtocol.ts | 77 +- client/src/network/protocol.ts | 33 +- client/src/pages/DraftPodPage.tsx | 5 + client/src/pages/GamePage.tsx | 64 +- .../DraftPodPage.betweenGames.test.tsx | 90 +- .../GamePage.bracketViolation.test.tsx | 68 +- client/src/providers/GameProvider.tsx | 122 +- .../GameProvider.nativeEngine.test.tsx | 4 +- .../__tests__/draftPersistence.test.ts | 33 + .../__tests__/fullTerminalResult.test.ts | 69 + .../__tests__/gamePersistence.test.ts | 28 +- .../__tests__/intergameCommandLedger.test.ts | 51 + .../__tests__/multiplayerSession.test.ts | 18 + .../src/services/__tests__/p2pSession.test.ts | 50 + .../__tests__/p2pTerminalResult.test.ts | 55 + client/src/services/draftPersistence.ts | 104 ++ client/src/services/fullTerminalResult.ts | 114 ++ client/src/services/gamePersistence.ts | 13 +- client/src/services/intergameCommandLedger.ts | 179 +++ client/src/services/multiplayerSession.ts | 15 +- client/src/services/p2pSession.ts | 81 +- client/src/services/p2pTerminalResult.ts | 121 ++ .../__tests__/multiplayerDraftStore.test.ts | 22 +- .../stores/__tests__/multiplayerStore.test.ts | 8 + client/src/stores/multiplayerDraftStore.ts | 230 ++- client/src/stores/multiplayerStore.ts | 13 +- client/src/wasm/engine_wasm.d.ts | 22 +- crates/engine/src/game/match_flow.rs | 114 +- crates/engine/src/types/game_state.rs | 9 +- crates/engine/src/types/match_config.rs | 20 + crates/engine/src/types/mod.rs | 3 +- .../fixtures/cr733/authority_matrix.json.gz | Bin 40792 -> 40811 bytes crates/phase-server/src/main.rs | 940 ++++++++++--- crates/phase-server/src/persistence.rs | 1245 ++++++++++++++++- .../src/client_message_wire_guard.rs | 40 +- crates/server-core/src/lib.rs | 8 +- crates/server-core/src/protocol.rs | 163 +++ crates/server-core/src/session.rs | 127 ++ .../server-core/tests/lobby_wire_contract.rs | 1 + 62 files changed, 6177 insertions(+), 730 deletions(-) create mode 100644 client/src/components/multiplayer/__tests__/ConcedeDialog.test.tsx create mode 100644 client/src/services/__tests__/fullTerminalResult.test.ts create mode 100644 client/src/services/__tests__/intergameCommandLedger.test.ts create mode 100644 client/src/services/__tests__/p2pSession.test.ts create mode 100644 client/src/services/__tests__/p2pTerminalResult.test.ts create mode 100644 client/src/services/fullTerminalResult.ts create mode 100644 client/src/services/intergameCommandLedger.ts create mode 100644 client/src/services/p2pTerminalResult.ts diff --git a/client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts b/client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts index 74bae5e528..96803efe1f 100644 --- a/client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts +++ b/client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts @@ -12,9 +12,10 @@ import type Peer from "peerjs"; import type { DataConnection } from "peerjs"; import { P2PGuestAdapter, P2PHostAdapter, playerSlotsFromSeatView } from "../p2p-adapter"; -import type { FormatConfig, GameAction, GameEvent, GameLogEntry, GameState } from "../types"; +import { supportsMatchConcede, type FormatConfig, type GameAction, type GameEvent, type GameLogEntry, type GameState } from "../types"; import { FakeDataConnection } from "../../network/__tests__/fakeDataConnection"; import { WIRE_PROTOCOL_VERSION } from "../../network/protocol"; +import { p2pFinalStateCommitment } from "../../services/p2pTerminalResult"; // `vi.mock` is hoisted above imports, so the factory can't reference module // scope. Inline the wire-format stub. See `./protocolTestStub.ts` for the @@ -40,10 +41,23 @@ vi.mock("../../network/protocol", async (orig) => { }; }); +vi.mock("../../services/p2pTerminalResult", async (orig) => { + const actual = await orig(); + return { + ...actual, + clearP2PTerminalResult: vi.fn(async () => undefined), + commitP2PTerminalResult: vi.fn(async () => true), + }; +}); + // ── Mock the WasmAdapter so we don't need an actual WASM build ───────────── // `vi.hoisted` lets us share these refs with the hoisted vi.mock factory. const mocks = vi.hoisted(() => { - const getState = vi.fn(async () => ({ players: [], objects: {} })); + const getState = vi.fn(async () => ({ + players: [], + objects: {}, + waiting_for: { type: "Priority", data: { player: 0 } }, + })); const getLegalActions = vi.fn(async () => ({ actions: [], autoPassRecommended: false, @@ -417,6 +431,79 @@ describe("P2PHostAdapter — 3-4p multiplayer", () => { expect(mockSetMultiplayerMode).toHaveBeenCalledTimes(1); }); + it("fences a stale host when a same-session resume claims a new incarnation", async () => { + const persistedSession = { + gameId: "lease-game", + roomCode: "ABCDE", + sessionKey: "stable-p2p-session", + useBroker: false, + playerTokens: {}, + guestDecks: {}, + kickedTokens: [], + eliminatedSeats: [], + playerCount: 2, + hostDeckData: { + player: { main_deck: ["Mountain"], sideboard: [] }, + opponent: { main_deck: ["Forest"], sideboard: [] }, + ai_decks: [], + }, + gameStarted: false, + }; + const stalePeer = createFakePeer(); + const currentPeer = createFakePeer(); + const stale = new P2PHostAdapter( + persistedSession.hostDeckData, + stalePeer.peer as unknown as Peer, + stalePeer.onGuestConnected, + 2, + undefined, + undefined, + 5_000, + undefined, + true, + undefined, + { gameId: "lease-game", roomCode: "ABCDE", resumeData: { session: persistedSession } }, + ); + await stale.initialize(); + + const current = new P2PHostAdapter( + persistedSession.hostDeckData, + currentPeer.peer as unknown as Peer, + currentPeer.onGuestConnected, + 2, + undefined, + undefined, + 5_000, + undefined, + true, + undefined, + { gameId: "lease-game", roomCode: "ABCDE", resumeData: { session: persistedSession } }, + ); + await current.initialize(); + + const staleGuest = new FakeOpenableConnection(); + stalePeer.emitConnection(staleGuest as unknown as DataConnection); + staleGuest.fireOpen(); + await flushPromises(); + expect(await staleGuest.getSentMessages()).toContainEqual({ + type: "reconnect_rejected", + reason: "Host session superseded", + }); + + const currentGuest = await joinGuest(currentPeer.emitConnection, { + type: "guest_deck", + deckData: { player: { main_deck: ["Plains"], sideboard: [] } }, + }); + await flushPromises(); + expect(current.getPlayerSlots()[1]?.kind.type).toBe("JoinedHuman"); + expect((await currentGuest.getSentMessages()).some( + (message) => (message as { authority?: { sessionKey?: string } }).authority?.sessionKey === "stable-p2p-session", + )).toBe(true); + + stale.dispose(); + current.dispose(); + }); + it("retries failed initialization without duplicating guest connections", async () => { const { adapter, emitConnection } = makeHost(2); mockInitialize @@ -1547,4 +1634,203 @@ describe("P2PHostAdapter — 3-4p multiplayer", () => { // Strictly increasing stamps let the store's gate order these commits. expect(second.seq).toBeGreaterThan(first.seq); }); + + it("commits each terminal result to the recipient's filtered final state", async () => { + const { adapter, emitConnection } = makeHost(2); + await adapter.initialize(); + const guest = await joinGuest(emitConnection, { + type: "guest_deck", + deckData: { player: { main_deck: [], sideboard: [] } }, + }); + await adapter.initializeGame(); + + const hostState = { + players: [], + objects: { 7: { name: "Secret Hand Card" } }, + waiting_for: { type: "GameOver", data: { winner: 0 } }, + } as unknown as GameState; + const guestState = { + players: [], + objects: { 7: { name: "Hidden Card" } }, + waiting_for: { type: "GameOver", data: { winner: 0 } }, + } as unknown as GameState; + (mockGetViewerSnapshot as unknown as { + mockImplementation: (implementation: (playerId: number) => Promise) => void; + }).mockImplementation(async (playerId: number) => ({ + state: playerId === 1 ? guestState : hostState, + actions: [], + autoPassRecommended: false, + })); + + await (adapter as unknown as { + commitTerminalIfComplete: (snapshot: unknown, revision: number) => Promise; + }).commitTerminalIfComplete({ + state: hostState, + legalResult: { actions: [], autoPassRecommended: false }, + seq: 42, + }, 42); + + const terminal = (await guest.getSentMessages()).find( + (message) => (message as { type?: string }).type === "terminal_result", + ) as { type: "terminal_result"; result: { recipient: number; finalStateCommitment: string } } | undefined; + expect(terminal?.result.recipient).toBe(1); + expect(terminal?.result.finalStateCommitment).toBe( + await p2pFinalStateCommitment(guestState), + ); + expect(terminal?.result.finalStateCommitment).not.toBe( + await p2pFinalStateCommitment(hostState), + ); + adapter.dispose(); + }); + + it("redelivers a recipient-bound terminal result after a guest reconnects", async () => { + const { adapter, emitConnection } = makeHost(2); + await adapter.initialize(); + const guest = await joinGuest(emitConnection, { + type: "guest_deck", + deckData: { player: { main_deck: [], sideboard: [] } }, + }); + await adapter.initializeGame(); + const setup = (await guest.getSentMessages()).find( + (message): message is { type: "game_setup"; playerToken: string } => + typeof message === "object" + && message !== null + && (message as { type?: string }).type === "game_setup", + ); + const terminalState = { + players: [], + objects: { 7: { name: "Hidden Card" } }, + waiting_for: { type: "GameOver", data: { winner: 0 } }, + } as unknown as GameState; + (mockGetViewerSnapshot as unknown as { mockResolvedValue: (value: unknown) => void }).mockResolvedValue({ + state: terminalState, + actions: [], + autoPassRecommended: false, + }); + await (adapter as unknown as { + commitTerminalIfComplete: (snapshot: unknown, revision: number) => Promise; + }).commitTerminalIfComplete({ + state: terminalState, + legalResult: { actions: [], autoPassRecommended: false }, + seq: 42, + }, 42); + + guest.simulateClose(); + const reconnect = await joinGuest(emitConnection, { + type: "reconnect", + playerToken: setup!.playerToken, + }); + await vi.waitFor(async () => { + const messages = await reconnect.getSentMessages(); + expect(messages.some((message) => + typeof message === "object" + && message !== null + && (message as { type?: string }).type === "terminal_result")).toBe(true); + }); + const messages = await reconnect.getSentMessages(); + const ackIndex = messages.findIndex((message) => + typeof message === "object" + && message !== null + && (message as { type?: string }).type === "reconnect_ack"); + const terminal = messages.find( + (message): message is { type: "terminal_result"; result: { recipient: number; finalStateCommitment: string } } => + typeof message === "object" + && message !== null + && (message as { type?: string }).type === "terminal_result", + ); + expect(ackIndex).toBeGreaterThanOrEqual(0); + expect(messages.indexOf(terminal!)).toBeGreaterThan(ackIndex); + expect(terminal?.result.recipient).toBe(1); + expect(terminal?.result.finalStateCommitment).toBe( + await p2pFinalStateCommitment(terminalState), + ); + adapter.dispose(); + }); +}); + +describe("P2PHostAdapter — bound draft match concession", () => { + it("installs the capability only when a pod binding supplies its forwarder", async () => { + const { peer, onGuestConnected } = createFakePeer(); + const onConcede = vi.fn(); + const adapter = new P2PHostAdapter( + { player: { main_deck: [], sideboard: [] }, opponent: { main_deck: [], sideboard: [] }, ai_decks: [] }, + peer as unknown as Peer, + onGuestConnected, + 2, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { onConcede }, + ); + + expect(supportsMatchConcede(adapter)).toBe(true); + await adapter.initialize(); + (adapter as unknown as { gameStarted: boolean }).gameStarted = true; + adapter.sendMatchConcede(); + adapter.sendMatchConcede(); + expect(onConcede).toHaveBeenCalledTimes(1); + expect(onConcede).toHaveBeenCalledWith(0); + adapter.dispose(); + }); + + it("routes a bound guest request to match settlement without conceding the engine game", async () => { + const { peer, onGuestConnected, emitConnection } = createFakePeer(); + const onConcede = vi.fn(); + const adapter = new P2PHostAdapter( + { player: { main_deck: [], sideboard: [] }, opponent: { main_deck: [], sideboard: [] }, ai_decks: [] }, + peer as unknown as Peer, + onGuestConnected, + 2, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { onConcede }, + ); + await adapter.initialize(); + const guest = await joinGuest(emitConnection, { + type: "guest_deck", + deckData: { player: { main_deck: [], sideboard: [] } }, + }); + await adapter.initializeGame(); + mockSubmitAction.mockClear(); + + await guest.simulateData({ type: "match_concede" }); + + expect(onConcede).toHaveBeenCalledTimes(1); + expect(onConcede).toHaveBeenCalledWith(1); + expect(mockSubmitAction).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "Concede" }), + expect.anything(), + ); + adapter.dispose(); + }); + + it("rejects a protected match request when no draft binding was installed", async () => { + const { adapter, emitConnection } = makeHost(2); + await adapter.initialize(); + const guest = await joinGuest(emitConnection, { + type: "guest_deck", + deckData: { player: { main_deck: [], sideboard: [] } }, + }); + await adapter.initializeGame(); + guest.sent.length = 0; + + await guest.simulateData({ type: "match_concede" }); + + expect(await guest.getSentMessages()).toContainEqual(expect.objectContaining({ + type: "action_rejected", + reason: "Whole-match concession is unavailable for this game", + })); + adapter.dispose(); + }); }); diff --git a/client/src/adapter/__tests__/p2pDraftHostBo3.test.ts b/client/src/adapter/__tests__/p2pDraftHostBo3.test.ts index a9fbdacc21..98fbc65f3b 100644 --- a/client/src/adapter/__tests__/p2pDraftHostBo3.test.ts +++ b/client/src/adapter/__tests__/p2pDraftHostBo3.test.ts @@ -12,19 +12,283 @@ vi.mock("../draft-adapter", () => ({ })); import { P2PDraftHost } from "../p2p-draft-host"; -import type { DraftP2PMessage } from "../../network/draftProtocol"; +import type { DraftMatchBinding, DraftMatchLaunch, DraftP2PMessage } from "../../network/draftProtocol"; import type { DraftPlayerView, PairingView } from "../draft-adapter"; +import { draftIntergameDigest, type DraftIntergameCommand } from "../../services/intergameCommandLedger"; describe("P2PDraftHost Bo3", () => { + describe("durable intergame ledger", () => { + function command(seat: number, launchDigest: string): DraftIntergameCommand { + return { + commandId: `sideboard-${seat}`, + matchId: "bo3-1", + gameNumber: 2, + seat, + payload: { type: "SubmitSideboard", main: [], sideboard: [] }, + launchPayload: { matchId: "bo3-1", seat }, + launchDigest, + payloadDigest: draftIntergameDigest({ type: "SubmitSideboard", main: [], sideboard: [] }), + status: "Pending", + }; + } + + it("authorizes both Traditional sideboards only after both held commands arrive", () => { + const host = new P2PDraftHost( + { id: "host" } as never, () => () => {}, + { type: "Set", data: { set_pool_json: "{}" } } as never, + "Traditional", 8, "Host", "Swiss", "Casual", + ); + const sent = new Map([[1, []], [2, []]]); + const privateHost = host as unknown as { + bo3State: Map; + launchDigests: Map>; + matchDecks: Map>; + guestSessions: Map void }>; + intergameCommands: { snapshot: () => DraftIntergameCommand[] }; + }; + privateHost.bo3State.set("bo3-1", { + seatA: 1, seatB: 2, submittedA: false, submittedB: false, + loserSeat: 1, gameNumber: 2, score: { p0_wins: 0, p1_wins: 1, draws: 0 }, + }); + const launch1 = draftIntergameDigest({ matchId: "bo3-1", seat: 1 }); + const launch2 = draftIntergameDigest({ matchId: "bo3-1", seat: 2 }); + privateHost.launchDigests.set("bo3-1", new Map([[1, launch1], [2, launch2]])); + privateHost.matchDecks.set("bo3-1", new Map([ + [1, { main_deck: [], sideboard: [], commander: [] }], + [2, { main_deck: [], sideboard: [], commander: [] }], + ])); + for (const seat of [1, 2]) { + privateHost.guestSessions.set(seat, { send: (message) => sent.get(seat)!.push(message) }); + } + + host.submitAuthorized(1, command(1, launch1)); + expect(sent.get(1)).toEqual([]); + host.submitAuthorized(2, command(2, launch2)); + expect(sent.get(1)?.[0]?.type).toBe("draft_bo3_intergame_authorized"); + expect(sent.get(2)?.[0]?.type).toBe("draft_bo3_intergame_authorized"); + expect(privateHost.intergameCommands.snapshot().every((entry) => entry.status === "Executing")).toBe(true); + }); + + it("rejects forged and stale held commands before authorization", () => { + const host = new P2PDraftHost( + { id: "host" } as never, () => () => {}, + { type: "Set", data: { set_pool_json: "{}" } } as never, + "Traditional", 8, "Host", "Swiss", "Casual", + ); + const privateHost = host as unknown as { + bo3State: Map; + launchDigests: Map>; + matchDecks: Map>; + intergameCommands: { snapshot: () => DraftIntergameCommand[] }; + }; + privateHost.bo3State.set("bo3-1", { + seatA: 1, seatB: 2, submittedA: false, submittedB: false, + loserSeat: 1, gameNumber: 2, score: { p0_wins: 0, p1_wins: 1, draws: 0 }, + }); + const launch1 = draftIntergameDigest({ matchId: "bo3-1", seat: 1 }); + privateHost.launchDigests.set("bo3-1", new Map([[1, launch1]])); + privateHost.matchDecks.set("bo3-1", new Map([ + [1, { main_deck: [], sideboard: [], commander: [] }], + ])); + host.submitAuthorized(1, { ...command(1, launch1), payloadDigest: "forged" }); + host.submitAuthorized(1, command(1, "stale")); + expect(privateHost.intergameCommands.snapshot()).toEqual([]); + }); + + it("rejects a sideboard submission that changes the registered deck pool", () => { + const host = new P2PDraftHost( + { id: "host" } as never, () => () => {}, + { type: "Set", data: { set_pool_json: "{}" } } as never, + "Traditional", 8, "Host", "Swiss", "Casual", + ); + const sent: DraftP2PMessage[] = []; + const privateHost = host as unknown as { + bo3State: Map; + launchDigests: Map>; + matchDecks: Map>; + guestSessions: Map void }>; + intergameCommands: { snapshot: () => DraftIntergameCommand[] }; + }; + privateHost.bo3State.set("bo3-1", { + seatA: 1, seatB: 2, submittedA: false, submittedB: false, + loserSeat: 1, gameNumber: 2, score: { p0_wins: 0, p1_wins: 0, draws: 0 }, decks: [], + }); + const launchDigest = draftIntergameDigest({ matchId: "bo3-1", seat: 1 }); + privateHost.launchDigests.set("bo3-1", new Map([[1, launchDigest]])); + privateHost.matchDecks.set("bo3-1", new Map([ + [1, { main_deck: ["Plains"], sideboard: ["Negate"], commander: [] }], + ])); + privateHost.guestSessions.set(1, { send: (message) => sent.push(message) }); + const payload = { + type: "SubmitSideboard" as const, + main: [{ name: "Island", count: 1 }], + sideboard: [{ name: "Negate", count: 1 }], + }; + + host.submitAuthorized(1, { + ...command(1, launchDigest), + payload, + payloadDigest: draftIntergameDigest(payload), + }); + + expect(privateHost.intergameCommands.snapshot()).toEqual([]); + expect(sent).toEqual([{ type: "draft_error", reason: "Invalid sideboard submission" }]); + }); + }); + describe("BO3-02: sideboard timer auto-submit", () => { - it.todo( - "auto-submits current deck when 60s sideboard timer expires in Competitive mode", - ); + it("issues unchanged deck defaults through the authorized intergame ledger", () => { + vi.useFakeTimers(); + try { + const host = new P2PDraftHost( + { id: "host" } as never, () => () => {}, + { type: "Set", data: { set_pool_json: "{}" } } as never, + "Traditional", 8, "Host", "Swiss", "Competitive", + ); + const sent = new Map([[1, []], [2, []]]); + const binding: DraftMatchBinding = { + podId: "draft-1", matchId: "bo3-1", round: 1, sessionKey: "session", lease: "lease", nonce: "nonce", revision: 0, matchAuthoritySeat: 1, + }; + const launch = (seat: number): Extract => ({ + type: "HumanGuest", + matchId: "bo3-1", + matchRoomCode: "room", + round: 1, + localSeat: seat, + opponentSeat: seat === 1 ? 2 : 1, + opponentName: "Opponent", + matchHostPeerId: "room", + localDeck: { main_deck: [seat === 1 ? "Plains" : "Island"], sideboard: ["Negate"], commander: [] }, + matchConfig: { match_type: "Bo3" }, + binding, + }); + const launch1 = launch(1); + const launch2 = launch(2); + const privateHost = host as unknown as { + bo3State: Map; + launchDigests: Map>; + matchDecks: Map>; + matchLaunches: Map>; + guestSessions: Map void; close: () => void }>; + intergameCommands: { snapshot: () => DraftIntergameCommand[] }; + startSideboardTimer: (matchId: string) => void; + }; + privateHost.bo3State.set("bo3-1", { + seatA: 1, seatB: 2, submittedA: false, submittedB: false, + loserSeat: 1, gameNumber: 2, score: { p0_wins: 0, p1_wins: 1, draws: 0 }, + decks: [ + { seat: 1, main: [{ name: "Plains", count: 1 }], sideboard: [{ name: "Negate", count: 1 }] }, + { seat: 2, main: [{ name: "Island", count: 1 }], sideboard: [{ name: "Negate", count: 1 }] }, + ], + }); + privateHost.launchDigests.set("bo3-1", new Map([ + [1, draftIntergameDigest(launch1)], + [2, draftIntergameDigest(launch2)], + ])); + privateHost.matchDecks.set("bo3-1", new Map([ + [1, launch1.localDeck], + [2, launch2.localDeck], + ])); + privateHost.matchLaunches.set("bo3-1", new Map([[1, launch1], [2, launch2]])); + for (const seat of [1, 2]) { + privateHost.guestSessions.set(seat, { send: (message) => sent.get(seat)!.push(message), close: () => {} }); + } + + privateHost.startSideboardTimer("bo3-1"); + vi.advanceTimersByTime(60_000); + + for (const seat of [1, 2]) { + expect(sent.get(seat)).toContainEqual(expect.objectContaining({ + type: "draft_bo3_intergame_authorized", + command: expect.objectContaining({ + seat, + payload: { + type: "SubmitSideboard", + main: [{ name: seat === 1 ? "Plains" : "Island", count: 1 }], + sideboard: [{ name: "Negate", count: 1 }], + }, + }), + })); + } + expect(privateHost.intergameCommands.snapshot().every((command) => command.status === "Executing")).toBe(true); + host.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it("issues the play-first default through the same ledger when the choice timer expires", () => { + vi.useFakeTimers(); + try { + const host = new P2PDraftHost( + { id: "host" } as never, () => () => {}, + { type: "Set", data: { set_pool_json: "{}" } } as never, + "Traditional", 8, "Host", "Swiss", "Competitive", + ); + const launch = { + type: "HumanGuest" as const, + matchId: "bo3-1", + matchRoomCode: "room", + round: 1, + localSeat: 1, + opponentSeat: 2, + opponentName: "Opponent", + matchHostPeerId: "room", + localDeck: { main_deck: ["Plains"], sideboard: [], commander: [] }, + matchConfig: { match_type: "Bo3" as const }, + binding: { podId: "draft-1", matchId: "bo3-1", round: 1, sessionKey: "session", lease: "lease", nonce: "nonce", revision: 0, matchAuthoritySeat: 1 }, + }; + const sent: DraftP2PMessage[] = []; + const privateHost = host as unknown as { + bo3State: Map; + launchDigests: Map>; + matchLaunches: Map>; + guestSessions: Map void; close: () => void }>; + startPlayDrawTimer: (matchId: string) => void; + }; + privateHost.bo3State.set("bo3-1", { + seatA: 1, seatB: 2, submittedA: true, submittedB: true, + loserSeat: 1, gameNumber: 2, score: { p0_wins: 0, p1_wins: 1, draws: 0 }, decks: [], + }); + privateHost.launchDigests.set("bo3-1", new Map([[1, draftIntergameDigest(launch)]])); + privateHost.matchLaunches.set("bo3-1", new Map([[1, launch]])); + privateHost.guestSessions.set(1, { send: (message) => sent.push(message), close: () => {} }); + + privateHost.startPlayDrawTimer("bo3-1"); + vi.advanceTimersByTime(10_000); + + expect(sent).toContainEqual(expect.objectContaining({ + type: "draft_bo3_intergame_authorized", + command: expect.objectContaining({ payload: { type: "ChoosePlayDraw", playFirst: true } }), + })); + host.dispose(); + } finally { + vi.useRealTimers(); + } + }); }); describe("BO3-03: no timer in Casual", () => { - it.todo("does not start sideboard timer when podPolicy is Casual"); - it.todo("sends timerMs: 0 in sideboard prompt for Casual mode"); + it("publishes an untimed sideboard prompt without arming the production timer", () => { + const host = new P2PDraftHost( + { id: "host" } as never, () => () => {}, + { type: "Set", data: { set_pool_json: "{}" } } as never, + "Traditional", 8, "Host", "Swiss", "Casual", + ); + const events: unknown[] = []; + host.onEvent((event) => events.push(event)); + + host.handleMatchBetweenGames( + "bo3-1", 2, { p0_wins: 1, p1_wins: 0, draws: 0 }, 2, 0, 2, + ); + + expect(host.activeTimerContext).toBeNull(); + expect(events).toContainEqual(expect.objectContaining({ + type: "bo3SideboardPrompt", + timerMs: 0, + })); + host.dispose(); + }); }); // Security regression guard for PR #1454: the draft_match_result handler must @@ -70,7 +334,18 @@ describe("P2PDraftHost Bo3", () => { adapter.getViewForSeat = vi.fn(async () => view as DraftPlayerView); } - async function deliver( + const binding: DraftMatchBinding = { + podId: "draft-1", + matchId: "m-12", + round: 2, + sessionKey: "session-1", + lease: "lease-1", + nonce: "nonce-1", + revision: 0, + matchAuthoritySeat: 1, + }; + + async function deliverRaw( seat: number, matchId: string, winnerSeat: number | null, @@ -86,6 +361,20 @@ describe("P2PDraftHost Bo3", () => { }); } + async function deliverSettlement( + seat: number, + submittedBinding: DraftMatchBinding = binding, + ): Promise { + await ( + host as unknown as { + handleGuestMessage: (s: number, m: DraftP2PMessage) => Promise; + } + ).handleGuestMessage(seat, { + type: "draft_match_settlement", + settlement: { binding: submittedBinding, receiptId: "receipt-1", winnerSeat: 1 }, + }); + } + beforeEach(() => { sent.clear(); host = new P2PDraftHost( @@ -117,31 +406,43 @@ describe("P2PDraftHost Bo3", () => { guestSessions.set(1, fakeSession(1)); guestSessions.set(2, fakeSession(2)); guestSessions.set(5, fakeSession(5)); + (host as unknown as { matchBindings: Map }) + .matchBindings.set(binding.matchId, binding); }); - it("accepts a result reported by a seated participant", async () => { - await deliver(1, "m-12", 1); + it("accepts only the bound match-authority settlement and acks its receipt", async () => { + await deliverSettlement(1); expect(reportSpy).toHaveBeenCalledWith("m-12", 1); - expect(sent.get(1)).toEqual([]); + expect(sent.get(1)).toEqual([ + { type: "draft_match_settlement_ack", matchId: "m-12", receiptId: "receipt-1", revision: 0 }, + ]); + }); + + it("rejects the raw result shape", async () => { + await deliverRaw(1, "m-12", 1); + expect(reportSpy).not.toHaveBeenCalled(); + expect(sent.get(1)).toEqual([ + { type: "draft_error", reason: "Unbound match result" }, + ]); }); - it("rejects a result from a non-participant guest", async () => { - await deliver(5, "m-12", 1); + it("rejects a forged binding", async () => { + await deliverSettlement(1, { ...binding, nonce: "forged" }); expect(reportSpy).not.toHaveBeenCalled(); - expect(sent.get(5)).toEqual([ - { type: "draft_error", reason: "Not a participant in this match" }, + expect(sent.get(1)).toEqual([ + { type: "draft_error", reason: "Invalid match binding" }, ]); }); - it("rejects a result for a match not in the current round", async () => { + it("rejects a bound settlement when its round is no longer current", async () => { setHostView({ current_round: 2, pairings: [pairing("m-12", 1, 1, 2)], }); - await deliver(1, "m-12", 1); + await deliverSettlement(1); expect(reportSpy).not.toHaveBeenCalled(); expect(sent.get(1)).toEqual([ - { type: "draft_error", reason: "Unknown match" }, + { type: "draft_error", reason: "Unauthorized match settlement" }, ]); }); }); diff --git a/client/src/adapter/__tests__/ws-adapter.test.ts b/client/src/adapter/__tests__/ws-adapter.test.ts index 4d0d14ff9c..91c1ebfc1c 100644 --- a/client/src/adapter/__tests__/ws-adapter.test.ts +++ b/client/src/adapter/__tests__/ws-adapter.test.ts @@ -5,7 +5,7 @@ import { PROTOCOL_VERSION, WebSocketAdapter, } from "../ws-adapter"; -import { AdapterError } from "../types"; +import { AdapterError, supportsMatchConcede } from "../types"; import type { GameState } from "../types"; import type { PhaseSocketTransport } from "../../services/openPhaseSocket"; @@ -143,6 +143,14 @@ describe("WebSocketAdapter", () => { await initPromise; }); + it("sends the payload-free authenticated whole-match concession intent", () => { + expect(supportsMatchConcede(adapter)).toBe(true); + + adapter.sendMatchConcede(); + + expect(ws.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "ConcedeMatch" })); + }); + describe("native AI transport", () => { const nativeAiOptions = (socketFactory: () => PhaseSocketTransport) => ({ nativeAi: { @@ -253,7 +261,7 @@ describe("WebSocketAdapter", () => { "message", JSON.stringify({ type: "GameCreated", - data: { game_code: "ABCD", player_token: "tok" }, + data: { game_code: "ABCD", player_token: "tok", full_key: { game_code: "ABCD", generation: 1 } }, }), ); nativeSocket.dispatchSynthetic( @@ -313,7 +321,7 @@ describe("WebSocketAdapter", () => { "message", JSON.stringify({ type: "SessionAttached", - data: { game_code: "WXYZ", player_id: 0, player_token: "tok" }, + data: { game_code: "WXYZ", player_id: 0, player_token: "tok", full_key: { game_code: "WXYZ", generation: 1 } }, }), ); await attached; @@ -364,6 +372,39 @@ describe("WebSocketAdapter", () => { }); describe("native P2P pregame transport", () => { + it("rejects a native seat attachment without a Full session key", async () => { + const nativeAdapter = new WebSocketAdapter( + "native-engine", + "host", + { main_deck: [], sideboard: [] }, + undefined, + undefined, + undefined, + "Host", + { + nativePregame: { + kind: "host", + socketFactory: () => new MockWebSocket("native-engine") as unknown as PhaseSocketTransport, + playerCount: 2, + aiSeats: [], + }, + }, + ); + + const attached = nativeAdapter.initializePregame(); + const nativeSocket = await completeHandshake(nativeAdapter); + nativeSocket.dispatchSynthetic( + "message", + JSON.stringify({ + type: "SessionAttached", + data: { game_code: "NATIVE", player_id: 0, player_token: "host-token" }, + }), + ); + + await expect(attached).rejects.toMatchObject({ message: "Server omitted a valid Full session identity" }); + nativeAdapter.dispose(); + }); + it("waits for the server-issued seat attachment and slot confirmation", async () => { const nativeAdapter = new WebSocketAdapter( "native-engine", @@ -393,13 +434,14 @@ describe("WebSocketAdapter", () => { "message", JSON.stringify({ type: "SessionAttached", - data: { game_code: "NATIVE", player_id: 0, player_token: "host-token" }, + data: { game_code: "NATIVE", player_id: 0, player_token: "host-token", full_key: { game_code: "NATIVE", generation: 1 } }, }), ); await expect(attached).resolves.toEqual({ gameCode: "NATIVE", playerId: 0, playerToken: "host-token", + fullKey: { game_code: "NATIVE", generation: 1 }, }); const confirmed = nativeAdapter.sendSeatMutation({ type: "Start" }); @@ -429,6 +471,7 @@ describe("WebSocketAdapter", () => { gameCode: "NATIVE", playerId: 1, playerToken: "guest-token", + fullKey: { game_code: "NATIVE", generation: 1 }, }, }, ); @@ -438,7 +481,11 @@ describe("WebSocketAdapter", () => { expect(nativeSocket.send).toHaveBeenLastCalledWith( JSON.stringify({ type: "Reconnect", - data: { game_code: "NATIVE", player_token: "guest-token" }, + data: { + game_code: "NATIVE", + player_token: "guest-token", + full_key: { game_code: "NATIVE", generation: 1 }, + }, }), ); nativeSocket.dispatchSynthetic( @@ -452,6 +499,7 @@ describe("WebSocketAdapter", () => { gameCode: "NATIVE", playerId: 1, playerToken: "guest-token", + fullKey: { game_code: "NATIVE", generation: 1 }, }); }); @@ -679,6 +727,7 @@ describe("WebSocketAdapter", () => { state: createMockState(), your_player: 1, player_token: "player-token", + full_key: { game_code: "ABC123", generation: 1 }, }, }), ); @@ -701,6 +750,7 @@ describe("WebSocketAdapter", () => { data: { game_code: "ABC123", player_token: "player-token", + full_key: { game_code: "ABC123", generation: 1 }, }, }), ); diff --git a/client/src/adapter/draftPodGuestAdapter.ts b/client/src/adapter/draftPodGuestAdapter.ts index a5e28c2f65..24f2c923ca 100644 --- a/client/src/adapter/draftPodGuestAdapter.ts +++ b/client/src/adapter/draftPodGuestAdapter.ts @@ -12,7 +12,8 @@ import type { DraftPlayerView, SeatPublicView } from "./draft-adapter"; import { P2PDraftGuest, type DraftGuestEvent } from "./p2p-draft-guest"; -import type { DraftMatchLaunch, DraftPauseReason } from "../network/draftProtocol"; +import type { DraftMatchLaunch, DraftMatchSettlement, DraftPauseReason } from "../network/draftProtocol"; +import type { DraftIntergameCommand, DraftIntergameCommandAck } from "../services/intergameCommandLedger"; import { joinRoom, type JoinResult } from "../network/connection"; import { loadDraftGuestSession } from "../services/draftPersistence"; @@ -48,11 +49,13 @@ export type DraftPodGuestEvent = matchId: string; } | { type: "matchResult"; matchId: string; winnerSeat: number | null } + | { type: "matchSettlementAcknowledged"; matchId: string; receiptId: string; revision: number } | { type: "timerSync"; remainingMs: number } | { type: "matchStart"; launch: DraftMatchLaunch } | { type: "bo3SideboardPrompt"; matchId: string; gameNumber: number; score: { p0_wins: number; p1_wins: number; draws: number }; loserSeat: number | null; timerMs: number } | { type: "bo3ChoosePlayDraw"; matchId: string; gameNumber: number; score: { p0_wins: number; p1_wins: number; draws: number }; timerMs: number } | { type: "bo3GameStart"; matchId: string; gameNumber: number; firstPlayerSeat: number } + | { type: "bo3AuthorizedCommand"; command: DraftIntergameCommand; acknowledgement: DraftIntergameCommandAck } | { type: "bo3ScoreUpdate"; matchId: string; scoreA: number; scoreB: number } | { type: "kicked"; reason: string } | { type: "hostLeft"; reason: string } @@ -226,6 +229,14 @@ export class DraftPodGuestAdapter { winnerSeat: event.winnerSeat, }); break; + case "matchSettlementAcknowledged": + this.emit({ + type: "matchSettlementAcknowledged", + matchId: event.matchId, + receiptId: event.receiptId, + revision: event.revision, + }); + break; case "timerSync": this.emit({ type: "timerSync", remainingMs: event.remainingMs }); break; @@ -281,6 +292,9 @@ export class DraftPodGuestAdapter { firstPlayerSeat: event.firstPlayerSeat, }); break; + case "bo3AuthorizedCommand": + this.emit({ type: "bo3AuthorizedCommand", command: event.command, acknowledgement: event.acknowledgement }); + break; case "bo3ScoreUpdate": this.emit({ type: "bo3ScoreUpdate", @@ -330,19 +344,25 @@ export class DraftPodGuestAdapter { await this.guest.submitDeck(mainDeck); } - sendMatchResult(matchId: string, winnerSeat: number | null): void { - if (!this.guest) return; - this.guest.sendMatchResult(matchId, winnerSeat); + sendMatchSettlement(settlement: DraftMatchSettlement): void { + this.guest?.sendMatchSettlement(settlement); + } + + handleMatchBetweenGames( + matchId: string, + gameNumber: number, + score: { p0_wins: number; p1_wins: number; draws: number }, + loserSeat: number | null, + ): void { + this.guest?.sendBetweenGames(matchId, gameNumber, score, loserSeat); } - sendSideboardSubmit(matchId: string, mainDeck: string[], sideboard: Array<{ name: string; count: number }>): void { - if (!this.guest) return; - this.guest.sendSideboardSubmit(matchId, mainDeck, sideboard); + submitAuthorized(command: DraftIntergameCommand): void { + this.guest?.sendAuthorizedIntergameCommand(command); } - sendPlayDrawChoice(matchId: string, playFirst: boolean): void { - if (!this.guest) return; - this.guest.sendPlayDrawChoice(matchId, playFirst); + acknowledgeAuthorized(acknowledgement: DraftIntergameCommandAck, receiptId: string): void { + this.guest?.sendIntergameReceipt(acknowledgement, receiptId); } // ── Cleanup ──────────────────────────────────────────────────────── diff --git a/client/src/adapter/draftPodHostAdapter.ts b/client/src/adapter/draftPodHostAdapter.ts index 4ec9d2c786..5c3fce2787 100644 --- a/client/src/adapter/draftPodHostAdapter.ts +++ b/client/src/adapter/draftPodHostAdapter.ts @@ -15,9 +15,10 @@ import type { DraftPlayerView, PairingView, PodPolicy, PoolInput, SeatPublicView import type { MatchScore } from "./types"; import { P2PDraftHost, type DraftHostEvent } from "./p2p-draft-host"; import { hostRoom, type HostResult } from "../network/connection"; -import type { DraftMatchLaunch, DraftPauseReason } from "../network/draftProtocol"; +import type { DraftMatchLaunch, DraftMatchSettlement, DraftPauseReason } from "../network/draftProtocol"; import type { BrokerClient, RegisterHostRequest } from "../services/brokerClient"; import { loadDraftHostSession } from "../services/draftPersistence"; +import type { DraftIntergameCommand, DraftIntergameCommandAck } from "../services/intergameCommandLedger"; // ── Types ────────────────────────────────────────────────────────────── @@ -75,6 +76,7 @@ export type DraftPodHostEvent = | { type: "bo3SideboardPromptSent"; matchId: string } | { type: "bo3BothSideboardsSubmitted"; matchId: string } | { type: "bo3GameStarted"; matchId: string; gameNumber: number } + | { type: "bo3AuthorizedCommand"; command: DraftIntergameCommand; acknowledgement: DraftIntergameCommandAck } | { type: "error"; message: string }; type DraftPodHostEventListener = (event: DraftPodHostEvent) => void; @@ -380,6 +382,9 @@ export class DraftPodHostAdapter { firstPlayerSeat: event.firstPlayerSeat, }); break; + case "bo3AuthorizedCommand": + this.emit({ type: "bo3AuthorizedCommand", command: event.command, acknowledgement: event.acknowledgement }); + break; } } @@ -422,6 +427,11 @@ export class DraftPodHostAdapter { await this.host.overrideMatchResult(matchId, winnerSeat); } + async submitMatchSettlement(settlement: DraftMatchSettlement): Promise { + if (!this.host) throw new Error("Host not initialized"); + await this.host.submitHostMatchSettlement(settlement); + } + async replaceSeatWithBot(seat: number): Promise { if (!this.host) throw new Error("Host not initialized"); await this.host.replaceSeatWithBot(seat); @@ -441,19 +451,9 @@ export class DraftPodHostAdapter { this.host.handleMatchBetweenGames(matchId, gameNumber, score, loserSeat, seatA, seatB); } - handleSideboardSubmit( - seat: number, - matchId: string, - mainDeck: string[], - sideboard: Array<{ name: string; count: number }>, - ): void { - if (!this.host) throw new Error("Host not initialized"); - this.host.handleSideboardSubmit(seat, matchId, mainDeck, sideboard); - } - - handlePlayDrawChosen(seat: number, matchId: string, playFirst: boolean): void { + submitAuthorized(seat: number, command: DraftIntergameCommand): void { if (!this.host) throw new Error("Host not initialized"); - this.host.handlePlayDrawChosen(seat, matchId, playFirst); + this.host.submitAuthorized(seat, command); } // ── Host controls ────────────────────────────────────────────────── diff --git a/client/src/adapter/p2p-adapter.ts b/client/src/adapter/p2p-adapter.ts index c87d8cfcda..c3ce67ce78 100644 --- a/client/src/adapter/p2p-adapter.ts +++ b/client/src/adapter/p2p-adapter.ts @@ -39,6 +39,7 @@ import type { SeatView, } from "../multiplayer/seatTypes"; import type { BrokerClient } from "../services/brokerClient"; +import type { FullSessionKey } from "../services/multiplayerSession"; import { clearP2PHostSession, type NativeP2PServerSession, @@ -46,7 +47,22 @@ import { saveGame, saveP2PHostSession, } from "../services/gamePersistence"; -import { saveP2PSession } from "../services/p2pSession"; +import { + claimP2PHostLease, + createP2PSessionKey, + ownsP2PHostLease, + releaseP2PHostLease, + clearP2PSession, + saveP2PSession, + type P2PAuthorityStamp, + type P2PSessionKey, +} from "../services/p2pSession"; +import { + commitP2PTerminalResult, + isValidP2PTerminalResult, + p2pFinalStateCommitment, + type P2PTerminalResult, +} from "../services/p2pTerminalResult"; import { NativeEngineSocket } from "../services/nativeEngineSocket"; /** @@ -62,6 +78,8 @@ export type P2PAdapterEvent = | { type: "guestConnected" } | { type: "opponentDisconnected"; reason: string } | { type: "gameOver"; winner: PlayerId | null; reason: string } + | { type: "terminalResult"; result: P2PTerminalResult } + | { type: "terminalUnavailable"; message: string } | { type: "error"; message: string } /** * Pre-game setup failure on the host side. Distinct from the catch-all @@ -129,6 +147,12 @@ export interface NativeP2PHostOptions { expectedServerVersion?: string; } +/** Installed only by a pod-issued draft match binding. */ +export interface BoundP2PMatchConcede { + /** The authenticated game seat that chose to concede the whole match. */ + onConcede(concedingPlayer: PlayerId): void | Promise; +} + type NativeViewerUpdate = { snapshot: EngineSnapshot; events: GameEvent[]; @@ -146,7 +170,11 @@ class NativeP2PBridge { private readonly latestViews = new Map(); private readonly pendingViews = new Map>(); private readonly startWaiters: Array<(update: NativeViewerUpdate) => void> = []; + /** Preserve the server's revision order while asynchronous PeerJS frame + * encoding runs; a terminal commitment must follow its final state frame. */ + private revisionQueue: Promise = Promise.resolve(); private gameCode: string | null = null; + private fullKey: FullSessionKey | null = null; constructor( private readonly hostDeck: DeckListPayload["player"], @@ -155,13 +183,14 @@ class NativeP2PBridge { private readonly formatConfig: FormatConfig | undefined, private readonly matchConfig: MatchConfig | undefined, private readonly options: NativeP2PHostOptions, - private readonly onRevision: (views: Map) => void, + private readonly onRevision: (revision: number, views: Map) => Promise, private readonly resumeSession?: NativeP2PServerSession, ) {} async initializeHost(aiSeats: NativeAiSeat[]): Promise { if (this.resumeSession) { this.gameCode = this.resumeSession.gameCode; + this.fullKey = this.resumeSession.fullKey; const hostToken = this.resumeSession.playerTokens[0]; if (!hostToken) { throw new AdapterError("P2P_ERROR", "Native resume is missing the host token", false); @@ -202,6 +231,7 @@ class NativeP2PBridge { throw new AdapterError("P2P_ERROR", "Native host was assigned a non-host seat", false); } this.gameCode = attachment.gameCode; + this.fullKey = attachment.fullKey; return attachment; } @@ -316,6 +346,7 @@ class NativeP2PBridge { private async attachClient(client: WebSocketAdapter): Promise { client.onEvent((event) => { if (event.type !== "stateChanged" || event.serverRevision === undefined) return; + const revision = event.serverRevision; const playerId = client.playerId; if (playerId === null) return; const update: NativeViewerUpdate = { @@ -324,12 +355,16 @@ class NativeP2PBridge { logEntries: event.logEntries, }; this.latestViews.set(playerId, update); - const views = this.pendingViews.get(event.serverRevision) ?? new Map(); + const views = this.pendingViews.get(revision) ?? new Map(); views.set(playerId, update); - this.pendingViews.set(event.serverRevision, views); + this.pendingViews.set(revision, views); if (views.size !== this.clients.size) return; - this.pendingViews.delete(event.serverRevision); - this.onRevision(views); + this.pendingViews.delete(revision); + this.revisionQueue = this.revisionQueue + .then(() => this.onRevision(revision, views)) + .catch((error) => { + console.error("[NativeP2PBridge] revision fan-out failed:", error); + }); const hostUpdate = views.get(0); if (hostUpdate) { for (const resolve of this.startWaiters.splice(0)) resolve(hostUpdate); @@ -342,9 +377,10 @@ class NativeP2PBridge { } persistence(): NativeP2PServerSession | null { - if (!this.gameCode) return null; + if (!this.gameCode || !this.fullKey) return null; return { gameCode: this.gameCode, + fullKey: this.fullKey, playerTokens: Object.fromEntries(this.playerTokens), }; } @@ -353,7 +389,7 @@ class NativeP2PBridge { playerId: PlayerId, playerToken: string, ): Promise { - if (!this.gameCode) { + if (!this.gameCode || !this.fullKey) { throw new AdapterError("P2P_ERROR", "Native game code is unavailable for reconnect", false); } const client = new WebSocketAdapter( @@ -372,6 +408,7 @@ class NativeP2PBridge { gameCode: this.gameCode, playerId, playerToken, + fullKey: this.fullKey, }, }, ); @@ -411,7 +448,7 @@ function isDeckListPlayerShape(x: unknown): x is DeckListPayload["player"] { * the dropped player reconnecting (see plan §6 DisconnectChoiceDialog * semantics). Blocks `submitAction`. */ -type GameRunState = "running" | "paused-disconnect" | "paused-manual"; +type GameRunState = "running" | "paused-disconnect" | "paused-manual" | "terminal"; /** Default grace window for guest auto-reconnect, in milliseconds. */ const DEFAULT_GRACE_PERIOD_MS = 30_000; @@ -574,6 +611,14 @@ export class P2PHostAdapter implements EngineAdapter { */ private eliminatedSeats = new Set(); private gameRunState: GameRunState = "running"; + /** Monotonic authority revision for WASM hosts; native hosts replace this + * with the local phase-server's revision before fan-out. */ + private authoritativeRevision = 0; + /** First committed terminal statement fences every subsequent action and + * reconnect. Its id is immutable for this adapter incarnation. */ + private terminalResult: P2PTerminalResult | null = null; + readonly supportsMatchConcede: true | undefined; + private matchConcedeSent = false; private gameStarted = false; private guestDeckResolvers: Array<() => void> = []; @@ -598,6 +643,9 @@ export class P2PHostAdapter implements EngineAdapter { private readonly gameId: string | null; /** Bare 5-char room code without PEER_ID_PREFIX — persisted in the session record. */ private readonly roomCode: string | null; + /** Stable identity is retained on resume; the incarnation fences old hosts. */ + private readonly sessionKey: P2PSessionKey; + private readonly authority: P2PAuthorityStamp; /** True when the adapter was constructed from a persisted session (resume flow). */ private readonly isResume: boolean; /** @@ -658,7 +706,9 @@ export class P2PHostAdapter implements EngineAdapter { resumeData?: { state?: PersistedGameState; session: PersistedP2PHostSession }; }, native?: NativeP2PHostOptions, + private readonly boundMatchConcede?: BoundP2PMatchConcede, ) { + this.supportsMatchConcede = boundMatchConcede ? true : undefined; if (playerCount < 2 || playerCount > 6) { throw new AdapterError( "P2P_PLAYER_COUNT", @@ -677,6 +727,8 @@ export class P2PHostAdapter implements EngineAdapter { this.pregameSeatView = seatStateToView(this.pregameSeatState); this.gameId = persistence?.gameId ?? null; this.roomCode = persistence?.roomCode ?? null; + this.sessionKey = persistence?.resumeData?.session.sessionKey ?? createP2PSessionKey(); + this.authority = claimP2PHostLease(this.sessionKey); this.hostDisplayName = persistence?.hostDisplayName ?? null; this.isResume = persistence?.resumeData !== undefined; @@ -708,7 +760,7 @@ export class P2PHostAdapter implements EngineAdapter { formatConfig, matchConfig, native, - (views) => this.handleNativeRevision(views), + (revision, views) => this.handleNativeRevision(revision, views), nativeResume, ); } @@ -802,6 +854,7 @@ export class P2PHostAdapter implements EngineAdapter { return { gameId: this.gameId, roomCode: this.roomCode, + sessionKey: this.sessionKey, brokerGameCode: this.brokerGameCode, useBroker: this.broker !== undefined, playerTokens, @@ -871,6 +924,7 @@ export class P2PHostAdapter implements EngineAdapter { * snapshot, not a crash. */ private saveSession(): void { + if (!this.ownsAuthority()) return; if (!this.gameId) return; const snapshot = this.buildPersistedSession(); if (!snapshot) return; @@ -879,6 +933,7 @@ export class P2PHostAdapter implements EngineAdapter { /** Persist the host authority as the engine's opaque trusted envelope. */ private persistAuthoritativeState(): void { + if (!this.ownsAuthority()) return; if (this.nativeBridge) return; if (!this.gameId) return; void this.wasm @@ -889,6 +944,27 @@ export class P2PHostAdapter implements EngineAdapter { }); } + /** + * The persisted lease is checked at every authority boundary. This is a + * fence, not advisory bookkeeping: a resumed host with the same session key + * has already superseded this adapter and its delayed work must become inert. + */ + private ownsAuthority(): boolean { + const owns = ownsP2PHostLease(this.authority); + if (!owns) traceAdapter("Host", "lease-fenced", { sessionKey: this.sessionKey }); + return owns; + } + + private send(session: PeerSession, message: P2PMessage): Promise { + if (!this.ownsAuthority()) return Promise.resolve(); + return session.send({ ...message, authority: this.authority }); + } + + private rejectSuperseded(session: PeerSession): void { + void session.send({ type: "reconnect_rejected", reason: "Host session superseded" }); + session.close("Host session superseded"); + } + /** * Resolves the guest-deck gate in `initializeGame` so the engine starts * with whatever guests have connected so far. For 2p rooms this is @@ -939,8 +1015,9 @@ export class P2PHostAdapter implements EngineAdapter { } private broadcastSeatSnapshot(): void { + if (!this.ownsAuthority()) return; for (const session of this.guestSessions.values()) { - session.send({ type: "seat_snapshot", view: this.pregameSeatView }); + void this.send(session, { type: "seat_snapshot", view: this.pregameSeatView }); } this.emit({ type: "playerSlotsUpdated", slots: this.getPlayerSlots() }); } @@ -961,6 +1038,7 @@ export class P2PHostAdapter implements EngineAdapter { } private syncLobbyMetadata(consumedReservationTokens: string[] = []): void { + if (!this.ownsAuthority()) return; const currentPlayers = occupiedSeatCount(this.pregameSeatState); const maxPlayers = this.pregameSeatState.seats.length; this.emit({ type: "lobbyProgress", joined: currentPlayers, total: maxPlayers }); @@ -976,6 +1054,7 @@ export class P2PHostAdapter implements EngineAdapter { async applySeatMutation(mutation: SeatMutation): Promise { await this.enqueuePregameOp(async () => { + if (!this.ownsAuthority()) return; if (this.gameStarted) { throw new AdapterError("P2P_ERROR", "Pregame seats can no longer be edited", false); } @@ -993,7 +1072,7 @@ export class P2PHostAdapter implements EngineAdapter { if (seatToken !== token) continue; const session = this.guestSessions.get(pid); if (session) { - session.send({ type: "kick", reason: "Removed from the room by the host" }); + void this.send(session, { type: "kick", reason: "Removed from the room by the host" }); try { session.close("Removed by host"); } catch { @@ -1038,7 +1117,7 @@ export class P2PHostAdapter implements EngineAdapter { await this.refreshPregameSeatView(); this.saveSession(); for (const session of this.guestSessions.values()) { - session.send({ type: "seat_mutate", mutation }); + void this.send(session, { type: "seat_mutate", mutation }); } this.broadcastSeatSnapshot(); this.syncLobbyMetadata(); @@ -1050,10 +1129,12 @@ export class P2PHostAdapter implements EngineAdapter { } private async runAiLoop(): Promise { + if (!this.ownsAuthority()) return; if (this.nativeBridge) return; if (!this.gameStarted) return; for (;;) { + if (!this.ownsAuthority()) return; const state = await this.wasm.getState(); if (!state || typeof state !== "object" || !("waiting_for" in state)) { return; @@ -1143,6 +1224,11 @@ export class P2PHostAdapter implements EngineAdapter { // registration + adapter construction held this call off for hundreds // of ms while `wasm.initialize()` was cold-loading. this.hostConnectionUnsub = this.onGuestConnected((conn) => { + if (!this.ownsAuthority()) { + const session = createPeerSession(conn, {}); + this.rejectSuperseded(session); + return; + } traceAdapter("Host", "handle-connection-event", { connOpen: conn.open }); this.handleNewConnection(conn); }); @@ -1204,6 +1290,11 @@ export class P2PHostAdapter implements EngineAdapter { } private handleNewConnection(conn: DataConnection): void { + if (!this.ownsAuthority()) { + const session = createPeerSession(conn, {}); + this.rejectSuperseded(session); + return; + } traceAdapter("Host", "handle-new-connection", { connOpen: conn.open }); // Reconnect path: the first message determines whether this is a fresh // join or a reconnect. We attach a one-shot pre-handler to peek at the @@ -1230,7 +1321,7 @@ export class P2PHostAdapter implements EngineAdapter { if (msg.type === "reconnect") { traceAdapter("Host", "first-message", { type: msg.type }); - this.handleReconnect(session, msg.playerToken); + this.handleReconnect(session, msg.playerToken, msg.sessionKey); } else if (msg.type === "guest_deck") { traceAdapter("Host", "first-message", { type: msg.type }); void this.handleNewGuest( @@ -1242,13 +1333,13 @@ export class P2PHostAdapter implements EngineAdapter { traceAdapter("Host", "new-guest-error", { error: err instanceof Error ? err.message : String(err), }); - session.send({ type: "kick", reason: "Host failed to add player" }); + void this.send(session, { type: "kick", reason: "Host failed to add player" }); session.close("Host failed to add player"); }); } else { traceAdapter("Host", "first-message", { type: msg.type }); // Unexpected first message — reject. - session.send({ + void this.send(session, { type: "reconnect_rejected", reason: "Expected guest_deck or reconnect as first message", }); @@ -1264,16 +1355,24 @@ export class P2PHostAdapter implements EngineAdapter { reservationToken?: string, ): Promise { await this.enqueuePregameOp(async () => { + if (!this.ownsAuthority()) { + this.rejectSuperseded(session); + return; + } await this.pregameReady; + if (!this.ownsAuthority()) { + this.rejectSuperseded(session); + return; + } if (this.closedPregameSessions.has(session)) return; if (this.gameStarted) { - session.send({ type: "kick", reason: "Game already in progress" }); + void this.send(session, { type: "kick", reason: "Game already in progress" }); session.close("Game in progress"); return; } const pid = this.firstWaitingSeat(); if (pid === null) { - session.send({ type: "kick", reason: "Lobby full" }); + void this.send(session, { type: "kick", reason: "Lobby full" }); session.close("Lobby full"); return; } @@ -1308,6 +1407,10 @@ export class P2PHostAdapter implements EngineAdapter { this.nativeBridge = null; } } + if (!this.ownsAuthority()) { + this.rejectSuperseded(session); + return; + } const token = crypto.randomUUID(); this.playerTokens.set(pid, token); @@ -1339,6 +1442,7 @@ export class P2PHostAdapter implements EngineAdapter { deck: DeckListPayload["player"], ): Promise { await this.enqueuePregameOp(async () => { + if (!this.ownsAuthority()) return; if (this.gameStarted) return; if (this.pregameSeatState.seats[pid]?.type !== "JoinedHuman") return; @@ -1357,13 +1461,14 @@ export class P2PHostAdapter implements EngineAdapter { selected_format: this.formatConfig!.format, }) as { selected_format_compatible?: boolean | null; selected_format_reasons: string[] }; + if (!this.ownsAuthority()) return; if (this.gameStarted) return; if (result.selected_format_compatible === false) { const reason = result.selected_format_reasons[0] ?? `Deck is not legal in ${this.formatConfig!.format}.`; const session = this.guestSessions.get(pid); if (session) { - session.send({ type: "kick", reason: `Deck rejected: ${reason}`, format: this.formatConfig!.format }); + void this.send(session, { type: "kick", reason: `Deck rejected: ${reason}`, format: this.formatConfig!.format }); session.close("Deck validation failed"); } await this.releaseNativePregameSeat(pid, "deck rejection"); @@ -1417,6 +1522,9 @@ export class P2PHostAdapter implements EngineAdapter { } private async startPregameGameInner(): Promise { + if (!this.ownsAuthority()) { + throw new AdapterError("P2P_ERROR", "Host session superseded", true); + } if (this.gameStarted) { return { events: [] }; } @@ -1488,6 +1596,7 @@ export class P2PHostAdapter implements EngineAdapter { this.matchConfig, undefined, ); + if (!this.ownsAuthority()) return result; this.gameStarted = true; this.pregameSeatState.gameStarted = true; await this.refreshPregameSeatView(); @@ -1502,14 +1611,16 @@ export class P2PHostAdapter implements EngineAdapter { }); } + const revision = ++this.authoritativeRevision; for (const [pid, session] of this.guestSessions) { const token = this.playerTokens.get(pid)!; const snapshot = await this.wasm.getViewerSnapshot(pid); - session.send({ + void this.send(session, { type: "game_setup", wireProtocolVersion: WIRE_PROTOCOL_VERSION, assignedPlayerId: pid, playerToken: token, + revision, state: snapshot.state, events: result.events, playerNames: allNames, @@ -1526,6 +1637,9 @@ export class P2PHostAdapter implements EngineAdapter { // caller — gameStore — derived it from `getPlayerId()`). The host is // the trust boundary for its own actions; the engine's guard still // verifies the actor against `authorized_submitter(state)`. + if (!this.ownsAuthority()) { + throw new AdapterError("P2P_ERROR", "Host session superseded", true); + } if (this.gameRunState !== "running") { throw new AdapterError( "P2P_PAUSED", @@ -1546,6 +1660,9 @@ export class P2PHostAdapter implements EngineAdapter { submission: InteractionSubmission, actor: PlayerId, ): Promise { + if (!this.ownsAuthority()) { + throw new AdapterError("P2P_ERROR", "Host session superseded", true); + } if (this.gameRunState !== "running") { throw new AdapterError( "P2P_PAUSED", @@ -1563,6 +1680,9 @@ export class P2PHostAdapter implements EngineAdapter { } async previewManaPayment(action: GameAction, actor: PlayerId): Promise { + if (!this.ownsAuthority()) { + throw new AdapterError("P2P_ERROR", "Host session superseded", true); + } if (this.gameRunState !== "running") { throw new AdapterError( "P2P_PAUSED", @@ -1575,6 +1695,9 @@ export class P2PHostAdapter implements EngineAdapter { } async exportPersistenceState(): Promise { + if (!this.ownsAuthority()) { + throw new AdapterError("P2P_ERROR", "Host session superseded", true); + } if (this.nativeBridge) { throw new AdapterError("P2P_ERROR", "Native P2P persistence is managed by phase-server", false); } @@ -1582,38 +1705,49 @@ export class P2PHostAdapter implements EngineAdapter { } /** Releases a complete native-server revision only after every local seat - * socket has supplied its own filtered view. The PeerJS wire protocol stays - * unchanged; the revision is strictly a host-local ordering key. */ - private handleNativeRevision(views: Map): void { + * socket has supplied its own filtered view. The native server is the + * authority for this revision; PeerJS carries it through to terminal + * correlation rather than inventing another clock. */ + private async handleNativeRevision( + revision: number, + views: Map, + ): Promise { + if (!this.ownsAuthority()) return; const hostUpdate = views.get(0); if (!hostUpdate) return; + if (revision < this.authoritativeRevision) return; + this.authoritativeRevision = revision; const allNames = this.playerNamesForSeats(); + const sends: Array> = []; for (const [pid, session] of this.guestSessions) { const update = views.get(pid); if (!update || this.disconnectedSeats.has(pid)) continue; if (this.nativeInitialSetupPending) { const token = this.playerTokens.get(pid); if (!token) continue; - void session.send({ + sends.push(this.send(session, { type: "game_setup", wireProtocolVersion: WIRE_PROTOCOL_VERSION, assignedPlayerId: pid, playerToken: token, + revision, state: update.snapshot.state, events: update.events, playerNames: allNames, ...legalActionsToWire(update.snapshot.legalResult), - }); + })); } else { - void session.send({ + sends.push(this.send(session, { type: "state_update", + revision, state: update.snapshot.state, events: update.events, logEntries: update.logEntries, ...legalActionsToWire(update.snapshot.legalResult), - }); + })); } } + await Promise.all(sends); this.nativeInitialSetupPending = false; this.emit({ type: "stateChanged", @@ -1621,6 +1755,72 @@ export class P2PHostAdapter implements EngineAdapter { events: hostUpdate.events, logEntries: hostUpdate.logEntries, }); + await this.commitTerminalIfComplete(hostUpdate.snapshot, revision); + } + + /** + * Final state first, terminal statement second. The ordered transport plus + * the state commitment lets a guest reject a plausible-looking terminal + * result that belongs to another final position or host incarnation. + */ + private async commitTerminalIfComplete( + snapshot: EngineSnapshot, + revision: number, + reason: string = "Game complete", + ): Promise { + const { waiting_for: waitingFor } = snapshot.state; + if (waitingFor.type !== "GameOver" || this.terminalResult) return; + const winner = waitingFor.data.winner; + const display = { winner, reason }; + const createResult = async ( + recipient: PlayerId, + terminalState: GameState, + ): Promise => ({ + key: this.sessionKey, + lease: this.authority, + recipient, + revision, + terminalId: crypto.randomUUID(), + finalStateCommitment: await p2pFinalStateCommitment(terminalState), + display, + }); + const result = await createResult(0, snapshot.state); + if (!(await commitP2PTerminalResult(result))) { + this.emit({ type: "terminalUnavailable", message: "Failed to retain P2P terminal result" }); + return; + } + this.terminalResult = result; + this.gameRunState = "terminal"; + await Promise.all([...this.guestSessions].map(async ([playerId, session]) => { + const viewerSnapshot = this.nativeBridge + ? this.nativeBridge.viewerSnapshot(playerId) + : await this.wasm.getViewerSnapshot(playerId); + const recipientResult = await createResult(playerId, viewerSnapshot.state); + await this.send(session, { type: "terminal_result", result: recipientResult }); + })); + this.emit({ type: "terminalResult", result }); + } + + /** + * A terminal statement is recipient-bound because its commitment covers the + * recipient's filtered final state. Reconnects therefore need a newly + * committed statement rather than replaying the host's retained result. + */ + private async terminalResultForRecipient( + recipient: PlayerId, + terminalState: GameState, + ): Promise { + const terminal = this.terminalResult; + if (terminal === null) throw new Error("No terminal result to deliver"); + return { + key: this.sessionKey, + lease: this.authority, + recipient, + revision: this.authoritativeRevision, + terminalId: crypto.randomUUID(), + finalStateCommitment: await p2pFinalStateCommitment(terminalState), + display: terminal.display, + }; } /** @@ -1631,14 +1831,21 @@ export class P2PHostAdapter implements EngineAdapter { * actions from the engine-side viewer gate (`legal_actions_for_viewer`). * Skips disconnected seats (their state is delivered via `reconnect_ack`). */ - private async broadcastStateUpdate(events: GameEvent[], logEntries?: GameLogEntry[]): Promise { + private async broadcastStateUpdate( + events: GameEvent[], + logEntries?: GameLogEntry[], + terminalReason?: string, + ): Promise { + if (!this.ownsAuthority()) return; if (this.nativeBridge) return; + const revision = ++this.authoritativeRevision; const sends: Array> = []; for (const [pid, session] of this.guestSessions) { if (this.disconnectedSeats.has(pid)) continue; const snapshot = await this.wasm.getViewerSnapshot(pid); - sends.push(session.send({ + sends.push(this.send(session, { type: "state_update", + revision, state: snapshot.state, events, logEntries, @@ -1646,6 +1853,7 @@ export class P2PHostAdapter implements EngineAdapter { })); } await Promise.all(sends); + await this.commitTerminalIfComplete(await this.wasm.getSnapshot(), revision, terminalReason); } async getState(): Promise { @@ -1685,12 +1893,34 @@ export class P2PHostAdapter implements EngineAdapter { } async sendConcede(): Promise { + if (!this.ownsAuthority()) return; await this.concedePlayer(0, "Host conceded", "conceded"); for (const [, s] of this.guestSessions) { - s.send({ type: "player_conceded", playerId: 0, reason: "Host conceded" }); + void this.send(s, { type: "player_conceded", playerId: 0, reason: "Host conceded" }); } } + /** + * A draft match installs this only with its pod-issued capability. The + * adapter deliberately does not synthesize a result from a room code. + */ + sendMatchConcede(): void { + this.requestBoundMatchConcede(0); + } + + /** + * The sole match-concession sink. Both the local host control and a guest's + * protected wire request pass through this authority-bound route. + */ + private requestBoundMatchConcede(concedingPlayer: PlayerId): void { + if (!this.boundMatchConcede || this.matchConcedeSent || !this.ownsAuthority()) return; + if (!this.gameStarted || this.gameRunState !== "running") return; + this.matchConcedeSent = true; + void Promise.resolve(this.boundMatchConcede.onConcede(concedingPlayer)).catch(() => { + this.matchConcedeSent = false; + }); + } + /** * Release all transport + engine resources. PRESERVES the persisted * resume record so a subsequent reload can pick up the game. Called @@ -1723,6 +1953,7 @@ export class P2PHostAdapter implements EngineAdapter { // stored local tokens. Explicit termination below sends AbandonGame. this.nativeBridge?.dispose(); this.wasm.dispose(); + releaseP2PHostLease(this.authority); // Close the broker only when the adapter owns it. When the multiplayer // store owns the broker (externally managed), it survives adapter disposal // so the lobby entry stays alive across page navigations. @@ -1743,6 +1974,10 @@ export class P2PHostAdapter implements EngineAdapter { * persistence preserved and go through `dispose()`. */ async terminateGame(): Promise { + if (!this.ownsAuthority()) { + this.dispose(); + return; + } // Notify every live guest session BEFORE dispose tears the sessions down. // Without this, guests interpret the ensuing DataConnection close as a // transient network drop and burn through the full reconnect backoff @@ -1759,7 +1994,7 @@ export class P2PHostAdapter implements EngineAdapter { // received the farewell (or the channel was already gone). await Promise.all( [...this.guestSessions.values()].map((s) => - s.send({ type: "host_left", reason: "Host left the game" }), + this.send(s, { type: "host_left", reason: "Host left the game" }), ), ); await this.nativeBridge?.abandon().catch(() => { @@ -1775,13 +2010,28 @@ export class P2PHostAdapter implements EngineAdapter { pid: PlayerId, msg: P2PMessage, ): Promise { + const session = this.guestSessions.get(pid); + if (!this.ownsAuthority()) { + if (session) this.rejectSuperseded(session); + return; + } + if ( + msg.authority + && (msg.authority.sessionKey !== this.authority.sessionKey + || msg.authority.hostIncarnation !== this.authority.hostIncarnation) + ) { + if (session) { + void this.send(session, { type: "action_rejected", reason: "Stale host incarnation" }); + } + return; + } switch (msg.type) { case "action": { // Verify sender identity to prevent guest 2 spoofing as guest 3. if (msg.senderPlayerId !== pid) { const session = this.guestSessions.get(pid); if (session) { - session.send({ + void this.send(session, { type: "action_rejected", reason: `senderPlayerId mismatch (declared ${msg.senderPlayerId}, session owns ${pid})`, }); @@ -1797,7 +2047,7 @@ export class P2PHostAdapter implements EngineAdapter { if (this.eliminatedSeats.has(pid)) { const session = this.guestSessions.get(pid); if (session) { - session.send({ + void this.send(session, { type: "action_rejected", reason: "Player has conceded and can no longer act", }); @@ -1807,7 +2057,7 @@ export class P2PHostAdapter implements EngineAdapter { if (this.gameRunState !== "running") { const session = this.guestSessions.get(pid); if (session) { - session.send({ + void this.send(session, { type: "action_rejected", reason: `Game ${this.gameRunState}`, }); @@ -1842,18 +2092,18 @@ export class P2PHostAdapter implements EngineAdapter { } catch (err) { const reason = err instanceof Error ? err.message : String(err); const session = this.guestSessions.get(pid); - if (session) session.send({ type: "action_rejected", reason }); + if (session) void this.send(session, { type: "action_rejected", reason }); } break; } case "interaction": { const session = this.guestSessions.get(pid); if (!session || msg.senderPlayerId !== pid) { - if (session) session.send({ type: "action_rejected", reason: "senderPlayerId mismatch" }); + if (session) void this.send(session, { type: "action_rejected", reason: "senderPlayerId mismatch" }); return; } if (this.eliminatedSeats.has(pid) || this.gameRunState !== "running") { - session.send({ + void this.send(session, { type: "action_rejected", reason: this.eliminatedSeats.has(pid) ? "Player has conceded and can no longer act" @@ -1877,7 +2127,7 @@ export class P2PHostAdapter implements EngineAdapter { }); } } catch (err) { - session.send({ + void this.send(session, { type: "action_rejected", reason: err instanceof Error ? err.message : String(err), }); @@ -1888,7 +2138,7 @@ export class P2PHostAdapter implements EngineAdapter { const session = this.guestSessions.get(pid); if (!session) return; if (this.eliminatedSeats.has(pid)) { - session.send({ + void this.send(session, { type: "mana_payment_preview_rejected", requestId: msg.requestId, reason: "Player has conceded and can no longer act", @@ -1896,7 +2146,7 @@ export class P2PHostAdapter implements EngineAdapter { return; } if (this.gameRunState !== "running") { - session.send({ + void this.send(session, { type: "mana_payment_preview_rejected", requestId: msg.requestId, reason: `Game ${this.gameRunState}`, @@ -1907,10 +2157,10 @@ export class P2PHostAdapter implements EngineAdapter { const sourceIds = this.nativeBridge ? await this.nativeBridge.previewManaPayment(msg.action, pid) : await this.wasm.previewManaPayment(msg.action, pid); - session.send({ type: "mana_payment_preview", requestId: msg.requestId, sourceIds }); + void this.send(session, { type: "mana_payment_preview", requestId: msg.requestId, sourceIds }); } catch (err) { const reason = err instanceof Error ? err.message : String(err); - session.send({ + void this.send(session, { type: "mana_payment_preview_rejected", requestId: msg.requestId, reason, @@ -1926,7 +2176,7 @@ export class P2PHostAdapter implements EngineAdapter { // "kicked") so their log entries read correctly. for (const [otherPid, s] of this.guestSessions) { if (otherPid === pid) continue; - s.send({ + void this.send(s, { type: "player_conceded", playerId: pid, reason: "Player conceded", @@ -1934,12 +2184,26 @@ export class P2PHostAdapter implements EngineAdapter { } break; } + case "match_concede": { + if (!this.boundMatchConcede) { + if (session) { + void this.send(session, { + type: "action_rejected", + reason: "Whole-match concession is unavailable for this game", + }); + } + return; + } + this.requestBoundMatchConcede(pid); + break; + } default: break; } } private handleGuestDisconnect(pid: PlayerId): void { + if (!this.ownsAuthority()) return; if (!this.guestSessions.has(pid)) return; if (this.disconnectedSeats.has(pid)) return; @@ -1947,8 +2211,10 @@ export class P2PHostAdapter implements EngineAdapter { if (!this.gameStarted) { void this.enqueuePregameOp(async () => { + if (!this.ownsAuthority()) return; if (this.gameStarted) return; await this.releaseNativePregameSeat(pid, "disconnect"); + if (!this.ownsAuthority()) return; // Pre-game disconnect: free the seat back to the lobby. Drop the token // (no reconnect path before game start). The seat number is reused via // `nextSeat` rewind so the next joiner takes the same slot. @@ -1969,6 +2235,14 @@ export class P2PHostAdapter implements EngineAdapter { return; } + if (this.gameRunState === "terminal") { + this.disconnectedSeats.set(pid, { + disconnectedAt: Date.now(), + timer: null, + }); + return; + } + // Mid-game disconnect: hold the seat open indefinitely. We do NOT // auto-concede a dropped player — no grace timer is armed. The game stays // `paused-disconnect`, which auto-resumes the moment the player reconnects @@ -1985,8 +2259,8 @@ export class P2PHostAdapter implements EngineAdapter { // Notify remaining guests. for (const [otherPid, session] of this.guestSessions) { if (otherPid === pid) continue; - session.send({ type: "player_disconnected", playerId: pid }); - session.send({ type: "game_paused", reason: "Player disconnected" }); + void this.send(session, { type: "player_disconnected", playerId: pid }); + void this.send(session, { type: "game_paused", reason: "Player disconnected" }); } this.emit({ @@ -1997,9 +2271,22 @@ export class P2PHostAdapter implements EngineAdapter { this.emit({ type: "gamePaused", reason: "Player disconnected" }); } - private handleReconnect(session: PeerSession, playerToken: string): void { + private handleReconnect( + session: PeerSession, + playerToken: string, + sessionKey?: P2PSessionKey, + ): void { + if (!this.ownsAuthority()) { + this.rejectSuperseded(session); + return; + } + if (sessionKey !== undefined && sessionKey !== this.sessionKey) { + void this.send(session, { type: "reconnect_rejected", reason: "Wrong P2P session" }); + session.close("Wrong P2P session"); + return; + } if (this.kickedTokens.has(playerToken)) { - session.send({ type: "reconnect_rejected", reason: "Player kicked" }); + void this.send(session, { type: "reconnect_rejected", reason: "Player kicked" }); session.close("Kicked"); return; } @@ -2011,12 +2298,12 @@ export class P2PHostAdapter implements EngineAdapter { } } if (pid === null) { - session.send({ type: "reconnect_rejected", reason: "Unknown token" }); + void this.send(session, { type: "reconnect_rejected", reason: "Unknown token" }); session.close("Unknown token"); return; } if (!this.disconnectedSeats.has(pid)) { - session.send({ + void this.send(session, { type: "reconnect_rejected", reason: "No grace window active for this seat", }); @@ -2034,33 +2321,36 @@ export class P2PHostAdapter implements EngineAdapter { // Send fresh state to the reconnecting guest. void (async () => { + let state: GameState; + let legalResult: LegalActionsResult; if (this.nativeBridge) { const snapshot = this.nativeBridge.viewerSnapshot(pid as PlayerId); - session.send({ - type: "reconnect_ack", - wireProtocolVersion: WIRE_PROTOCOL_VERSION, - assignedPlayerId: pid as PlayerId, - state: snapshot.state, - playerNames: this.playerNamesForSeats(), - ...legalActionsToWire(snapshot.legalResult), - }); + state = snapshot.state; + legalResult = snapshot.legalResult; } else { const snapshot = await this.wasm.getViewerSnapshot(pid as PlayerId); - session.send({ - type: "reconnect_ack", - wireProtocolVersion: WIRE_PROTOCOL_VERSION, - assignedPlayerId: pid as PlayerId, - state: snapshot.state, - playerNames: this.playerNamesForSeats(), - ...legalActionsToWire(snapshot), - }); + state = snapshot.state; + legalResult = snapshot; + } + await this.send(session, { + type: "reconnect_ack", + wireProtocolVersion: WIRE_PROTOCOL_VERSION, + assignedPlayerId: pid as PlayerId, + revision: this.authoritativeRevision, + state, + playerNames: this.playerNamesForSeats(), + ...legalActionsToWire(legalResult), + }); + if (this.terminalResult !== null) { + const result = await this.terminalResultForRecipient(pid as PlayerId, state); + await this.send(session, { type: "terminal_result", result }); } })(); // Notify other guests. for (const [otherPid, otherSession] of this.guestSessions) { if (otherPid === pid) continue; - otherSession.send({ type: "player_reconnected", playerId: pid }); + void this.send(otherSession, { type: "player_reconnected", playerId: pid }); } this.emit({ type: "playerReconnected", playerId: pid }); @@ -2068,7 +2358,7 @@ export class P2PHostAdapter implements EngineAdapter { if (this.disconnectedSeats.size === 0 && this.gameRunState === "paused-disconnect") { this.gameRunState = "running"; for (const [, s] of this.guestSessions) { - s.send({ type: "game_resumed" }); + void this.send(s, { type: "game_resumed" }); } this.emit({ type: "gameResumed" }); } @@ -2086,6 +2376,7 @@ export class P2PHostAdapter implements EngineAdapter { reason: string, origin: "kick" | "conceded", ): Promise { + if (!this.ownsAuthority()) return; // Cancel any active grace timer for this seat. `timer` may be null if the // host already called `holdForReconnect`. const grace = this.disconnectedSeats.get(pid); @@ -2114,7 +2405,7 @@ export class P2PHostAdapter implements EngineAdapter { const result = this.nativeBridge ? await this.nativeBridge.submitAction(concedeAction, pid) : await this.wasm.submitAction(concedeAction, pid); - await this.broadcastStateUpdate(result.events, result.log_entries); + await this.broadcastStateUpdate(result.events, result.log_entries, reason); await this.runAiLoop(); this.persistAuthoritativeState(); if (!this.nativeBridge) { @@ -2140,7 +2431,7 @@ export class P2PHostAdapter implements EngineAdapter { ) { this.gameRunState = "running"; for (const [, s] of this.guestSessions) { - s.send({ type: "game_resumed" }); + void this.send(s, { type: "game_resumed" }); } this.emit({ type: "gameResumed" }); } @@ -2155,6 +2446,7 @@ export class P2PHostAdapter implements EngineAdapter { * Adds the seat's token to the denylist so they cannot reconnect. */ async kickPlayer(pid: PlayerId, reason: string = "Kicked by host"): Promise { + if (!this.ownsAuthority()) return; const token = this.playerTokens.get(pid); if (token) this.kickedTokens.add(token); // Persist the kick before the session close — the kickedTokens set @@ -2166,7 +2458,7 @@ export class P2PHostAdapter implements EngineAdapter { // for an already-removed seat. const session = this.guestSessions.get(pid); if (session) { - session.send({ type: "kick", reason }); + void this.send(session, { type: "kick", reason }); try { session.close("Kicked"); } catch { /* best-effort */ } this.guestSessions.delete(pid); } @@ -2175,7 +2467,7 @@ export class P2PHostAdapter implements EngineAdapter { // locally; remaining peers need the wire message). for (const [otherPid, s] of this.guestSessions) { if (otherPid === pid) continue; - s.send({ type: "player_kicked", playerId: pid, reason }); + void this.send(s, { type: "player_kicked", playerId: pid, reason }); } } @@ -2184,11 +2476,12 @@ export class P2PHostAdapter implements EngineAdapter { * Cancels their grace timer and routes to `concedePlayer`. */ async concedeDisconnected(pid: PlayerId): Promise { + if (!this.ownsAuthority()) return; const reason = "Host continued without reconnecting player"; await this.concedePlayer(pid, reason, "conceded"); for (const [otherPid, s] of this.guestSessions) { if (otherPid === pid) continue; - s.send({ type: "player_conceded", playerId: pid, reason }); + void this.send(s, { type: "player_conceded", playerId: pid, reason }); } } @@ -2199,6 +2492,7 @@ export class P2PHostAdapter implements EngineAdapter { * fires; only the auto-concede timer is cancelled. */ holdForReconnect(pid: PlayerId): void { + if (!this.ownsAuthority()) return; const grace = this.disconnectedSeats.get(pid); if (grace) { if (grace.timer !== null) clearTimeout(grace.timer); @@ -2214,10 +2508,11 @@ export class P2PHostAdapter implements EngineAdapter { /** Manually pause (host UI). */ requestPause(): void { + if (!this.ownsAuthority()) return; if (this.gameRunState === "running") { this.gameRunState = "paused-manual"; for (const [, s] of this.guestSessions) { - s.send({ type: "game_paused", reason: "Paused by host" }); + void this.send(s, { type: "game_paused", reason: "Paused by host" }); } this.emit({ type: "gamePaused", reason: "Paused by host" }); } @@ -2225,13 +2520,14 @@ export class P2PHostAdapter implements EngineAdapter { /** Manually resume (host UI). Only resumes if no seats are still disconnected. */ requestResume(): void { + if (!this.ownsAuthority()) return; if ( this.gameRunState === "paused-manual" && this.disconnectedSeats.size === 0 ) { this.gameRunState = "running"; for (const [, s] of this.guestSessions) { - s.send({ type: "game_resumed" }); + void this.send(s, { type: "game_resumed" }); } this.emit({ type: "gameResumed" }); } @@ -2264,6 +2560,13 @@ export class P2PGuestAdapter implements EngineAdapter { private session: PeerSession | null = null; private playerToken: string | null = null; private assignedPlayerId: PlayerId | null = null; + /** Current host lease accepted from game_setup/reconnect_ack. */ + private authority: P2PAuthorityStamp | null = null; + readonly supportsMatchConcede: true | undefined; + private matchConcedeSent = false; + /** Revision of the cached state frame. A terminal result is bound to this + * exact final state, not merely to the room code. */ + private cachedRevision: number | null = null; /** * Once true, the adapter is in a terminal state (kicked, reconnect rejected, * or disposed). `handleHostDisconnect` bails out so the auto-reconnect loop @@ -2294,10 +2597,14 @@ export class P2PGuestAdapter implements EngineAdapter { // persisted before a prefix bump still resolve. Falls back to // `hostPeerId` when omitted (callers that don't persist across bumps). private readonly sessionKey?: string, + existingAuthority?: P2PAuthorityStamp, + matchConcedeBound: boolean = false, ) { if (existingPlayerToken) { this.playerToken = existingPlayerToken; } + this.authority = existingAuthority ?? null; + this.supportsMatchConcede = matchConcedeBound ? true : undefined; this.gameSetupPromise = new Promise((resolve, reject) => { this.gameSetupResolve = resolve; this.gameSetupReject = reject; @@ -2322,10 +2629,14 @@ export class P2PGuestAdapter implements EngineAdapter { this.attachSession(this.initialConn); if (this.playerToken) { traceAdapter("Guest", "send-reconnect", { hostPeerId: this.hostPeerId }); - this.session!.send({ type: "reconnect", playerToken: this.playerToken }); + this.send({ + type: "reconnect", + playerToken: this.playerToken, + ...(this.authority ? { sessionKey: this.authority.sessionKey } : {}), + }); } else { traceAdapter("Guest", "send-guest-deck", { hostPeerId: this.hostPeerId }); - this.session!.send({ + this.send({ type: "guest_deck", deckData: this.deckData, displayName: this.displayName, @@ -2374,7 +2685,7 @@ export class P2PGuestAdapter implements EngineAdapter { return new Promise((resolve, reject) => { this.pendingResolve = resolve; this.pendingReject = reject; - this.session!.send({ + this.send({ type: "action", senderPlayerId: this.assignedPlayerId!, action, @@ -2395,7 +2706,7 @@ export class P2PGuestAdapter implements EngineAdapter { return new Promise((resolve, reject) => { this.pendingResolve = resolve; this.pendingReject = reject; - this.session!.send({ + this.send({ type: "interaction", senderPlayerId: this.assignedPlayerId!, submission, @@ -2414,7 +2725,7 @@ export class P2PGuestAdapter implements EngineAdapter { const requestId = this.nextManaPaymentPreviewRequestId++; return new Promise((resolve, reject) => { this.pendingManaPaymentPreviews.set(requestId, { resolve, reject }); - void this.session!.send({ type: "preview_mana_payment", requestId, action }); + this.send({ type: "preview_mana_payment", requestId, action }); }); } @@ -2443,6 +2754,46 @@ export class P2PGuestAdapter implements EngineAdapter { return this.snapshot; } + private async acceptTerminalResult( + message: Extract, + ): Promise { + const { result } = message; + if ( + !isValidP2PTerminalResult(result) + || !message.authority + || message.authority.sessionKey !== result.lease.sessionKey + || message.authority.hostIncarnation !== result.lease.hostIncarnation + || this.authority === null + || result.key !== this.authority.sessionKey + || result.lease.hostIncarnation !== this.authority.hostIncarnation + || result.recipient !== this.assignedPlayerId + || this.cachedRevision !== result.revision + || this.snapshot?.state.waiting_for.type !== "GameOver" + ) { + this.emit({ type: "terminalUnavailable", message: "Rejected an unbound P2P terminal result" }); + return; + } + try { + if ((await p2pFinalStateCommitment(this.snapshot.state)) !== result.finalStateCommitment) { + this.emit({ type: "terminalUnavailable", message: "P2P terminal result did not match the final state" }); + return; + } + if (!(await commitP2PTerminalResult(result))) { + this.emit({ type: "terminalUnavailable", message: "Conflicting P2P terminal result" }); + return; + } + } catch (error) { + this.emit({ + type: "terminalUnavailable", + message: error instanceof Error ? error.message : "Failed to retain P2P terminal result", + }); + return; + } + this.terminated = true; + void clearP2PSession(this.sessionKey ?? this.hostPeerId); + this.emit({ type: "terminalResult", result }); + } + getAiAction(_difficulty: string, _playerId: number): GameAction | null { return null; } @@ -2461,7 +2812,14 @@ export class P2PGuestAdapter implements EngineAdapter { sendConcede(): void { if (!this.session) return; - this.session.send({ type: "concede" }); + this.send({ type: "concede" }); + } + + /** Requests settlement from the authenticated host-side match authority. */ + sendMatchConcede(): void { + if (!this.supportsMatchConcede || this.matchConcedeSent || !this.session) return; + this.matchConcedeSent = true; + this.send({ type: "match_concede" }); } dispose(): void { @@ -2507,14 +2865,20 @@ export class P2PGuestAdapter implements EngineAdapter { return; } } + if (!this.acceptsHostAuthority(msg)) return; switch (msg.type) { case "game_setup": { this.assignedPlayerId = msg.assignedPlayerId; this.playerToken = msg.playerToken; - void saveP2PSession(this.sessionKey ?? this.hostPeerId, { - playerToken: msg.playerToken, - playerId: msg.assignedPlayerId, - }); + if (msg.authority) { + this.authority = msg.authority; + void saveP2PSession(this.sessionKey ?? this.hostPeerId, { + playerToken: msg.playerToken, + playerId: msg.assignedPlayerId, + authority: this.authority, + }); + } + this.cachedRevision = msg.revision ?? null; this.cacheSnapshot(msg.state, legalActionsFromWire(msg)); this.emit({ type: "playerIdentity", playerId: msg.assignedPlayerId, playerNames: msg.playerNames }); this.settleGameSetup({ events: msg.events }); @@ -2522,12 +2886,15 @@ export class P2PGuestAdapter implements EngineAdapter { } case "reconnect_ack": { this.assignedPlayerId = msg.assignedPlayerId; - if (this.playerToken) { + if (this.playerToken && msg.authority) { + this.authority = msg.authority; void saveP2PSession(this.sessionKey ?? this.hostPeerId, { playerToken: this.playerToken, playerId: msg.assignedPlayerId, + authority: this.authority, }); } + this.cachedRevision = msg.revision ?? null; const reconnectSnapshot = this.cacheSnapshot(msg.state, legalActionsFromWire(msg)); this.emit({ type: "playerIdentity", playerId: msg.assignedPlayerId, playerNames: msg.playerNames }); this.emit({ @@ -2568,7 +2935,12 @@ export class P2PGuestAdapter implements EngineAdapter { this.emit({ type: "gameOver", winner: null, reason: msg.reason }); break; } + case "terminal_result": { + void this.acceptTerminalResult(msg); + break; + } case "state_update": { + this.cachedRevision = msg.revision ?? null; const updateSnapshot = this.cacheSnapshot(msg.state, legalActionsFromWire(msg)); if (this.pendingResolve) { this.pendingResolve({ events: msg.events, log_entries: msg.logEntries }); @@ -2693,6 +3065,37 @@ export class P2PGuestAdapter implements EngineAdapter { this.pendingManaPaymentPreviews.clear(); } + private acceptsHostAuthority(msg: P2PMessage): boolean { + if (!msg.authority) { + // Old room peers cannot emit a lease stamp. Hosts from this build always + // do, so their stale incarnations are fenced; accepting this shape keeps + // the additive wire change from breaking an already-open legacy room. + return true; + } + if (this.authority === null) return true; + if (msg.type === "reconnect_ack") { + // A legitimate same-key resume intentionally has a new incarnation. + if (this.authority && msg.authority.sessionKey !== this.authority.sessionKey) { + this.terminated = true; + this.rejectGameSetup("Host changed the P2P session key"); + return false; + } + return true; + } + if (msg.type === "game_setup") { + return msg.authority.sessionKey === this.authority.sessionKey + && msg.authority.hostIncarnation === this.authority.hostIncarnation; + } + return this.authority !== null + && msg.authority.sessionKey === this.authority.sessionKey + && msg.authority.hostIncarnation === this.authority.hostIncarnation; + } + + private send(message: P2PMessage): void { + if (!this.session) return; + this.session.send({ ...message, ...(this.authority ? { authority: this.authority } : {}) }); + } + private handleHostDisconnect(): void { this.rejectPendingManaPaymentPreviews( new AdapterError("P2P_ERROR", "Host disconnected during mana-payment preview", true), @@ -2735,7 +3138,11 @@ export class P2PGuestAdapter implements EngineAdapter { }); this.attachSession(conn); if (this.playerToken) { - this.session!.send({ type: "reconnect", playerToken: this.playerToken }); + this.send({ + type: "reconnect", + playerToken: this.playerToken, + ...(this.authority ? { sessionKey: this.authority.sessionKey } : {}), + }); } } catch (err) { console.warn( diff --git a/client/src/adapter/p2p-draft-guest.ts b/client/src/adapter/p2p-draft-guest.ts index b297125ff7..897db6972d 100644 --- a/client/src/adapter/p2p-draft-guest.ts +++ b/client/src/adapter/p2p-draft-guest.ts @@ -20,6 +20,7 @@ import { import { DRAFT_PROTOCOL_VERSION } from "../network/draftProtocol"; import type { DraftMatchLaunch, + DraftMatchSettlement, DraftP2PMessage, DraftPauseReason, } from "../network/draftProtocol"; @@ -27,6 +28,10 @@ import { saveDraftGuestSession, clearDraftGuestSession, } from "../services/draftPersistence"; +import type { + DraftIntergameCommand, + DraftIntergameCommandAck, +} from "../services/intergameCommandLedger"; // ── Types ────────────────────────────────────────────────────────────── @@ -40,11 +45,13 @@ export type DraftGuestEvent = | { type: "draftResumed" } | { type: "pairing"; round: number; table: number; opponentName: string; matchHostPeerId: string; matchId: string } | { type: "matchResult"; matchId: string; winnerSeat: number | null } + | { type: "matchSettlementAcknowledged"; matchId: string; receiptId: string; revision: number } | { type: "timerSync"; remainingMs: number } | { type: "matchStart"; launch: DraftMatchLaunch } | { type: "bo3SideboardPrompt"; matchId: string; gameNumber: number; score: { p0_wins: number; p1_wins: number; draws: number }; loserSeat: number | null; timerMs: number } | { type: "bo3ChoosePlayDraw"; matchId: string; gameNumber: number; score: { p0_wins: number; p1_wins: number; draws: number }; timerMs: number } | { type: "bo3GameStart"; matchId: string; gameNumber: number; firstPlayerSeat: number } + | { type: "bo3AuthorizedCommand"; command: DraftIntergameCommand; acknowledgement: DraftIntergameCommandAck } | { type: "bo3ScoreUpdate"; matchId: string; scoreA: number; scoreB: number } | { type: "kicked"; reason: string } | { type: "hostLeft"; reason: string } @@ -128,19 +135,29 @@ export class P2PDraftGuest { await this.session.send({ type: "draft_submit_deck", mainDeck }); } - sendMatchResult(matchId: string, winnerSeat: number | null): void { + sendMatchSettlement(settlement: DraftMatchSettlement): void { + if (!this.session) return; + void this.session.send({ type: "draft_match_settlement", settlement }); + } + + sendBetweenGames( + matchId: string, + gameNumber: number, + score: { p0_wins: number; p1_wins: number; draws: number }, + loserSeat: number | null, + ): void { if (!this.session) return; - void this.session.send({ type: "draft_match_result", matchId, winnerSeat }); + void this.session.send({ type: "draft_bo3_between_games", matchId, gameNumber, score, loserSeat }); } - sendSideboardSubmit(matchId: string, mainDeck: string[], sideboard: Array<{ name: string; count: number }>): void { + sendAuthorizedIntergameCommand(command: DraftIntergameCommand): void { if (!this.session) return; - void this.session.send({ type: "draft_bo3_sideboard_submit", matchId, mainDeck, sideboard }); + void this.session.send({ type: "draft_bo3_intergame_command", command }); } - sendPlayDrawChoice(matchId: string, playFirst: boolean): void { + sendIntergameReceipt(acknowledgement: DraftIntergameCommandAck, receiptId: string): void { if (!this.session) return; - void this.session.send({ type: "draft_bo3_play_draw_choice", matchId, playFirst }); + void this.session.send({ type: "draft_bo3_intergame_receipt", acknowledgement, receiptId }); } // ── Message handling ─────────────────────────────────────────────── @@ -242,6 +259,16 @@ export class P2PDraftGuest { break; } + case "draft_match_settlement_ack": { + this.emit({ + type: "matchSettlementAcknowledged", + matchId: msg.matchId, + receiptId: msg.receiptId, + revision: msg.revision, + }); + break; + } + case "draft_paused": { this.emit({ type: "draftPaused", reason: msg.reason }); break; @@ -293,6 +320,15 @@ export class P2PDraftGuest { break; } + case "draft_bo3_intergame_authorized": { + this.emit({ + type: "bo3AuthorizedCommand", + command: msg.command, + acknowledgement: msg.acknowledgement, + }); + break; + } + case "draft_bo3_play_draw_prompt": { this.emit({ type: "bo3ChoosePlayDraw", diff --git a/client/src/adapter/p2p-draft-host.ts b/client/src/adapter/p2p-draft-host.ts index 26fede35a9..f734020917 100644 --- a/client/src/adapter/p2p-draft-host.ts +++ b/client/src/adapter/p2p-draft-host.ts @@ -20,13 +20,28 @@ import { type DraftPeerSession, } from "../network/draftPeerSession"; import { DRAFT_PROTOCOL_VERSION, DraftPauseReason } from "../network/draftProtocol"; -import type { DraftDeckPayload, DraftMatchDeckPayload, DraftMatchLaunch, DraftP2PMessage } from "../network/draftProtocol"; -import type { MatchConfig, MatchScore } from "./types"; +import type { + DraftDeckPayload, + DraftMatchBinding, + DraftMatchDeckPayload, + DraftMatchLaunch, + DraftMatchSettlement, + DraftP2PMessage, +} from "../network/draftProtocol"; +import type { DeckCardCount, MatchConfig, MatchScore } from "./types"; import { saveDraftHostSession, clearDraftHostSession, type PersistedDraftHostSession, } from "../services/draftPersistence"; +import { + commandAcknowledgement, + draftIntergameDigest, + IntergameCommandController, + matchesCommandAcknowledgement, + type DraftIntergameCommand, + type DraftIntergameCommandAck, +} from "../services/intergameCommandLedger"; import { assignAvatarForSeat } from "../services/playerAvatars"; // ── Types ────────────────────────────────────────────────────────────── @@ -40,6 +55,7 @@ interface Bo3MatchState { loserSeat: number | null; gameNumber: number; score: MatchScore; + decks: Array<{ seat: number; main: DeckCardCount[]; sideboard: DeckCardCount[] }>; } export type DraftHostEvent = @@ -82,7 +98,8 @@ export type DraftHostEvent = | { type: "bo3GameStart"; matchId: string; gameNumber: number; firstPlayerSeat: number } | { type: "bo3SideboardPromptSent"; matchId: string } | { type: "bo3BothSideboardsSubmitted"; matchId: string } - | { type: "bo3GameStarted"; matchId: string; gameNumber: number }; + | { type: "bo3GameStarted"; matchId: string; gameNumber: number } + | { type: "bo3AuthorizedCommand"; command: DraftIntergameCommand; acknowledgement: DraftIntergameCommandAck }; type DraftHostEventListener = (event: DraftHostEvent) => void; @@ -115,6 +132,38 @@ function deckPayload(mainDeck: string[], sideboard: string[]): DraftDeckPayload return { main_deck: mainDeck, sideboard, commander: [] }; } +function deckCardCounts(cards: readonly string[]): DeckCardCount[] { + const counts = new Map(); + for (const card of cards) counts.set(card, (counts.get(card) ?? 0) + 1); + return [...counts].map(([name, count]) => ({ name, count })); +} + +function deckSubmission(deck: DraftDeckPayload): { main: DeckCardCount[]; sideboard: DeckCardCount[] } { + return { + main: deckCardCounts(deck.main_deck), + sideboard: deckCardCounts(deck.sideboard), + }; +} + +/** Sideboarding may move cards between zones, but cannot change a player's pool. */ +function preservesDeckPool( + deck: DraftDeckPayload, + main: readonly DeckCardCount[], + sideboard: readonly DeckCardCount[], +): boolean { + const submitted = new Map(); + for (const card of [...main, ...sideboard]) { + if (!Number.isSafeInteger(card.count) || card.count < 0) return false; + submitted.set(card.name, (submitted.get(card.name) ?? 0) + card.count); + } + const original = new Map(); + for (const name of [...deck.main_deck, ...deck.sideboard]) { + original.set(name, (original.get(name) ?? 0) + 1); + } + return submitted.size === original.size + && [...submitted].every(([name, count]) => original.get(name) === count); +} + function hashStringToSeed(value: string): number { let hash = 5381; for (let i = 0; i < value.length; i++) { @@ -170,6 +219,21 @@ export class P2PDraftHost { private timerEndAt = 0; private timerContext: "pick" | "sideboard" | "playdraw" | null = null; private bo3State = new Map(); + /** Registered decks are captured at match launch and become the first + * authority-owned default for an unchanged sideboard submission. */ + private matchDecks = new Map>(); + /** Full launch records let the host mint a timeout command under the same + * immutable launch digest the participant originally received. */ + private matchLaunches = new Map>(); + /** Private issuer for the durable Pending → Authorized → Executing → Receipted ledger. */ + private intergameCommands = new IntergameCommandController(); + private launchDigests = new Map>(); + /** Durable pod-issued authority records, keyed by match ID. */ + private matchBindings = new Map(); + /** Write-ahead settlement records; retained until the reducer accepts them. */ + private settlementOutbox = new Map(); + /** Immutable receipt per match makes retries idempotent. */ + private settlementReceipts = new Map(); // Server backup upload state (D-08) private backupEndpoint: string | null = null; @@ -415,25 +479,17 @@ export class P2PDraftHost { break; } case "draft_match_result": { - const hostView = await this.adapter.getViewForSeat(0); - const pairing = hostView.pairings.find( - (p) => p.match_id === msg.matchId && p.round === hostView.current_round, - ); - if (!pairing) { - this.guestSessions.get(seat)?.send({ - type: "draft_error", - reason: "Unknown match", - }); - return; - } - if (pairing.seat_a !== seat && pairing.seat_b !== seat) { - this.guestSessions.get(seat)?.send({ - type: "draft_error", - reason: "Not a participant in this match", - }); - return; - } - await this.reportMatchResult(msg.matchId, msg.winnerSeat); + // A raw match ID is forgeable by any connected seat. Keep the legacy + // shape decodable for an in-flight old client, but never settle it. + this.guestSessions.get(seat)?.send({ type: "draft_error", reason: "Unbound match result" }); + break; + } + case "draft_match_settlement": { + await this.acceptMatchSettlement(seat, msg.settlement); + break; + } + case "draft_bo3_between_games": { + await this.handleGuestBetweenGames(seat, msg); break; } case "draft_request_advance": { @@ -441,11 +497,19 @@ export class P2PDraftHost { break; } case "draft_bo3_sideboard_submit": { - this.handleSideboardSubmit(seat, msg.matchId, msg.mainDeck, msg.sideboard); + this.guestSessions.get(seat)?.send({ type: "draft_error", reason: "Unbound intergame command" }); break; } case "draft_bo3_play_draw_choice": { - this.handlePlayDrawChosen(seat, msg.matchId, msg.playFirst); + this.guestSessions.get(seat)?.send({ type: "draft_error", reason: "Unbound intergame command" }); + break; + } + case "draft_bo3_intergame_command": { + this.holdIntergameCommand(seat, msg.command); + break; + } + case "draft_bo3_intergame_receipt": { + this.receiptIntergameCommand(seat, msg.acknowledgement, msg.receiptId); break; } default: @@ -799,8 +863,7 @@ export class P2PDraftHost { this.broadcastToGuests({ type: "draft_timer_sync", remainingMs: this.timerRemainingMs }); if (this.timerRemainingMs <= 0) { this.clearActiveTimer(); - // Auto-choose "play" on expiry - this.resolvePlayDrawChoice(matchId, true); + this.autoChoosePlayDraw(matchId); } }, 1_000); } @@ -919,12 +982,108 @@ export class P2PDraftHost { } } + private matchBindingFor(pairing: PairingView): DraftMatchBinding { + const existing = this.matchBindings.get(pairing.match_id); + if (existing && existing.round === pairing.round) return existing; + + const binding: DraftMatchBinding = { + podId: this.draftCode, + matchId: pairing.match_id, + round: pairing.round, + sessionKey: crypto.randomUUID(), + lease: crypto.randomUUID(), + nonce: crypto.randomUUID(), + revision: 0, + matchAuthoritySeat: Math.min(pairing.seat_a, pairing.seat_b), + }; + this.matchBindings.set(pairing.match_id, binding); + this.persistSession(); + return binding; + } + + private async acceptMatchSettlement( + submittingSeat: number, + settlement: DraftMatchSettlement, + ): Promise { + const binding = this.matchBindings.get(settlement.binding.matchId); + if (!binding || !this.sameBinding(binding, settlement.binding)) { + this.guestSessions.get(submittingSeat)?.send({ type: "draft_error", reason: "Invalid match binding" }); + return; + } + const view = await this.adapter.getViewForSeat(0); + const pairing = view.pairings.find( + (candidate) => candidate.match_id === binding.matchId && candidate.round === binding.round, + ); + if ( + !pairing || + view.current_round !== binding.round || + submittingSeat !== binding.matchAuthoritySeat || + (settlement.winnerSeat !== null && + settlement.winnerSeat !== pairing.seat_a && + settlement.winnerSeat !== pairing.seat_b) + ) { + this.guestSessions.get(submittingSeat)?.send({ type: "draft_error", reason: "Unauthorized match settlement" }); + return; + } + + const receipt = this.settlementReceipts.get(binding.matchId); + if (receipt) { + if (receipt.receiptId === settlement.receiptId) { + void this.sendSettlementAck(submittingSeat, binding.matchId, receipt); + } else { + this.sendToSeat(submittingSeat, { + type: "draft_error", + reason: "Match already settled", + }); + } + return; + } + + // Persist the intent before invoking the draft reducer. A recovered pod + // can retry this record without applying a second result. + this.settlementOutbox.set(settlement.receiptId, settlement); + this.persistSession(); + await this.reportMatchResult(binding.matchId, settlement.winnerSeat); + const accepted = { receiptId: settlement.receiptId, revision: binding.revision }; + this.settlementReceipts.set(binding.matchId, accepted); + this.settlementOutbox.delete(settlement.receiptId); + this.persistSession(); + void this.sendSettlementAck(submittingSeat, binding.matchId, accepted); + } + + private sameBinding(left: DraftMatchBinding, right: DraftMatchBinding): boolean { + return left.podId === right.podId + && left.matchId === right.matchId + && left.round === right.round + && left.sessionKey === right.sessionKey + && left.lease === right.lease + && left.nonce === right.nonce + && left.revision === right.revision + && left.matchAuthoritySeat === right.matchAuthoritySeat; + } + + private async sendSettlementAck( + seat: number, + matchId: string, + receipt: { receiptId: string; revision: number }, + ): Promise { + const message: DraftP2PMessage = { + type: "draft_match_settlement_ack", + matchId, + receiptId: receipt.receiptId, + revision: receipt.revision, + }; + if (seat === 0) return; + await this.guestSessions.get(seat)?.send(message); + } + private async dispatchMatchLaunch(pairing: PairingView, view: DraftPlayerView): Promise { const seatA = pairing.seat_a; const seatB = pairing.seat_b; const seatAIsBot = this.isBotSeatFromView(view, seatA); const seatBIsBot = this.isBotSeatFromView(view, seatB); const session = await this.exportDraftSession(); + const binding = this.matchBindingFor(pairing); if (seatAIsBot && seatBIsBot) { await this.reportMatchResult(pairing.match_id, Math.min(seatA, seatB)); @@ -943,9 +1102,7 @@ export class P2PDraftHost { ai_decks: [], }; - this.sendToSeat(humanSeat, { - type: "draft_match_start", - launch: { + this.sendMatchLaunch(humanSeat, { type: "Bot", matchId: pairing.match_id, round: pairing.round, @@ -954,7 +1111,7 @@ export class P2PDraftHost { botName, deckPayload, matchConfig: this.matchConfig(), - }, + binding, }); return; } @@ -972,9 +1129,7 @@ export class P2PDraftHost { ai_decks: [], }; - this.sendToSeat(matchHostSeat, { - type: "draft_match_start", - launch: { + this.sendMatchLaunch(matchHostSeat, { type: "HumanHost", matchId: pairing.match_id, matchRoomCode, @@ -985,11 +1140,9 @@ export class P2PDraftHost { matchHostPeerId: matchRoomCode, deckPayload, matchConfig: this.matchConfig(), - }, + binding, }); - this.sendToSeat(guestSeat, { - type: "draft_match_start", - launch: { + this.sendMatchLaunch(guestSeat, { type: "HumanGuest", matchId: pairing.match_id, matchRoomCode, @@ -1000,10 +1153,49 @@ export class P2PDraftHost { matchHostPeerId: matchRoomCode, localDeck: guestDeck, matchConfig: this.matchConfig(), - }, + binding, }); } + private sendMatchLaunch(seat: number, launch: DraftMatchLaunch): void { + this.rememberMatchDecks(launch); + let launches = this.matchLaunches.get(launch.matchId); + if (!launches) { + launches = new Map(); + this.matchLaunches.set(launch.matchId, launches); + } + launches.set(seat, launch); + let digests = this.launchDigests.get(launch.matchId); + if (!digests) { + digests = new Map(); + this.launchDigests.set(launch.matchId, digests); + } + digests.set(seat, draftIntergameDigest(launch)); + this.persistSession(); + this.sendToSeat(seat, { type: "draft_match_start", launch }); + } + + private rememberMatchDecks(launch: DraftMatchLaunch): void { + let decks = this.matchDecks.get(launch.matchId); + if (!decks) { + decks = new Map(); + this.matchDecks.set(launch.matchId, decks); + } + switch (launch.type) { + case "HumanHost": + decks.set(launch.localSeat, launch.deckPayload.player); + decks.set(launch.opponentSeat, launch.deckPayload.opponent); + break; + case "HumanGuest": + decks.set(launch.localSeat, launch.localDeck); + break; + case "Bot": + decks.set(launch.localSeat, launch.deckPayload.player); + decks.set(launch.botSeat, launch.deckPayload.opponent); + break; + } + } + private async dispatchMatchLaunchesForSeat(view: DraftPlayerView, seat: number): Promise { for (const pairing of view.pairings) { if (pairing.round !== view.current_round) continue; @@ -1078,9 +1270,15 @@ export class P2PDraftHost { } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(`[P2PDraftHost] reportMatchResult failed:`, message); + throw err; } } + /** Seat 0 uses the same authenticated settlement gate as remote match hosts. */ + async submitHostMatchSettlement(settlement: DraftMatchSettlement): Promise { + await this.acceptMatchSettlement(0, settlement); + } + /** * Advance to the next round (Casual mode, host-only). * T-57-07: only callable from host UI; guests sending draft_request_advance are ignored. @@ -1133,10 +1331,15 @@ export class P2PDraftHost { seatA: number, seatB: number, ): void { + const decks = this.matchDecks.get(matchId); this.bo3State.set(matchId, { seatA, seatB, submittedA: false, submittedB: false, loserSeat, gameNumber, score, + decks: [seatA, seatB].flatMap((seat) => { + const deck = decks?.get(seat); + return deck ? [{ seat, ...deckSubmission(deck) }] : []; + }), }); const timerMs = this.podPolicy === "Competitive" ? 60_000 : 0; @@ -1164,50 +1367,220 @@ export class P2PDraftHost { this.emit({ type: "bo3SideboardPromptSent", matchId }); } - /** - * Handle a sideboard submission from a player in a Bo3 match. - * T-58-01: validates seat matches seatA or seatB. - */ - handleSideboardSubmit( + private async handleGuestBetweenGames( seat: number, - matchId: string, - _mainDeck: string[], - _sideboard: Array<{ name: string; count: number }>, - ): void { - const state = this.bo3State.get(matchId); - if (!state) return; + message: Extract, + ): Promise { + const binding = this.matchBindings.get(message.matchId); + const view = await this.adapter.getViewForSeat(0); + const pairing = view.pairings.find( + (candidate) => candidate.match_id === message.matchId && candidate.round === binding?.round, + ); + if ( + !binding + || binding.matchAuthoritySeat !== seat + || view.current_round !== binding.round + || !pairing + || (pairing.status !== "Pending" && pairing.status !== "InProgress") + ) { + this.guestSessions.get(seat)?.send({ type: "draft_error", reason: "Unauthorized between-games report" }); + return; + } + if (this.bo3State.get(message.matchId)?.gameNumber === message.gameNumber) return; + + this.handleMatchBetweenGames( + message.matchId, + message.gameNumber, + message.score, + message.loserSeat, + pairing.seat_a, + pairing.seat_b, + ); + } + + /** The sole command ingress for host UI and authenticated guest sessions. */ + submitAuthorized(seat: number, command: DraftIntergameCommand): void { + if (command.status === "Receipted" && command.receiptId) { + this.receiptIntergameCommand(seat, commandAcknowledgement(command), command.receiptId); + return; + } + this.holdIntergameCommand(seat, command); + } + + private holdIntergameCommand(seat: number, command: DraftIntergameCommand): void { + const state = this.bo3State.get(command.matchId); + const launchDigest = this.launchDigests.get(command.matchId)?.get(seat); + if (!state + || command.status !== "Pending" + || command.seat !== seat + || command.gameNumber !== state.gameNumber + || !launchDigest + || command.launchDigest !== launchDigest + || draftIntergameDigest(command.launchPayload) !== command.launchDigest + || command.payloadDigest !== draftIntergameDigest(command.payload) + || this.intergameCommands.snapshot().some((candidate) => candidate.commandId === command.commandId)) { + return; + } + if (command.payload.type === "SubmitSideboard") { + const deck = this.matchDecks.get(command.matchId)?.get(seat); + if ( + (seat !== state.seatA && seat !== state.seatB) + || !deck + || !preservesDeckPool(deck, command.payload.main, command.payload.sideboard) + ) { + this.sendToSeat(seat, { type: "draft_error", reason: "Invalid sideboard submission" }); + return; + } + } else if (state.loserSeat !== seat) { + return; + } - // T-58-01: validate sending seat belongs to this pairing - if (seat === state.seatA) state.submittedA = true; - else if (seat === state.seatB) state.submittedB = true; - else return; + const held = this.intergameCommands.hold({ + commandId: command.commandId, + matchId: command.matchId, + gameNumber: command.gameNumber, + seat, + payload: command.payload, + launchPayload: command.launchPayload, + launchDigest: command.launchDigest, + }); + this.persistSession(); - // Check both-submitted gate - if (state.submittedA && state.submittedB) { - this.clearActiveTimer(); - this.emit({ type: "bo3BothSideboardsSubmitted", matchId }); - this.transitionToPlayDraw(matchId, state); + switch (held.payload.type) { + case "SubmitSideboard": + if (seat === state.seatA) state.submittedA = true; + else state.submittedB = true; + if (state.submittedA && state.submittedB) { + this.clearActiveTimer(); + for (const pending of this.intergameCommands.snapshot()) { + if (pending.matchId === held.matchId + && pending.gameNumber === held.gameNumber + && pending.status === "Pending" + && pending.payload.type === "SubmitSideboard") { + this.authorizeIntergameCommand(pending); + } + } + this.emit({ type: "bo3BothSideboardsSubmitted", matchId: held.matchId }); + } + break; + case "ChoosePlayDraw": + this.authorizeIntergameCommand(held); + break; } } - /** - * Handle play/draw choice from the losing player. - * T-58-04: validates seat matches loserSeat. - */ - handlePlayDrawChosen(seat: number, matchId: string, playFirst: boolean): void { - const state = this.bo3State.get(matchId); - if (!state || state.loserSeat !== seat) return; - this.resolvePlayDrawChoice(matchId, playFirst); + private authorizeIntergameCommand(command: DraftIntergameCommand): void { + const acknowledgement = commandAcknowledgement(command); + const authorized = this.intergameCommands.authorize(command.commandId, acknowledgement); + if (!authorized) return; + const permit = this.intergameCommands.begin(command.commandId, acknowledgement); + if (!permit) return; + // The controller, not a caller supplied flag, owns the Executing state. + // This deliberate no-op consumption proves the issuer created the permit; + // the participant performs the same pre-execution check with its own issuer. + void permit; + this.persistSession(); + this.sendToSeat(command.seat, { + type: "draft_bo3_intergame_authorized", + command: authorized, + acknowledgement, + }); + } + + private receiptIntergameCommand( + seat: number, + acknowledgement: DraftIntergameCommandAck, + receiptId: string, + ): void { + const command = this.intergameCommands.snapshot().find( + (candidate) => candidate.commandId === acknowledgement.commandId, + ); + if (!command || command.seat !== seat || !matchesCommandAcknowledgement(command, acknowledgement)) return; + const receipted = this.intergameCommands.receipt(command.commandId, acknowledgement, receiptId); + if (!receipted) return; + this.persistSession(); + switch (receipted.payload.type) { + case "SubmitSideboard": { + const state = this.bo3State.get(receipted.matchId); + const deck = state?.decks.find((candidate) => candidate.seat === seat); + if (deck) { + deck.main = receipted.payload.main; + deck.sideboard = receipted.payload.sideboard; + } + const complete = state && [state.seatA, state.seatB].every((participant) => + this.intergameCommands.snapshot().some((candidate) => + candidate.matchId === receipted.matchId + && candidate.gameNumber === receipted.gameNumber + && candidate.seat === participant + && candidate.payload.type === "SubmitSideboard" + && candidate.status === "Receipted"), + ); + if (complete && state) this.transitionToPlayDraw(receipted.matchId, state); + break; + } + case "ChoosePlayDraw": + this.resolvePlayDrawChoice(receipted.matchId, receipted.payload.playFirst); + break; + } } private autoSubmitSideboards(matchId: string): void { const state = this.bo3State.get(matchId); if (!state) return; - // Mark both as submitted (they keep their current decks) - state.submittedA = true; - state.submittedB = true; - this.emit({ type: "bo3BothSideboardsSubmitted", matchId }); - this.transitionToPlayDraw(matchId, state); + const participants = [state.seatA, state.seatB]; + const submitted = new Set([ + ...(state.submittedA ? [state.seatA] : []), + ...(state.submittedB ? [state.seatB] : []), + ]); + for (const seat of participants) { + if (submitted.has(seat)) continue; + const deck = state.decks.find((candidate) => candidate.seat === seat); + if (!deck) { + this.emit({ type: "error", message: "Sideboard timer expired without a registered deck" }); + continue; + } + this.submitDefaultIntergameCommand(matchId, state, seat, { + type: "SubmitSideboard", + main: deck.main, + sideboard: deck.sideboard, + }); + } + } + + private autoChoosePlayDraw(matchId: string): void { + const state = this.bo3State.get(matchId); + if (!state || state.loserSeat === null) return; + this.submitDefaultIntergameCommand(matchId, state, state.loserSeat, { + type: "ChoosePlayDraw", + playFirst: true, + }); + } + + /** Timeout defaults enter the same signed launch/ledger path as a player + * submission, so they cannot bypass authorization or the execution receipt. */ + private submitDefaultIntergameCommand( + matchId: string, + state: Bo3MatchState, + seat: number, + payload: DraftIntergameCommand["payload"], + ): void { + const launch = this.matchLaunches.get(matchId)?.get(seat); + const launchDigest = this.launchDigests.get(matchId)?.get(seat); + if (!launch || !launchDigest) { + this.emit({ type: "error", message: "Intergame timeout lacks launch authority" }); + return; + } + this.holdIntergameCommand(seat, { + commandId: crypto.randomUUID(), + matchId, + gameNumber: state.gameNumber, + seat, + payload, + launchPayload: launch, + launchDigest, + payloadDigest: draftIntergameDigest(payload), + status: "Pending", + }); } private transitionToPlayDraw(matchId: string, state: Bo3MatchState): void { @@ -1284,6 +1657,13 @@ export class P2PDraftHost { firstPlayerSeat: msg.firstPlayerSeat, }); break; + case "draft_bo3_intergame_authorized": + this.emit({ + type: "bo3AuthorizedCommand", + command: msg.command, + acknowledgement: msg.acknowledgement, + }); + break; default: break; } @@ -1378,6 +1758,19 @@ export class P2PDraftHost { draftCode: this.draftCode, draftSessionJson: sessionJson, poolInput: this.poolInput, + matchBindings: [...this.matchBindings.values()], + settlementOutbox: [...this.settlementOutbox.values()], + settlementReceipts: [...this.settlementReceipts.entries()].map( + ([matchId, receipt]) => ({ matchId, ...receipt }), + ), + intergameCommands: this.intergameCommands.snapshot(), + bo3State: [...this.bo3State.entries()].map(([matchId, state]) => ({ matchId, ...state })), + launchDigests: [...this.launchDigests.entries()].flatMap(([matchId, digests]) => + [...digests.entries()].map(([seat, digest]) => ({ matchId, seat, digest })), + ), + matchLaunches: [...this.matchLaunches.entries()].flatMap(([matchId, launches]) => + [...launches.entries()].map(([seat, launch]) => ({ matchId, seat, launch })), + ), }; await saveDraftHostSession(this.persistenceId!, snapshot); @@ -1448,9 +1841,48 @@ export class P2PDraftHost { this.draftStarted = session.draftStarted; this.draftCode = session.draftCode; this.draftSeed = hashStringToSeed(session.draftCode || this.roomCode || "draft"); + for (const binding of session.matchBindings ?? []) { + this.matchBindings.set(binding.matchId, binding); + } + for (const settlement of session.settlementOutbox ?? []) { + this.settlementOutbox.set(settlement.receiptId, settlement); + } + for (const receipt of session.settlementReceipts ?? []) { + this.settlementReceipts.set(receipt.matchId, { + receiptId: receipt.receiptId, + revision: receipt.revision, + }); + } + this.intergameCommands = new IntergameCommandController(session.intergameCommands ?? []); + this.intergameCommands.recover(); + for (const state of session.bo3State ?? []) { + const { matchId, ...rest } = state; + this.bo3State.set(matchId, { ...rest, decks: rest.decks ?? [] }); + } + for (const { matchId, seat, digest } of session.launchDigests ?? []) { + let digests = this.launchDigests.get(matchId); + if (!digests) { + digests = new Map(); + this.launchDigests.set(matchId, digests); + } + digests.set(seat, digest); + } + for (const { matchId, seat, launch } of session.matchLaunches ?? []) { + const binding = launch.binding ?? this.matchBindings.get(matchId); + if (!binding || binding.matchId !== matchId) continue; + const recoveredLaunch = launch.binding ? launch : { ...launch, binding } as DraftMatchLaunch; + let launches = this.matchLaunches.get(matchId); + if (!launches) { + launches = new Map(); + this.matchLaunches.set(matchId, launches); + } + launches.set(seat, recoveredLaunch); + this.rememberMatchDecks(recoveredLaunch); + } if (session.draftSessionJson) { const view = await this.adapter.importSession(session.draftSessionJson, 2); + await this.recoverSettlementOutbox(view); // Arm grace windows for all guest seats for (const seatStr of Object.keys(session.seatTokens)) { @@ -1484,6 +1916,26 @@ export class P2PDraftHost { return null; } + /** Replays only write-ahead settlements that the restored draft still lacks. */ + private async recoverSettlementOutbox(view: DraftPlayerView): Promise { + for (const settlement of [...this.settlementOutbox.values()]) { + const binding = this.matchBindings.get(settlement.binding.matchId); + const pairing = view.pairings.find( + (candidate) => candidate.match_id === settlement.binding.matchId, + ); + if (!binding || !this.sameBinding(binding, settlement.binding) || !pairing) continue; + if (pairing.status === "Pending" || pairing.status === "InProgress") { + await this.reportMatchResult(settlement.binding.matchId, settlement.winnerSeat); + } + this.settlementReceipts.set(settlement.binding.matchId, { + receiptId: settlement.receiptId, + revision: settlement.binding.revision, + }); + this.settlementOutbox.delete(settlement.receiptId); + } + this.persistSession(); + } + // ── Cleanup ──────────────────────────────────────────────────────── dispose(): void { @@ -1494,6 +1946,8 @@ export class P2PDraftHost { } this.disconnectedSeats.clear(); this.bo3State.clear(); + this.matchDecks.clear(); + this.matchLaunches.clear(); for (const session of this.guestSessions.values()) { session.close(); } diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index ddc7c48e62..eb1be7d8be 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -3584,3 +3584,23 @@ export interface EngineAdapter { */ estimateBracket(deck: BracketDeckRequest): Promise; } + +/** + * Optional transport capability for a whole-match concession. This is a + * capability rather than a route-mode policy: the UI may offer it only when + * the installed adapter explicitly vouches that it can bind the request to an + * authenticated match session. P2P installs it only for a pod-issued draft + * match binding; ordinary P2P rooms intentionally do not expose it. + */ +export interface MatchConcedeCapability { + readonly supportsMatchConcede: true; + sendMatchConcede(): void; +} + +export function supportsMatchConcede( + adapter: EngineAdapter | null, +): adapter is EngineAdapter & MatchConcedeCapability { + return adapter !== null + && (adapter as Partial).supportsMatchConcede === true + && typeof (adapter as Partial).sendMatchConcede === "function"; +} diff --git a/client/src/adapter/ws-adapter.ts b/client/src/adapter/ws-adapter.ts index 8d3024f348..f5aab792b9 100644 --- a/client/src/adapter/ws-adapter.ts +++ b/client/src/adapter/ws-adapter.ts @@ -25,7 +25,11 @@ import { type PhaseSocketTransport, } from "../services/openPhaseSocket"; import { isValidWebSocketUrl, mixedContentBlockReason } from "../services/serverDetection"; -import type { WsSessionData } from "../services/multiplayerSession"; +import type { FullSessionKey, WsSessionData } from "../services/multiplayerSession"; +import { + commitFullTerminalDelivery, + type FullTerminalDelivery, +} from "../services/fullTerminalResult"; /** Deck data format matching server protocol. */ export interface DeckData { @@ -39,6 +43,100 @@ export interface DeckData { sticker_sheets?: string[]; } +export type { FullSessionKey } from "../services/multiplayerSession"; + +/** + * Performs one terminal-only request on a fresh raw websocket. This never + * constructs a WebSocketAdapter, so a recovered terminal cannot initialize a + * game loop or acquire a normal Full attachment. + */ +async function terminalSocketRequest( + serverUrl: string, + request: unknown, + socketFactory?: PhaseSocketFactory, +): Promise<{ type: string; data?: unknown }> { + const socket = await openPhaseSocket(serverUrl, { socketFactory }); + return new Promise((resolve, reject) => { + const closeAndResolve = (message: { type: string; data?: unknown }) => { + socket.ws.close(); + resolve(message); + }; + socket.ws.onmessage = (event) => { + try { + closeAndResolve(JSON.parse(event.data as string) as { type: string; data?: unknown }); + } catch (error) { + socket.ws.close(); + reject(error); + } + }; + socket.ws.onerror = () => { + socket.ws.close(); + reject(new Error("Terminal websocket request failed")); + }; + socket.ws.onclose = () => { + reject(new Error("Terminal websocket closed before a response")); + }; + socket.ws.send(JSON.stringify(request)); + }); +} + +export async function bootstrapFullTerminalDelivery( + serverUrl: string, + key: FullSessionKey, + playerToken: string, + requestId: string, + socketFactory?: PhaseSocketFactory, +): Promise { + const response = await terminalSocketRequest( + serverUrl, + { + type: "BootstrapTerminalDelivery", + data: { + request: { + key, + playerToken, + requestId, + }, + }, + }, + socketFactory, + ); + if (response.type !== "TerminalBootstrapResult") { + throw new Error("Unexpected terminal bootstrap response"); + } + return (response.data as { delivery?: FullTerminalDelivery }).delivery ?? null; +} + +export async function readFullTerminalResult( + serverUrl: string, + credential: string, + socketFactory?: PhaseSocketFactory, +): Promise { + const response = await terminalSocketRequest( + serverUrl, + { type: "ReadTerminalResult", data: { credential } }, + socketFactory, + ); + if (response.type !== "TerminalResult") { + throw new Error("Unexpected terminal result response"); + } + return (response.data as { delivery?: FullTerminalDelivery }).delivery ?? null; +} + +export async function acknowledgeFullTerminalDelivery( + serverUrl: string, + deliveryId: string, + credential: string, + socketFactory?: PhaseSocketFactory, +): Promise { + const response = await terminalSocketRequest( + serverUrl, + { type: "AckTerminalDelivery", data: { delivery_id: deliveryId, credential } }, + socketFactory, + ); + return response.type === "TerminalDeliveryAcknowledged"; +} + /** AI seat configuration for the private native-engine host path. */ export interface NativeAiSeat { seatIndex: number; @@ -73,12 +171,13 @@ export interface NativeSocketAdapterOptions { export type NativePregameAdapterOptions = | ({ kind: "host"; aiSeats: NativeAiSeat[]; playerCount: number; formatConfig?: FormatConfig; matchConfig?: MatchConfig } & NativeSocketAdapterOptions) | ({ kind: "guest" } & NativeSocketAdapterOptions) - | ({ kind: "reconnect"; gameCode: string; playerId: PlayerId; playerToken: string } & NativeSocketAdapterOptions); + | ({ kind: "reconnect"; gameCode: string; playerId: PlayerId; playerToken: string; fullKey: FullSessionKey } & NativeSocketAdapterOptions); export interface NativeSessionAttachment { gameCode: string; playerId: PlayerId; playerToken: string; + fullKey: FullSessionKey; } export interface WebSocketAdapterOptions { @@ -173,6 +272,8 @@ export type WsAdapterEvent = | { type: "reconnecting"; attempt: number; maxAttempts: number } | { type: "reconnected" } | { type: "reconnectFailed" } + | { type: "terminalDelivery"; delivery: FullTerminalDelivery } + | { type: "terminalUnavailable"; message: string } /** The engine pair travels as one `EngineSnapshot` — see the P2P adapter's * `stateChanged` for why the halves must stay inseparable. */ | { type: "stateChanged"; snapshot: EngineSnapshot; events: GameEvent[]; logEntries?: GameLogEntry[]; serverRevision?: number } @@ -205,6 +306,7 @@ function playerNamesFromWire(names: string[]): Record { * for multiplayer games. */ export class WebSocketAdapter implements EngineAdapter { + readonly supportsMatchConcede = true; private ws: PhaseSocketTransport | null = null; /** * The single cached engine pair, rebuilt (and re-stamped) once per inbound @@ -216,6 +318,7 @@ export class WebSocketAdapter implements EngineAdapter { private _playerId: PlayerId | null = null; private playerToken: string | null = null; private _gameCode: string | null = null; + private fullSessionKey: FullSessionKey | null = null; private pendingResolve: ((result: SubmitResult) => void) | null = null; private pendingReject: ((error: Error) => void) | null = null; private nextManaPaymentPreviewRequestId = 1; @@ -333,14 +436,15 @@ export class WebSocketAdapter implements EngineAdapter { * persist this so a suspended game can be resumed by constructing a * `kind: "reconnect"` adapter. The player token is issued once at creation * and lives only client-side — it is the reconnect security boundary. */ - get nativeSession(): { gameCode: string; playerId: PlayerId; playerToken: string } | null { - if (this._gameCode === null || this._playerId === null || this.playerToken === null) { + get nativeSession(): { gameCode: string; playerId: PlayerId; playerToken: string; fullKey: FullSessionKey } | null { + if (this._gameCode === null || this._playerId === null || this.playerToken === null || this.fullSessionKey === null) { return null; } return { gameCode: this._gameCode, playerId: this._playerId, playerToken: this.playerToken, + fullKey: this.fullSessionKey, }; } @@ -436,6 +540,7 @@ export class WebSocketAdapter implements EngineAdapter { this._gameCode = options.gameCode; this._playerId = options.playerId; this.playerToken = options.playerToken; + this.fullSessionKey = options.fullKey; } return new Promise((resolve, reject) => { this.pregameResolve = resolve; @@ -729,6 +834,11 @@ export class WebSocketAdapter implements EngineAdapter { this.send({ type: "Concede" }); } + /** Requests a whole-match concession for this authenticated session. */ + sendMatchConcede(): void { + this.send({ type: "ConcedeMatch" }); + } + sendEmote(emote: string): void { this.send({ type: "Emote", data: { emote } }); } @@ -785,6 +895,7 @@ export class WebSocketAdapter implements EngineAdapter { this._playerId = null; this.playerToken = null; this._gameCode = null; + this.fullSessionKey = null; this.pendingResolve = null; this.pendingReject = null; this.rejectPendingManaPaymentPreviews( @@ -809,6 +920,7 @@ export class WebSocketAdapter implements EngineAdapter { tryReconnect(session: WsSessionData): boolean { this._gameCode = session.gameCode; this.playerToken = session.playerToken; + this.fullSessionKey = session.fullKey; if (!this.isNativeSocket() && !isValidWebSocketUrl(this.serverUrl)) { this.emit({ type: "reconnectFailed" }); @@ -821,6 +933,7 @@ export class WebSocketAdapter implements EngineAdapter { data: { game_code: session.gameCode, player_token: session.playerToken, + full_key: session.fullKey, }, }).catch(() => { // attachSocket handles reconnect-driven retries via `attemptReconnect` @@ -926,6 +1039,7 @@ export class WebSocketAdapter implements EngineAdapter { data: { game_code: options.gameCode, player_token: options.playerToken, + full_key: options.fullKey, }, }; } @@ -1022,9 +1136,14 @@ export class WebSocketAdapter implements EngineAdapter { // post-handshake message loop begins. case "GameCreated": { - const data = msg.data as { game_code: string; player_token: string }; + const data = msg.data as { + game_code: string; + player_token: string; + full_key?: FullSessionKey; + }; this._gameCode = data.game_code; this.playerToken = data.player_token; + if (data.full_key && !this.acceptFullSessionKey(data.full_key)) break; this.hostWaitingForOpponent = true; this.emit({ type: "sessionChanged", session: this.currentSession() }); this.emit({ type: "gameCreated", gameCode: data.game_code }); @@ -1033,13 +1152,25 @@ export class WebSocketAdapter implements EngineAdapter { } case "SessionAttached": { - const data = msg.data as { game_code: string; player_id: PlayerId; player_token: string }; + const data = msg.data as { + game_code: string; + player_id: PlayerId; + player_token: string; + full_key?: FullSessionKey; + }; + this._gameCode = data.game_code; + const fullKey = data.full_key; + if (!fullKey) { + this.acceptFullSessionKey(undefined); + break; + } + if (!this.acceptFullSessionKey(fullKey)) break; const attachment: NativeSessionAttachment = { gameCode: data.game_code, playerId: data.player_id, playerToken: data.player_token, + fullKey, }; - this._gameCode = attachment.gameCode; this._playerId = attachment.playerId; this.playerToken = attachment.playerToken; this.emit({ type: "sessionChanged", session: this.currentSession() }); @@ -1118,7 +1249,7 @@ export class WebSocketAdapter implements EngineAdapter { } case "GameStarted": { - const data = msg.data as { state_revision: number; state: GameState; your_player: PlayerId; opponent_name?: string; player_names?: string[]; legal_actions?: GameAction[]; auto_pass_recommended?: boolean; end_continuous_effect_offers?: LegalActionsResult["endContinuousEffectOffers"]; mana_payment_shortcut_actions?: GameAction[]; spell_costs?: Record; legal_actions_by_object?: Record; viewer_interaction?: LegalActionsResult["viewerInteraction"]; derived?: GameState["derived"]; player_token?: string; events?: GameEvent[] }; + const data = msg.data as { state_revision: number; state: GameState; your_player: PlayerId; opponent_name?: string; player_names?: string[]; legal_actions?: GameAction[]; auto_pass_recommended?: boolean; end_continuous_effect_offers?: LegalActionsResult["endContinuousEffectOffers"]; mana_payment_shortcut_actions?: GameAction[]; spell_costs?: Record; legal_actions_by_object?: Record; viewer_interaction?: LegalActionsResult["viewerInteraction"]; derived?: GameState["derived"]; player_token?: string; full_key?: FullSessionKey; events?: GameEvent[] }; if (this.reconnectInFlight) { this.reconnectInFlight = false; this.reconnectAttempt = 0; @@ -1159,6 +1290,7 @@ export class WebSocketAdapter implements EngineAdapter { gameCode: expected.gameCode, playerId: expected.playerId, playerToken: expected.playerToken, + fullKey: expected.fullKey, }; this.emit({ type: "sessionChanged", session: this.currentSession() }); this.emit({ type: "sessionAttached", attachment }); @@ -1172,6 +1304,7 @@ export class WebSocketAdapter implements EngineAdapter { if (!this._gameCode && this.joinGameCode) { this._gameCode = this.joinGameCode; } + if (data.full_key && !this.acceptFullSessionKey(data.full_key)) break; if (data.player_token) { this.playerToken = data.player_token; this.emit({ type: "sessionChanged", session: this.currentSession() }); @@ -1315,6 +1448,35 @@ export class WebSocketAdapter implements EngineAdapter { break; } + case "TerminalResult": { + const delivery = (msg.data as { delivery?: FullTerminalDelivery }).delivery; + if (!delivery) break; + void (async () => { + if (!(await commitFullTerminalDelivery(delivery))) { + this.emit({ + type: "terminalUnavailable", + message: "Failed to retain terminal delivery", + }); + return; + } + this.gameEnded = true; + this.emit({ type: "actionPendingChanged", pending: false }); + this.emit({ type: "sessionChanged", session: null }); + this.emit({ type: "terminalDelivery", delivery }); + await acknowledgeFullTerminalDelivery( + this.serverUrl, + delivery.delivery_id, + delivery.credential, + ); + })().catch((error: unknown) => { + this.emit({ + type: "terminalUnavailable", + message: error instanceof Error ? error.message : "Terminal acknowledgement failed", + }); + }); + break; + } + case "GameOver": { const data = msg.data as { winner: PlayerId | null; reason: string }; this.gameEnded = true; @@ -1443,14 +1605,37 @@ export class WebSocketAdapter implements EngineAdapter { } private currentSession(): WsSessionData | null { - if (!this._gameCode || !this.playerToken) { + if (!this._gameCode || !this.playerToken || !this.fullSessionKey) { return null; } return { gameCode: this._gameCode, playerToken: this.playerToken, + fullKey: this.fullSessionKey, serverUrl: this.serverUrl, timestamp: Date.now(), }; } + + /** Rejects missing or changed Full identities before they can be persisted. */ + private acceptFullSessionKey(key: FullSessionKey | undefined): boolean { + if (!key || key.game_code !== this._gameCode || key.generation < 1) { + const error = new AdapterError("WS_ERROR", "Server omitted a valid Full session identity", false); + this.rejectInitialization(error); + this.emit({ type: "error", message: error.message }); + return false; + } + if ( + this.fullSessionKey + && (this.fullSessionKey.game_code !== key.game_code + || this.fullSessionKey.generation !== key.generation) + ) { + const error = new AdapterError("WS_ERROR", "Server changed the Full session identity", false); + this.rejectInitialization(error); + this.emit({ type: "error", message: error.message }); + return false; + } + this.fullSessionKey = key; + return true; + } } diff --git a/client/src/components/multiplayer/ConcedeDialog.tsx b/client/src/components/multiplayer/ConcedeDialog.tsx index 05e6896786..57b941ee86 100644 --- a/client/src/components/multiplayer/ConcedeDialog.tsx +++ b/client/src/components/multiplayer/ConcedeDialog.tsx @@ -1,13 +1,26 @@ import { motion, AnimatePresence } from "framer-motion"; import { useTranslation } from "react-i18next"; +interface GameConcessionAction { + readonly kind: "game"; + readonly consequence: "ordinary-game" | "best-of-three-game"; + readonly onConfirm: () => void; +} + +interface MatchConcessionAction { + readonly kind: "match"; + readonly onConfirm: () => void; +} + interface ConcedeDialogProps { isOpen: boolean; - onConfirm: () => void; + gameAction: GameConcessionAction; + /** Present only when the active transport supports authenticated match concession. */ + matchAction?: MatchConcessionAction; onCancel: () => void; } -export function ConcedeDialog({ isOpen, onConfirm, onCancel }: ConcedeDialogProps) { +export function ConcedeDialog({ isOpen, gameAction, matchAction, onCancel }: ConcedeDialogProps) { const { t } = useTranslation("multiplayer"); return ( @@ -28,8 +41,12 @@ export function ConcedeDialog({ isOpen, onConfirm, onCancel }: ConcedeDialogProp transition={{ type: "spring", stiffness: 300, damping: 25 }} >

{t("concedeDialog.title")}

-

- {t("concedeDialog.message")} +

+ {t( + gameAction.consequence === "best-of-three-game" + ? "concedeDialog.game.matchMessage" + : "concedeDialog.game.ordinaryMessage", + )}

+ {matchAction && ( +
+

+ {t("concedeDialog.match.message")} +

+ +
+ )}
diff --git a/client/src/components/multiplayer/__tests__/ConcedeDialog.test.tsx b/client/src/components/multiplayer/__tests__/ConcedeDialog.test.tsx new file mode 100644 index 0000000000..9f9847ac1f --- /dev/null +++ b/client/src/components/multiplayer/__tests__/ConcedeDialog.test.tsx @@ -0,0 +1,55 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ConcedeDialog } from "../ConcedeDialog"; + +describe("ConcedeDialog", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("keeps ordinary-game and whole-match concessions distinct", () => { + const concedeGame = vi.fn(); + const concedeMatch = vi.fn(); + render( + , + ); + + expect( + screen.getByText("You concede only the current game. If the match is not decided, sideboarding or the next game follows."), + ).toBeInTheDocument(); + expect( + screen.getByText("You concede the match. Your opponent wins the match and no further games will be played."), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Concede this game" })); + expect(concedeGame).toHaveBeenCalledOnce(); + expect(concedeMatch).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Concede entire match" })); + expect(concedeMatch).toHaveBeenCalledOnce(); + }); + + it("does not expose the match action without the capability", () => { + render( + , + ); + + expect(screen.getByText("Your opponent wins this game.")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Concede entire match" })).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/hooks/__tests__/useConcedeHandler.test.tsx b/client/src/hooks/__tests__/useConcedeHandler.test.tsx index a4626f3997..db9fa9fc72 100644 --- a/client/src/hooks/__tests__/useConcedeHandler.test.tsx +++ b/client/src/hooks/__tests__/useConcedeHandler.test.tsx @@ -19,9 +19,9 @@ const dispatchMock = vi.fn(); const clearGameMock = vi.fn().mockResolvedValue(undefined); const clearPromptOverlayStateMock = vi.fn(); const recordMatchResultMock = vi.fn(); -const reportActiveMatchConcessionMock = vi.fn(); -const sendConcedeMock = vi.fn(); +const sendMatchConcedeMock = vi.fn(); const navigateMock = vi.fn(); +let adapterForTest: unknown = { supportsMatchConcede: true, sendMatchConcede: sendMatchConcedeMock }; vi.mock("../../game/sessionCleanup", () => ({ clearPromptOverlayState: () => clearPromptOverlayStateMock(), @@ -31,7 +31,7 @@ vi.mock("../../stores/gameStore", () => ({ useGameStore: { getState: () => ({ dispatch: dispatchMock, - adapter: { sendConcede: sendConcedeMock }, + adapter: adapterForTest, }), }, clearGame: (...args: unknown[]) => clearGameMock(...args), @@ -45,14 +45,6 @@ vi.mock("../../stores/draftStore", () => ({ }, })); -vi.mock("../../stores/multiplayerDraftStore", () => ({ - useMultiplayerDraftStore: { - getState: () => ({ - reportActiveMatchConcession: reportActiveMatchConcessionMock, - }), - }, -})); - vi.mock("react-router", async () => { const actual = await vi.importActual("react-router"); return { @@ -73,13 +65,12 @@ beforeEach(() => { clearGameMock.mockResolvedValue(undefined); clearPromptOverlayStateMock.mockReset(); recordMatchResultMock.mockReset(); - reportActiveMatchConcessionMock.mockReset(); - sendConcedeMock.mockReset(); + sendMatchConcedeMock.mockReset(); + adapterForTest = { supportsMatchConcede: true, sendMatchConcede: sendMatchConcedeMock }; navigateMock.mockReset(); dispatchMock.mockResolvedValue([]); recordMatchResultMock.mockResolvedValue(undefined); - reportActiveMatchConcessionMock.mockResolvedValue(undefined); }); afterEach(() => { @@ -151,7 +142,7 @@ describe("useConcedeHandler", () => { expect(dispatchMock).not.toHaveBeenCalled(); }); - it("isDraftPodMatch branch fires adapter sendConcede + concession report + clear + navigate", async () => { + it("isDraftPodMatch branch uses only the bound whole-match capability", async () => { const { result } = renderHook( () => useConcedeHandler({ @@ -165,87 +156,18 @@ describe("useConcedeHandler", () => { await act(async () => { result.current(); - // Hook chains: sendPromise -> .catch -> .then(report) -> .then(clear+nav). - // Four microtask flushes cover the whole chain. - await Promise.resolve(); - await Promise.resolve(); await Promise.resolve(); - await Promise.resolve(); - }); - - expect(sendConcedeMock).toHaveBeenCalledTimes(1); - expect(reportActiveMatchConcessionMock).toHaveBeenCalledTimes(1); - expect(clearGameMock).toHaveBeenCalledWith("g1"); - expect(navigateMock).toHaveBeenCalledWith("/draft-pod"); - expect(dispatchMock).not.toHaveBeenCalled(); - - // Regression coverage for PR #1252 review: sendConcede must complete - // BEFORE reportActiveMatchConcession + clearGame + navigate. Without - // the chained await on the host-side async sendConcede (which fans - // out player_conceded to every guest's PeerJS data channel), tearing - // down the adapter mid-fan-out drops peer notifications. - const sendOrder = sendConcedeMock.mock.invocationCallOrder[0]; - const reportOrder = reportActiveMatchConcessionMock.mock.invocationCallOrder[0]; - const clearOrder = clearGameMock.mock.invocationCallOrder[0]; - expect(sendOrder).toBeLessThan(reportOrder); - expect(reportOrder).toBeLessThan(clearOrder); - }); - - it("isDraftPodMatch branch awaits async sendConcede before reporting concession (race fix)", async () => { - // Discriminating regression: the host-side sendConcede returns a - // Promise (it awaits engine concedePlayer then broadcasts to guests). - // A fire-and-forget call would invoke reportActiveMatchConcession - // synchronously after sendConcede returns its pending promise — this - // test gates on the unresolved promise to catch that regression. - let releaseSend: () => void = () => {}; - const sendPending = new Promise((resolve) => { - releaseSend = resolve; }); - sendConcedeMock.mockReturnValueOnce(sendPending); - const { result } = renderHook( - () => - useConcedeHandler({ - gameId: "g1", - isOnlineMode: false, - isDraft: false, - isDraftPodMatch: true, - }), - { wrapper }, - ); - - await act(async () => { - result.current(); - await Promise.resolve(); - await Promise.resolve(); - }); - - // sendConcede has been called but its promise is still pending — - // downstream chain must not have run. - expect(sendConcedeMock).toHaveBeenCalledTimes(1); - expect(reportActiveMatchConcessionMock).not.toHaveBeenCalled(); + expect(sendMatchConcedeMock).toHaveBeenCalledTimes(1); expect(clearGameMock).not.toHaveBeenCalled(); expect(navigateMock).not.toHaveBeenCalled(); - - // Releasing the send promise lets the chain proceed. - await act(async () => { - releaseSend(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(reportActiveMatchConcessionMock).toHaveBeenCalledTimes(1); - expect(clearGameMock).toHaveBeenCalledWith("g1"); - expect(navigateMock).toHaveBeenCalledWith("/draft-pod"); + expect(dispatchMock).not.toHaveBeenCalled(); }); - it("isDraftPodMatch branch still navigates if reportActiveMatchConcession rejects", async () => { - // User intent on Concede is to leave — a store-mutation failure - // must not strand them on the conceded screen. - reportActiveMatchConcessionMock.mockRejectedValueOnce(new Error("store failed")); + it("refuses an unbound draft pod concession without falling through to the game engine", async () => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - + adapterForTest = { sendConcede: vi.fn() }; const { result } = renderHook( () => useConcedeHandler({ @@ -260,15 +182,13 @@ describe("useConcedeHandler", () => { await act(async () => { result.current(); await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); }); - expect(clearGameMock).toHaveBeenCalledWith("g1"); - expect(navigateMock).toHaveBeenCalledWith("/draft-pod"); - expect(consoleErrorSpy).toHaveBeenCalled(); - + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[useConcedeHandler] refused unbound draft pod match concession", + ); + expect(clearGameMock).not.toHaveBeenCalled(); + expect(navigateMock).not.toHaveBeenCalled(); consoleErrorSpy.mockRestore(); }); }); diff --git a/client/src/hooks/useConcedeHandler.ts b/client/src/hooks/useConcedeHandler.ts index 22f52e913a..89ec7d1bbd 100644 --- a/client/src/hooks/useConcedeHandler.ts +++ b/client/src/hooks/useConcedeHandler.ts @@ -4,7 +4,7 @@ import { useNavigate } from "react-router"; import { clearPromptOverlayState } from "../game/sessionCleanup"; import { clearGame, useGameStore } from "../stores/gameStore"; import { useDraftStore } from "../stores/draftStore"; -import { useMultiplayerDraftStore } from "../stores/multiplayerDraftStore"; +import { supportsMatchConcede } from "../adapter/types"; import { getPlayerId } from "./usePlayerId"; export interface ConcedeHandlerOptions { @@ -40,8 +40,9 @@ export interface ConcedeHandlerOptions { * * Branches (priority order): * 1. `isDraft` — quick-draft single-match concede. - * 2. `isDraftPodMatch` — pod-match concede (P2P adapter sendConcede + - * multiplayer draft store concession report). + * 2. `isDraftPodMatch` — only a transport-installed whole-match capability + * may settle a pod match. It never dispatches a game-level Concede or + * writes a raw draft result from this UI path. * 3. Default — AI / local / p2p-host / p2p-join: dispatch `Concede` to the * engine, then clear local state and navigate home. * @@ -71,33 +72,12 @@ export function useConcedeHandler({ } if (isDraftPodMatch) { - const adapter = useGameStore.getState().adapter as { - sendConcede?: () => void | Promise; - } | null; - // Host's sendConcede (p2p-adapter.ts:1062) is async — it awaits - // concedePlayer (engine dispatch) then fans out player_conceded to - // every guest's PeerJS data channel. Guest/WS versions are sync - // void-returners; Promise.resolve() normalizes both shapes so we - // serialize the chain and never tear down the adapter mid-fan-out. - const sendPromise = adapter?.sendConcede - ? Promise.resolve(adapter.sendConcede()) - : Promise.resolve(); - void sendPromise - .catch((err) => { - console.error("[useConcedeHandler] sendConcede failed:", err); - }) - .then(() => useMultiplayerDraftStore.getState().reportActiveMatchConcession()) - .then(() => { - clearGame(gameId); - navigate("/draft-pod"); - }) - .catch((err) => { - // User intent is to leave — strand them on the conceded screen - // is a worse outcome than logging the store-mutation failure. - console.error("[useConcedeHandler] failed to report draft pod concession:", err); - clearGame(gameId); - navigate("/draft-pod"); - }); + const adapter = useGameStore.getState().adapter; + if (supportsMatchConcede(adapter)) { + adapter.sendMatchConcede(); + } else { + console.error("[useConcedeHandler] refused unbound draft pod match concession"); + } return; } diff --git a/client/src/i18n/locales/de/multiplayer.json b/client/src/i18n/locales/de/multiplayer.json index 6d70fc63e0..e7f1035f6f 100644 --- a/client/src/i18n/locales/de/multiplayer.json +++ b/client/src/i18n/locales/de/multiplayer.json @@ -1,8 +1,15 @@ { "concedeDialog": { "title": "Spiel aufgeben?", - "message": "Dein Gegner wird zum Sieger erklärt.", - "concede": "Aufgeben" + "game": { + "label": "Dieses Spiel aufgeben", + "ordinaryMessage": "Dein Gegner gewinnt dieses Spiel.", + "matchMessage": "Du gibst nur das aktuelle Spiel auf. Ist das Match noch nicht entschieden, folgt das Sideboarden oder das nächste Spiel." + }, + "match": { + "label": "Gesamtes Match aufgeben", + "message": "Du gibst das Match auf. Dein Gegner gewinnt das Match und es werden keine weiteren Spiele gespielt." + } }, "takebackDialog": { "requestedTitle": "{{name}} beantragt eine Rücknahme", diff --git a/client/src/i18n/locales/en/multiplayer.json b/client/src/i18n/locales/en/multiplayer.json index bb1e6bd5c7..fd9870eb9d 100644 --- a/client/src/i18n/locales/en/multiplayer.json +++ b/client/src/i18n/locales/en/multiplayer.json @@ -1,8 +1,15 @@ { "concedeDialog": { "title": "Concede Game?", - "message": "Your opponent will be declared the winner.", - "concede": "Concede" + "game": { + "label": "Concede this game", + "ordinaryMessage": "Your opponent wins this game.", + "matchMessage": "You concede only the current game. If the match is not decided, sideboarding or the next game follows." + }, + "match": { + "label": "Concede entire match", + "message": "You concede the match. Your opponent wins the match and no further games will be played." + } }, "takebackDialog": { "requestedTitle": "{{name}} requests a takeback", diff --git a/client/src/i18n/locales/es/multiplayer.json b/client/src/i18n/locales/es/multiplayer.json index e27914fac1..aa881eda47 100644 --- a/client/src/i18n/locales/es/multiplayer.json +++ b/client/src/i18n/locales/es/multiplayer.json @@ -1,8 +1,15 @@ { "concedeDialog": { "title": "¿Conceder la partida?", - "message": "Tu oponente será declarado ganador.", - "concede": "Conceder" + "game": { + "label": "Conceder esta partida", + "ordinaryMessage": "Tu oponente gana esta partida.", + "matchMessage": "Concedes solo la partida actual. Si el match no está decidido, seguirá el banquillo o la siguiente partida." + }, + "match": { + "label": "Conceder todo el match", + "message": "Concedes el match. Tu oponente gana el match y no se jugarán más partidas." + } }, "takebackDialog": { "requestedTitle": "{{name}} solicita una repetición", diff --git a/client/src/i18n/locales/fr/multiplayer.json b/client/src/i18n/locales/fr/multiplayer.json index 3639c235e3..74554d19e9 100644 --- a/client/src/i18n/locales/fr/multiplayer.json +++ b/client/src/i18n/locales/fr/multiplayer.json @@ -1,8 +1,15 @@ { "concedeDialog": { "title": "Concéder la partie ?", - "message": "Votre adversaire sera déclaré vainqueur.", - "concede": "Concéder" + "game": { + "label": "Concéder cette partie", + "ordinaryMessage": "Votre adversaire gagne cette partie.", + "matchMessage": "Vous concédez seulement la partie en cours. Si le match n'est pas décidé, le sideboard ou la partie suivante suivra." + }, + "match": { + "label": "Concéder tout le match", + "message": "Vous concédez le match. Votre adversaire gagne le match et aucune autre partie ne sera jouée." + } }, "takebackDialog": { "requestedTitle": "{{name}} demande une reprise", diff --git a/client/src/i18n/locales/it/multiplayer.json b/client/src/i18n/locales/it/multiplayer.json index 8cb3ba2a1f..814049c602 100644 --- a/client/src/i18n/locales/it/multiplayer.json +++ b/client/src/i18n/locales/it/multiplayer.json @@ -1,8 +1,15 @@ { "concedeDialog": { "title": "Concedere la partita?", - "message": "Il tuo avversario sarà dichiarato vincitore.", - "concede": "Concedi" + "game": { + "label": "Concedi questa partita", + "ordinaryMessage": "Il tuo avversario vince questa partita.", + "matchMessage": "Concedi solo la partita attuale. Se il match non è deciso, seguiranno sideboard o partita successiva." + }, + "match": { + "label": "Concedi l'intero match", + "message": "Concedi il match. Il tuo avversario vince il match e non verranno giocate altre partite." + } }, "takebackDialog": { "requestedTitle": "{{name}} richiede un ripristino", diff --git a/client/src/i18n/locales/pl/multiplayer.json b/client/src/i18n/locales/pl/multiplayer.json index a34e6ed82b..ea7887bc26 100644 --- a/client/src/i18n/locales/pl/multiplayer.json +++ b/client/src/i18n/locales/pl/multiplayer.json @@ -1,8 +1,15 @@ { "concedeDialog": { "title": "Poddać grę?", - "message": "Twój przeciwnik zostanie ogłoszony zwycięzcą.", - "concede": "Poddaj się" + "game": { + "label": "Poddaj tę grę", + "ordinaryMessage": "Twój przeciwnik wygrywa tę grę.", + "matchMessage": "Poddajesz tylko bieżącą grę. Jeśli mecz nie jest rozstrzygnięty, nastąpi sideboard lub kolejna gra." + }, + "match": { + "label": "Poddaj cały mecz", + "message": "Poddajesz mecz. Twój przeciwnik wygrywa mecz i nie zostaną rozegrane dalsze gry." + } }, "takebackDialog": { "requestedTitle": "{{name}} prosi o wycofanie ruchu", diff --git a/client/src/i18n/locales/pt/multiplayer.json b/client/src/i18n/locales/pt/multiplayer.json index 031ed37745..b8dc3c4d0b 100644 --- a/client/src/i18n/locales/pt/multiplayer.json +++ b/client/src/i18n/locales/pt/multiplayer.json @@ -1,8 +1,15 @@ { "concedeDialog": { "title": "Conceder Jogo?", - "message": "Seu oponente será declarado o vencedor.", - "concede": "Conceder" + "game": { + "label": "Conceder este jogo", + "ordinaryMessage": "Seu oponente vence este jogo.", + "matchMessage": "Você concede apenas o jogo atual. Se a partida não estiver decidida, seguirá o sideboard ou o próximo jogo." + }, + "match": { + "label": "Conceder toda a partida", + "message": "Você concede a partida. Seu oponente vence a partida e não haverá mais jogos." + } }, "takebackDialog": { "requestedTitle": "{{name}} solicita um retrocesso", diff --git a/client/src/network/__tests__/draftProtocol.test.ts b/client/src/network/__tests__/draftProtocol.test.ts index 506d596b29..81145d1752 100644 --- a/client/src/network/__tests__/draftProtocol.test.ts +++ b/client/src/network/__tests__/draftProtocol.test.ts @@ -10,8 +10,8 @@ import type { DraftP2PMessage } from "../draftProtocol"; describe("draftProtocol", () => { describe("DRAFT_PROTOCOL_VERSION", () => { - it("is version 4", () => { - expect(DRAFT_PROTOCOL_VERSION).toBe(4); + it("is version 7", () => { + expect(DRAFT_PROTOCOL_VERSION).toBe(7); }); }); @@ -68,6 +68,8 @@ describe("draftProtocol", () => { "draft_kicked", "draft_pairing", "draft_match_result", + "draft_match_settlement", + "draft_match_settlement_ack", "draft_paused", "draft_resumed", "draft_lobby_update", @@ -76,7 +78,11 @@ describe("draftProtocol", () => { "draft_request_advance", "draft_match_start", "draft_bo3_sideboard_prompt", + "draft_bo3_between_games", "draft_bo3_sideboard_submit", + "draft_bo3_intergame_command", + "draft_bo3_intergame_authorized", + "draft_bo3_intergame_receipt", "draft_bo3_play_draw_prompt", "draft_bo3_play_draw_choice", "draft_bo3_game_start", @@ -158,6 +164,16 @@ describe("draftProtocol", () => { ai_decks: [], }, matchConfig: { match_type: "Bo1" }, + binding: { + podId: "draft-1", + matchId: "round-1-table-1", + round: 1, + sessionKey: "session-1", + lease: "lease-1", + nonce: "nonce-1", + revision: 0, + matchAuthoritySeat: 0, + }, }, }; diff --git a/client/src/network/__tests__/protocol.test.ts b/client/src/network/__tests__/protocol.test.ts index af7a4ad0e7..03bf4a02c1 100644 --- a/client/src/network/__tests__/protocol.test.ts +++ b/client/src/network/__tests__/protocol.test.ts @@ -36,7 +36,7 @@ const viewerInteractionWithProducedMana = { } as never; describe("encodeWireMessage / decodeWireMessage", () => { - it("pins the P2P wire protocol to v16", () => { + it("pins the P2P wire protocol to v17", () => { expect(WIRE_PROTOCOL_VERSION).toBe(17); }); @@ -75,6 +75,7 @@ describe("encodeWireMessage / decodeWireMessage", () => { { type: "ping", timestamp: 12345 }, { type: "pong", timestamp: 12345 }, { type: "concede" }, + { type: "match_concede" }, { type: "disconnect", reason: "Page closed" }, { type: "kick", reason: "Removed" }, { type: "host_left", reason: "Host left" }, @@ -251,4 +252,8 @@ describe("validateMessage", () => { it("rejects unknown type", () => { expect(() => validateMessage({ type: "nope" })).toThrow(/Invalid message type/); }); + + it("rejects raw unbound match concessions", () => { + expect(() => validateMessage({ type: "concede_match" })).toThrow(/Invalid message type/); + }); }); diff --git a/client/src/network/draftProtocol.ts b/client/src/network/draftProtocol.ts index b83cd7f7c6..ca18000256 100644 --- a/client/src/network/draftProtocol.ts +++ b/client/src/network/draftProtocol.ts @@ -20,6 +20,10 @@ import type { SeatPublicView, } from "../adapter/draft-adapter"; import type { DeckCardCount, MatchConfig, MatchScore } from "../adapter/types"; +import type { + DraftIntergameCommand, + DraftIntergameCommandAck, +} from "../services/intergameCommandLedger"; // ── Protocol Version ─────────────────────────────────────────────────── @@ -31,8 +35,11 @@ import type { DeckCardCount, MatchConfig, MatchScore } from "../adapter/types"; * 2 — add timer sync, match start, round advance messages (Phase 57) * 3 — add Bo3 sideboard and game-level result messages (Phase 58) * 4 — add deck-carrying tournament match launch descriptors + * 5 — bind match settlement to a durable pod-issued capability + * 6 — durable authorized Bo3 intergame command ledger + * 7 — forward authenticated match-host between-games observations */ -export const DRAFT_PROTOCOL_VERSION = 4 as const; +export const DRAFT_PROTOCOL_VERSION = 7 as const; /** * Typed reason for a draft pause, used over the wire and on the i18n key path. @@ -65,6 +72,28 @@ export interface DraftMatchDeckPayload { ai_decks: DraftDeckPayload[]; } +/** + * Pod-issued capability for exactly one tournament match authority. The + * random lease and nonce are intentionally opaque: a match result is valid + * only when it echoes the complete binding issued for its current round. + */ +export interface DraftMatchBinding { + podId: string; + matchId: string; + round: number; + sessionKey: string; + lease: string; + nonce: string; + revision: number; + matchAuthoritySeat: number; +} + +export interface DraftMatchSettlement { + binding: DraftMatchBinding; + receiptId: string; + winnerSeat: number | null; +} + export type DraftMatchLaunch = | { type: "HumanHost"; @@ -77,6 +106,7 @@ export type DraftMatchLaunch = matchHostPeerId: string; deckPayload: DraftMatchDeckPayload; matchConfig: MatchConfig; + binding: DraftMatchBinding; } | { type: "HumanGuest"; @@ -89,6 +119,7 @@ export type DraftMatchLaunch = matchHostPeerId: string; localDeck: DraftDeckPayload; matchConfig: MatchConfig; + binding: DraftMatchBinding; } | { type: "Bot"; @@ -99,6 +130,7 @@ export type DraftMatchLaunch = botName: string; deckPayload: DraftMatchDeckPayload; matchConfig: MatchConfig; + binding: DraftMatchBinding; }; // ── Message Types ────────────────────────────────────────────────────── @@ -188,6 +220,18 @@ export type DraftP2PMessage = matchId: string; winnerSeat: number | null; } + | { + /** Match-authority seat → pod host: authenticated result settlement. */ + type: "draft_match_settlement"; + settlement: DraftMatchSettlement; + } + | { + /** Pod host → match-authority seat: durable exact-once receipt. */ + type: "draft_match_settlement_ack"; + matchId: string; + receiptId: string; + revision: number; + } | { type: "draft_paused"; reason: DraftPauseReason; @@ -232,6 +276,14 @@ export type DraftP2PMessage = /** Sideboard timer duration in ms (0 = no timer). */ timerMs: number; } + | { + /** Match host → pod host: authenticated observation of an engine between-games state. */ + type: "draft_bo3_between_games"; + matchId: string; + gameNumber: number; + score: MatchScore; + loserSeat: number | null; + } | { /** Guest → Host: player submits their sideboarded deck for the next game. */ type: "draft_bo3_sideboard_submit"; @@ -239,6 +291,23 @@ export type DraftP2PMessage = mainDeck: string[]; sideboard: DeckCardCount[]; } + | { + /** Participant → pod: a durable, still-held intergame command. */ + type: "draft_bo3_intergame_command"; + command: DraftIntergameCommand; + } + | { + /** Pod → participant: the exact held command is now executable. */ + type: "draft_bo3_intergame_authorized"; + command: DraftIntergameCommand; + acknowledgement: DraftIntergameCommandAck; + } + | { + /** Participant → pod: the authorized command reached its local sink. */ + type: "draft_bo3_intergame_receipt"; + acknowledgement: DraftIntergameCommandAck; + receiptId: string; + } | { /** Host → Guest: prompt the loser to choose play or draw for the next game. */ type: "draft_bo3_play_draw_prompt"; @@ -293,6 +362,8 @@ const VALID_DRAFT_TYPES = new Set([ "draft_kicked", "draft_pairing", "draft_match_result", + "draft_match_settlement", + "draft_match_settlement_ack", "draft_paused", "draft_resumed", "draft_lobby_update", @@ -301,7 +372,11 @@ const VALID_DRAFT_TYPES = new Set([ "draft_request_advance", "draft_match_start", "draft_bo3_sideboard_prompt", + "draft_bo3_between_games", "draft_bo3_sideboard_submit", + "draft_bo3_intergame_command", + "draft_bo3_intergame_authorized", + "draft_bo3_intergame_receipt", "draft_bo3_play_draw_prompt", "draft_bo3_play_draw_choice", "draft_bo3_game_start", diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 4288b9f8c2..809f35e842 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -11,6 +11,18 @@ import type { } from "../adapter/types"; import type { InteractionSubmission, ViewerInteraction } from "../adapter/generated/interaction"; import type { SeatMutation, SeatView } from "../multiplayer/seatTypes"; +import type { P2PAuthorityStamp, P2PSessionKey } from "../services/p2pSession"; +import type { P2PTerminalResult } from "../services/p2pTerminalResult"; + +/** + * The stable session identity is carried on reconnect so a resumed host can + * accept a legitimate guest while replacing its previous host incarnation. + * Every host-originated frame is stamped by P2PHostAdapter; the optional + * shape keeps first-contact guest_deck messages intentionally credentialless. + */ +export interface P2PAuthorityWire { + authority?: P2PAuthorityStamp; +} /** * Wire-format projection of `LegalActionsResult`. Single source of truth for @@ -88,19 +100,23 @@ export function legalActionsFromWire(wire: LegalActionsWire): LegalActionsResult * 9 — Meld pair and attacking-entry choices after mana-payment preview variants. * 8 — Mana-payment preview request/response variants. * 7 — PrecastCopyShortcut action and its two WaitingFor variants. + * 17 — Bound draft-match concession request. A Traditional-draft guest + * asks its match authority to settle the match; it must not send a + * game-level concession through the ordinary P2P path. * 6 — Mulligan bottoming folded into a MulliganDecisionPhase::BottomCards * sub-phase on WaitingFor::MulliganDecision; the MulliganBottomCards * variant was removed */ export const WIRE_PROTOCOL_VERSION = 17 as const; -export type P2PMessage = +export type P2PMessage = P2PAuthorityWire & ( | { type: "guest_deck"; deckData: unknown; displayName?: string; reservationToken?: string } | ({ type: "game_setup"; wireProtocolVersion: typeof WIRE_PROTOCOL_VERSION; assignedPlayerId: number; playerToken: string; + revision?: number; state: GameState; events: GameEvent[]; playerNames?: Record; @@ -110,6 +126,7 @@ export type P2PMessage = | { type: "preview_mana_payment"; requestId: number; action: GameAction } | ({ type: "state_update"; + revision?: number; state: GameState; events: GameEvent[]; logEntries?: GameLogEntry[]; @@ -122,12 +139,15 @@ export type P2PMessage = | { type: "disconnect"; reason: string } | { type: "emote"; emote: string } | { type: "concede" } + /** Protected by a draft-installed match capability on the host. */ + | { type: "match_concede" } // Reconnect: guest presents prior token; host accepts (with fresh state) or rejects. - | { type: "reconnect"; playerToken: string } + | { type: "reconnect"; playerToken: string; sessionKey?: P2PSessionKey } | ({ type: "reconnect_ack"; wireProtocolVersion: typeof WIRE_PROTOCOL_VERSION; assignedPlayerId: number; + revision?: number; state: GameState; playerNames?: Record; } & LegalActionsWire) @@ -145,6 +165,10 @@ export type P2PMessage = // send this, since those cases may be transient and the reconnect loop is // correct behavior there. | { type: "host_left"; reason: string } + /** Recipient-scoped terminal commitment. It is host-originated and + * lease-bound; guests pin the first valid terminal id and never reconnect + * after accepting the commitment for their filtered state. */ + | { type: "terminal_result"; result: P2PTerminalResult } // Lifecycle broadcasts (host → all remaining peers). | { type: "player_kicked"; playerId: number; reason: string } // Host chose "continue without them" OR guest self-conceded mid-game. Wire @@ -158,7 +182,8 @@ export type P2PMessage = // Pre-game lobby progress (host → all peers in the lobby). | { type: "lobby_progress"; joined: number; total: number } | { type: "seat_mutate"; mutation: SeatMutation } - | { type: "seat_snapshot"; view: SeatView }; + | { type: "seat_snapshot"; view: SeatView } +); const VALID_TYPES = new Set([ "guest_deck", @@ -175,11 +200,13 @@ const VALID_TYPES = new Set([ "disconnect", "emote", "concede", + "match_concede", "reconnect", "reconnect_ack", "reconnect_rejected", "kick", "host_left", + "terminal_result", "player_kicked", "player_conceded", "player_disconnected", diff --git a/client/src/pages/DraftPodPage.tsx b/client/src/pages/DraftPodPage.tsx index 047dd25146..18e304ab0d 100644 --- a/client/src/pages/DraftPodPage.tsx +++ b/client/src/pages/DraftPodPage.tsx @@ -564,6 +564,11 @@ function BetweenGamesView() {

{t("betweenGames.waitingSideboard")}

+ {submittedDeck.length > 0 && ( +

+ {submittedDeck.join(", ")} +

+ )}
); diff --git a/client/src/pages/GamePage.tsx b/client/src/pages/GamePage.tsx index 37a9084642..37c50208e2 100644 --- a/client/src/pages/GamePage.tsx +++ b/client/src/pages/GamePage.tsx @@ -19,6 +19,7 @@ import type { ObjectId, SerializedAbilityCost, } from "../adapter/types"; +import { supportsMatchConcede } from "../adapter/types"; import type { InteractionManaRestriction, InteractionPresentationSurface, @@ -347,6 +348,7 @@ export function GamePage() { {}, ); const [gameStartedAt, setGameStartedAt] = useState(null); + const [terminalReason, setTerminalReason] = useState(null); const hasConcededRef = useRef(false); // GH #1507: "request takeback" — the table-wide pending request, if any. const [pendingTakeback, setPendingTakeback] = useState< @@ -423,6 +425,17 @@ export function GamePage() { waitingFor: { type: "GameOver", data: { winner: event.winner } }, }); break; + case "terminalDelivery": + clearPromptOverlayState(); + if (gameId) clearGame(gameId); + setTerminalReason(event.delivery.display.reason); + useGameStore.setState({ + waitingFor: { type: "GameOver", data: { winner: event.delivery.display.winner } }, + }); + break; + case "terminalUnavailable": + useMultiplayerStore.getState().showToast(event.message); + break; case "emoteReceived": setReceivedEmote(event.emote); if (receivedEmoteTimerRef.current) @@ -640,6 +653,17 @@ export function GamePage() { waitingFor: { type: "GameOver", data: { winner: event.winner } }, }); break; + case "terminalResult": + clearPromptOverlayState(); + if (gameId) void clearGame(gameId); + setTerminalReason(event.result.display.reason); + useGameStore.setState({ + waitingFor: { type: "GameOver", data: { winner: event.result.display.winner } }, + }); + break; + case "terminalUnavailable": + useMultiplayerStore.getState().showToast(event.message); + break; case "deckRejected": navigate("/multiplayer", { state: { @@ -751,6 +775,7 @@ export function GamePage() { receivedEmote={receivedEmote} timerRemaining={timerRemaining} gameStartedAt={gameStartedAt} + terminalReason={terminalReason} pendingTakeback={pendingTakeback} onCloseTakebackDialog={() => setPendingTakeback(null)} disconnectChoice={disconnectChoice} @@ -788,6 +813,7 @@ interface GamePageContentProps { receivedEmote: string | null; timerRemaining: Record; gameStartedAt: number | null; + terminalReason: string | null; pendingTakeback: { requester: number; requesterName: string } | null; onCloseTakebackDialog: () => void; // 3-4p P2P additions @@ -819,6 +845,7 @@ function GamePageContent({ receivedEmote, timerRemaining, gameStartedAt, + terminalReason, pendingTakeback, onCloseTakebackDialog, disconnectChoice, @@ -840,6 +867,9 @@ function GamePageContent({ const focusedGridTemplateRows = useResolvedGridRows(); const splitGridTemplateRows = useResolvedSplitGridRows(); const gameState = useGameStore((s) => s.gameState); + const isBestOfThree = gameState?.match_config?.match_type === "Bo3"; + const draftMatchPairing = useMultiplayerDraftStore((s) => s.matchPairing); + const submitIntergameCommand = useMultiplayerDraftStore((s) => s.submitIntergameCommand); const objects = useGameStore((s) => s.gameState?.objects); const legalActionsByObject = useGameStore((s) => s.legalActionsByObject); const turnNumber = useGameStore((s) => s.gameState?.turn_number); @@ -985,6 +1015,13 @@ function GamePageContent({ onHideConcedeDialog(); }, [adapter, onHideConcedeDialog]); + const handleMatchConcede = useCallback(() => { + if (supportsMatchConcede(adapter)) { + adapter.sendMatchConcede(); + } + onHideConcedeDialog(); + }, [adapter, onHideConcedeDialog]); + const handleSendEmote = useCallback( (emote: string) => { if (adapter && adapter instanceof WebSocketAdapter) { @@ -1173,22 +1210,30 @@ function GamePageContent({ const handleSubmitSideboard = useCallback( (main: DeckCardCount[], sideboard: DeckCardCount[]) => { + if (draftMatchPairing?.matchConfig.match_type === "Bo3") { + void submitIntergameCommand({ type: "SubmitSideboard", main, sideboard }); + return; + } dispatch({ type: "SubmitSideboard", data: { main, sideboard }, }); }, - [dispatch], + [dispatch, draftMatchPairing, submitIntergameCommand], ); const handleChoosePlayDraw = useCallback( (playFirst: boolean) => { + if (draftMatchPairing?.matchConfig.match_type === "Bo3") { + void submitIntergameCommand({ type: "ChoosePlayDraw", playFirst }); + return; + } dispatch({ type: "ChoosePlayDraw", data: { play_first: playFirst }, }); }, - [dispatch], + [dispatch, draftMatchPairing, submitIntergameCommand], ); @@ -2035,7 +2080,16 @@ function GamePageContent({ <> )} @@ -2645,11 +2700,13 @@ function GameOverScreen({ mode, isOnlineMode = false, gameStartedAt, + terminalReason, }: { winner: number | null; mode: string | null; isOnlineMode?: boolean; gameStartedAt?: number | null; + terminalReason?: string | null; }) { const { t } = useTranslation("game"); const navigate = useNavigate(); @@ -2774,6 +2831,7 @@ function GameOverScreen({ animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }} > + {terminalReason &&

{terminalReason}

}

({ + draftState: { + phase: "betweenGames", + sideboardPrompt: { + matchId: "bo3-1", + gameNumber: 2, + score: { p0_wins: 1, p1_wins: 0, draws: 0 }, + loserSeat: 1, + timerMs: 60_000, + }, + playDrawPrompt: null, + sideboardSubmitted: false, + seatIndex: 0, + timerRemainingMs: 60_000, + mainDeck: ["Plains", "Island"], + submittedDeck: ["Plains", "Island"], + submitSideboard: vi.fn(), + choosePlayDraw: vi.fn(), + leave: vi.fn(), + }, +})); + +vi.mock("../../stores/multiplayerDraftStore", () => ({ + useMultiplayerDraftStore: (selector: (state: typeof draftState) => unknown) => selector(draftState), +})); + +vi.mock("../../stores/draftPodStore", () => ({ + useDraftPodStore: (selector: (state: { reset: () => void; resumeHostedPod: () => void }) => unknown) => selector({ + reset: vi.fn(), + resumeHostedPod: vi.fn(), + }), +})); + +vi.mock("../../components/chrome/ScreenChrome", () => ({ ScreenChrome: () => null })); +vi.mock("../../components/menu/MenuShell", () => ({ MenuShell: ({ children }: { children: ReactNode }) => <>{children} })); +vi.mock("../../components/draft/HostControls", () => ({ HostControls: () => null })); +vi.mock("../../components/draft/LimitedDeckBuilder", () => ({ LimitedDeckBuilder: () =>

})); +vi.mock("../../components/draft/ScoreBadge", () => ({ ScoreBadge: () =>
})); + +function renderPage() { + return render(); +} describe("DraftPodPage betweenGames", () => { - describe("BO3-09: sideboard UI rendering", () => { - it.todo( - "renders sideboard UI when sideboardPrompt is set and not yet submitted", - ); + afterEach(cleanup); + + beforeEach(() => { + draftState.sideboardPrompt = { + matchId: "bo3-1", + gameNumber: 2, + score: { p0_wins: 1, p1_wins: 0, draws: 0 }, + loserSeat: 1, + timerMs: 60_000, + }; + draftState.playDrawPrompt = null; + draftState.sideboardSubmitted = false; + draftState.submittedDeck = ["Plains", "Island"]; + draftState.submitSideboard.mockClear(); + }); + + it("renders the live sideboard prompt and submits its current deck through the store authority", async () => { + const user = userEvent.setup(); + renderPage(); + + expect(screen.getByRole("heading", { name: "Sideboard — Game 2" })).toBeInTheDocument(); + expect(screen.getByTestId("limited-deck-builder")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Submit Sideboard" })); + + expect(draftState.submitSideboard).toHaveBeenCalledWith("bo3-1", ["Plains", "Island"], []); }); - describe("BO3-10: waiting-for-opponent view", () => { - it.todo("shows waiting-for-opponent view after sideboard submission"); - it.todo("displays submitted deck in read-only view"); + it("shows the submitted deck read-only while waiting for the opponent", () => { + draftState.sideboardSubmitted = true; + renderPage(); + + expect(screen.getByText("Waiting for opponent to submit sideboard...")).toBeInTheDocument(); + expect(screen.getByText("Plains, Island")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Submit Sideboard" })).toBeNull(); }); }); diff --git a/client/src/pages/__tests__/GamePage.bracketViolation.test.tsx b/client/src/pages/__tests__/GamePage.bracketViolation.test.tsx index 5dd1ffcebd..4b0225ab5f 100644 --- a/client/src/pages/__tests__/GamePage.bracketViolation.test.tsx +++ b/client/src/pages/__tests__/GamePage.bracketViolation.test.tsx @@ -35,7 +35,7 @@ const { mockClearPromptOverlayState, mockSetGameState, storeOverrides } = vi.hoi // Mutable slice of the mocked game store. Defaults match the previous // hardcoded values, so every pre-existing test is unaffected; tests that // need a live adapter assign here and `beforeEach` resets. - storeOverrides: { adapter: null as unknown }, + storeOverrides: { adapter: null as unknown, gameState: null as unknown, waitingFor: null as unknown }, })); // Captures the props GameMenu was rendered with, so tests can assert which @@ -126,8 +126,8 @@ vi.mock("../../stores/gameStore", () => ({ useGameStore: Object.assign( vi.fn((selector: (s: Record) => unknown) => selector({ - gameState: null, - waitingFor: null, + gameState: storeOverrides.gameState, + waitingFor: storeOverrides.waitingFor, legalActions: [], autoPassRecommended: false, spellCosts: {}, @@ -243,6 +243,14 @@ vi.mock("../../components/chrome/GameMenu", () => ({ }, })); +let capturedConcedeDialogProps: Record | undefined; +vi.mock("../../components/multiplayer/ConcedeDialog", () => ({ + ConcedeDialog: (props: Record) => { + capturedConcedeDialogProps = props; + return null; + }, +})); + vi.mock("../../hooks/useCardDataMeta", () => ({ useCardDataMeta: () => null, formatRelativeDate: () => "", @@ -271,6 +279,9 @@ beforeEach(() => { capturedOnP2PEvent = undefined; capturedGameMenuProps = undefined; storeOverrides.adapter = null; + storeOverrides.gameState = null; + storeOverrides.waitingFor = null; + capturedConcedeDialogProps = undefined; vi.clearAllMocks(); }); @@ -634,3 +645,54 @@ describe("GamePage — takeback is a transport capability", () => { expect(capturedGameMenuProps?.onRequestTakeback).toBeTypeOf("function"); }); }); + +describe("GamePage — bound whole-match concession", () => { + class FakeWebSocketAdapter extends WebSocketAdapter { + sendMatchConcede = vi.fn(); + + constructor() { + super("ws://test/ws", "host", { main_deck: [], sideboard: [] }); + } + } + + it("offers and invokes the WebSocket whole-match capability for a Bo3", () => { + const sendMatchConcede = vi.fn(); + const adapter = new FakeWebSocketAdapter(); + adapter.sendMatchConcede = sendMatchConcede; + storeOverrides.adapter = adapter; + storeOverrides.gameState = { + match_config: { match_type: "Bo3" }, + waiting_for: { + type: "BetweenGamesChoosePlayDraw", + data: { + player: 0, + game_number: 2, + score: { p0_wins: 1, p1_wins: 0, draws: 0 }, + }, + }, + players: [], + objects: {}, + battlefield: [], + stack: [], + exile: [], + }; + storeOverrides.waitingFor = { + type: "BetweenGamesChoosePlayDraw", + data: { + player: 0, + game_number: 2, + score: { p0_wins: 1, p1_wins: 0, draws: 0 }, + }, + }; + + renderGamePage("/game/test-game-123?mode=host"); + act(() => (capturedGameMenuProps?.onConcede as () => void)()); + + const matchAction = capturedConcedeDialogProps?.matchAction as + | { onConfirm: () => void } + | undefined; + expect(matchAction?.onConfirm).toBeTypeOf("function"); + act(() => matchAction?.onConfirm()); + expect(sendMatchConcede).toHaveBeenCalledOnce(); + }); +}); diff --git a/client/src/providers/GameProvider.tsx b/client/src/providers/GameProvider.tsx index 460c164d70..0a948b9acc 100644 --- a/client/src/providers/GameProvider.tsx +++ b/client/src/providers/GameProvider.tsx @@ -16,6 +16,9 @@ import { WasmAdapter, getSharedAdapter } from "../adapter/wasm-adapter"; import { NativeEngineVersionMismatchError, WebSocketAdapter, + acknowledgeFullTerminalDelivery, + bootstrapFullTerminalDelivery, + readFullTerminalResult, } from "../adapter/ws-adapter"; import { audioManager } from "../audio/AudioManager"; import type { DeckData, NativeAiSeat, WsAdapterEvent } from "../adapter/ws-adapter"; @@ -39,12 +42,19 @@ import { useGameplayPreferencesSync } from "../hooks/useGameplayPreferencesSync" import { hostRoom, joinRoom } from "../network/connection"; import type { BrokerClient } from "../services/brokerClient"; import { loadP2PSession } from "../services/p2pSession"; +import { loadP2PTerminalResult } from "../services/p2pTerminalResult"; import { expandParsedDeck, type ExpandedDeck, type ParsedDeck } from "../services/deckParser"; import { formatSuppliesDeck } from "../data/formatRegistry"; import { consumeRecentAutoUpdateMarker } from "../pwa/updateMarker"; import { loadDraftRun } from "../services/quickDraftPersistence"; import { SPECTATOR_PLAYER_ID } from "../constants/game"; import { clearWsSession, loadWsSession, saveWsSession } from "../services/multiplayerSession"; +import { + commitFullTerminalDelivery, + loadFullTerminalDelivery, + replaceFullTerminalDelivery, + type FullTerminalDelivery, +} from "../services/fullTerminalResult"; import { detectServerUrl } from "../services/serverDetection"; import { canAttemptNativeEngine, @@ -802,6 +812,15 @@ export function GameProvider({ ]); signal.throwIfAborted(); + if (savedSession) { + const terminal = await loadP2PTerminalResult(savedSession.sessionKey); + signal.throwIfAborted(); + if (terminal) { + onP2PEventRef.current?.({ type: "terminalResult", result: terminal }); + return; + } + } + const isNativeResume = savedSession?.nativeSession !== undefined; const isWasmResume = !isNativeResume @@ -950,16 +969,7 @@ export function GameProvider({ } else { // p2p-join const code = joinCode!; - const { conn, peer } = await joinRoom(code, signal, 10_000); - hostPeerHandle = peer; - signal.throwIfAborted(); - // Two deliberately-decoupled identifiers: - // - dial target: `conn.peer` — the *actual* host peer id we just - // connected to (= `phase2-`). Auto-reconnect re-dials - // this, so it must be the live id the host registered under; - // reconstructing a literal prefix here is how the dial silently - // broke after the PEER_ID_PREFIX bump. // - sessionKey: the IndexedDB key for the persisted reconnect // token, held on the legacy `phase-` prefix so tokens saved // before the bump still resolve. IndexedDB (not sessionStorage) @@ -968,6 +978,19 @@ export function GameProvider({ const sessionKey = `phase-${code}`; const existing = await loadP2PSession(sessionKey); signal.throwIfAborted(); + if (existing) { + const terminal = await loadP2PTerminalResult(existing.authority.sessionKey); + signal.throwIfAborted(); + if (terminal) { + onP2PEventRef.current?.({ type: "terminalResult", result: terminal }); + return; + } + } + // Dial target: `conn.peer` is the actual current host peer id; + // reconnect reuses it rather than reconstructing a prefix. + const { conn, peer } = await joinRoom(code, signal, 10_000); + hostPeerHandle = peer; + signal.throwIfAborted(); const adapter = new P2PGuestAdapter( deckList, peer, @@ -977,6 +1000,7 @@ export function GameProvider({ useMultiplayerStore.getState().displayName || undefined, undefined, sessionKey, + existing?.authority, ); p2pAdapter = adapter; hostPeerHandle = null; @@ -1093,6 +1117,85 @@ export function GameProvider({ // Use smart server detection for initial connection const setupWs = async () => { if (cancelled) return; + const reconnectSession = isReconnect ? loadWsSession() : null; + if (reconnectSession) { + const terminalDelivery = await loadFullTerminalDelivery(reconnectSession.fullKey); + if (cancelled) return; + + if (terminalDelivery) { + // A retained terminal capability is read only through the small raw + // socket helper. This branch intentionally returns before any + // playable adapter, controller, deck, or reconnect setup exists. + try { + const refreshed = await readFullTerminalResult( + reconnectSession.serverUrl, + terminalDelivery.credential, + ); + if (cancelled) return; + if (refreshed && !(await replaceFullTerminalDelivery(refreshed))) { + throw new Error("Failed to retain terminal delivery"); + } + const display = refreshed ?? terminalDelivery; + void acknowledgeFullTerminalDelivery( + reconnectSession.serverUrl, + display.delivery_id, + display.credential, + ).catch(() => {}); + clearWsSession(); + onWsEventRef.current?.({ + type: "terminalDelivery", + delivery: display, + }); + } catch (error) { + if (!cancelled) { + useMultiplayerStore.getState().setConnectionStatus("disconnected"); + onWsEventRef.current?.({ + type: "terminalUnavailable", + message: error instanceof Error ? error.message : "Terminal result is unavailable", + }); + } + } + return; + } + + let bootstrap: FullTerminalDelivery | null; + try { + bootstrap = await bootstrapFullTerminalDelivery( + reconnectSession.serverUrl, + reconnectSession.fullKey, + reconnectSession.playerToken, + crypto.randomUUID(), + ); + } catch (error) { + if (!cancelled) { + useMultiplayerStore.getState().setConnectionStatus("disconnected"); + onWsEventRef.current?.({ + type: "terminalUnavailable", + message: error instanceof Error ? error.message : "Terminal result is unavailable", + }); + } + return; + } + if (cancelled) return; + if (bootstrap) { + if (!(await commitFullTerminalDelivery(bootstrap))) { + useMultiplayerStore.getState().setConnectionStatus("disconnected"); + onWsEventRef.current?.({ + type: "terminalUnavailable", + message: "Failed to retain terminal delivery", + }); + return; + } + void acknowledgeFullTerminalDelivery( + reconnectSession.serverUrl, + bootstrap.delivery_id, + bootstrap.credential, + ).catch(() => {}); + clearWsSession(); + onWsEventRef.current?.({ type: "terminalDelivery", delivery: bootstrap }); + return; + } + } const serverUrl = import.meta.env.VITE_WS_URL ?? await detectServerUrl(); if (cancelled) return; @@ -1551,6 +1654,7 @@ export function GameProvider({ gameCode: nativeResume.gameCode, playerId: nativeResume.playerId, playerToken: nativeResume.playerToken, + fullKey: nativeResume.fullKey, socketFactory: () => new NativeEngineSocket(), expectedServerVersion, }, diff --git a/client/src/providers/__tests__/GameProvider.nativeEngine.test.tsx b/client/src/providers/__tests__/GameProvider.nativeEngine.test.tsx index 37e33da3ce..39a30e1d89 100644 --- a/client/src/providers/__tests__/GameProvider.nativeEngine.test.tsx +++ b/client/src/providers/__tests__/GameProvider.nativeEngine.test.tsx @@ -428,7 +428,7 @@ describe("GameProvider native AI routing", () => { id: "native-resume-read", mode: "ai", difficulty: "Medium", - nativeSession: { gameCode: "GAME-XYZ", playerId: 0, playerToken: "tok-xyz" }, + nativeSession: { gameCode: "GAME-XYZ", playerId: 0, playerToken: "tok-xyz", fullKey: { game_code: "GAME-XYZ", generation: 1 } }, }); render( @@ -464,7 +464,7 @@ describe("GameProvider native AI routing", () => { id: "native-resume-fail", mode: "ai", difficulty: "Medium", - nativeSession: { gameCode: "GONE", playerId: 0, playerToken: "tok" }, + nativeSession: { gameCode: "GONE", playerId: 0, playerToken: "tok", fullKey: { game_code: "GONE", generation: 1 } }, }); // The reconnect handshake fails (e.g. the server no longer holds the game). nativeAdapterInitialize.mockRejectedValue(new Error("Reconnect grace period expired")); diff --git a/client/src/services/__tests__/draftPersistence.test.ts b/client/src/services/__tests__/draftPersistence.test.ts index 1b1ab1412e..c93e8d177f 100644 --- a/client/src/services/__tests__/draftPersistence.test.ts +++ b/client/src/services/__tests__/draftPersistence.test.ts @@ -22,10 +22,15 @@ import { loadActiveDraftPod, loadDraftGuestSession, loadDraftHostSession, + loadDraftIntergameCommands, + loadDraftSettlementOutbox, saveActiveDraftPod, saveDraftGuestSession, saveDraftHostSession, + saveDraftIntergameCommands, + saveDraftSettlementOutbox, } from "../draftPersistence"; +import { draftIntergameDigest } from "../intergameCommandLedger"; import type { PersistedDraftHostSession } from "../draftPersistence"; describe("draftPersistence", () => { @@ -159,6 +164,34 @@ describe("draftPersistence", () => { }); }); + it("persists a held intergame command until its receipt", async () => { + const payload = { type: "ChoosePlayDraw" as const, playFirst: true }; + const command = { + commandId: "command-1", + matchId: "traditional-1", + gameNumber: 2, + seat: 1, + payload, + launchPayload: { matchId: "traditional-1", seat: 1 }, + launchDigest: draftIntergameDigest({ matchId: "traditional-1", seat: 1 }), + payloadDigest: draftIntergameDigest(payload), + status: "Pending" as const, + }; + await saveDraftIntergameCommands(command.matchId, [command]); + await expect(loadDraftIntergameCommands(command.matchId)).resolves.toEqual([command]); + }); + + it("does not replay a legacy settlement across a renewed match binding", async () => { + const binding = { + podId: "draft-1", matchId: "match-1", round: 1, sessionKey: "session-1", + lease: "lease-1", nonce: "nonce-1", revision: 0, matchAuthoritySeat: 0, + }; + await saveDraftSettlementOutbox({ binding, receiptId: "receipt-1", winnerSeat: 1 }); + + await expect(loadDraftSettlementOutbox({ ...binding, revision: 1, lease: "lease-2" })).resolves.toBeNull(); + await expect(loadDraftSettlementOutbox(binding)).resolves.toMatchObject({ receiptId: "receipt-1" }); + }); + describe("guest session", () => { it("saves and loads a guest session", async () => { await saveDraftGuestSession("phase2-HOST1", { diff --git a/client/src/services/__tests__/fullTerminalResult.test.ts b/client/src/services/__tests__/fullTerminalResult.test.ts new file mode 100644 index 0000000000..5460169ed4 --- /dev/null +++ b/client/src/services/__tests__/fullTerminalResult.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("idb-keyval", () => ({ + createStore: vi.fn(() => ({})), + del: vi.fn().mockResolvedValue(undefined), + get: vi.fn().mockResolvedValue(undefined), + set: vi.fn().mockResolvedValue(undefined), +})); + +import { get as idbGet, set as idbSet } from "idb-keyval"; +import { + commitFullTerminalDelivery, + isValidFullTerminalDelivery, + loadFullTerminalDelivery, + replaceFullTerminalDelivery, + type FullTerminalDelivery, +} from "../fullTerminalResult"; + +const delivery: FullTerminalDelivery = { + key: { game_code: "TERM01", generation: 3 }, + terminal_revision: 8, + delivery_id: "delivery-0", + credential: "credential-0", + display: { winner: 1, reason: "Match conceded" }, +}; + +describe("full terminal result persistence", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("commits a first delivery and loads it from the isolated namespace", async () => { + expect(await commitFullTerminalDelivery(delivery)).toBe(true); + expect(idbSet).toHaveBeenCalledWith( + "phase-full-terminal:TERM01:3", + delivery, + expect.anything(), + ); + + vi.mocked(idbGet).mockResolvedValueOnce(delivery); + await expect(loadFullTerminalDelivery(delivery.key)).resolves.toEqual(delivery); + }); + + it("requires an explicit replacement for a changed delivery tuple", async () => { + vi.mocked(idbGet).mockResolvedValueOnce(delivery); + expect( + await commitFullTerminalDelivery({ ...delivery, delivery_id: "delivery-1" }), + ).toBe(false); + expect(idbSet).not.toHaveBeenCalled(); + + expect( + await replaceFullTerminalDelivery({ ...delivery, delivery_id: "delivery-1" }), + ).toBe(true); + expect(idbSet).toHaveBeenCalledWith( + "phase-full-terminal:TERM01:3", + expect.objectContaining({ delivery_id: "delivery-1" }), + expect.anything(), + ); + }); + + it("does not revive a legacy snapshot as a terminal delivery", async () => { + const legacySnapshot = { waiting_for: { type: "GameOver" }, players: [] }; + vi.mocked(idbGet).mockResolvedValueOnce(legacySnapshot); + + expect(isValidFullTerminalDelivery(legacySnapshot)).toBe(false); + await expect(loadFullTerminalDelivery(delivery.key)).resolves.toBeNull(); + await expect(commitFullTerminalDelivery(legacySnapshot as never)).resolves.toBe(false); + }); +}); diff --git a/client/src/services/__tests__/gamePersistence.test.ts b/client/src/services/__tests__/gamePersistence.test.ts index 46145d87bf..cb549b7163 100644 --- a/client/src/services/__tests__/gamePersistence.test.ts +++ b/client/src/services/__tests__/gamePersistence.test.ts @@ -12,8 +12,13 @@ vi.mock("idb-keyval", () => ({ set: vi.fn().mockResolvedValue(undefined), })); -import { get as idbGet, set as idbSet } from "idb-keyval"; -import { loadGame, saveAuthoritativeGame } from "../gamePersistence"; +import { del as idbDel, get as idbGet, set as idbSet } from "idb-keyval"; +import { + loadGame, + loadP2PHostSession, + saveAuthoritativeGame, + saveGame, +} from "../gamePersistence"; function fixtureState(): GameState { return buildGameState({ @@ -51,4 +56,23 @@ describe("game persistence", () => { expect(restored).toEqual(envelope); expect(persistedGameStateView(restored!)).toEqual(state); }); + + it("does not clear resumable storage for a terminal state before GameOver delivery", async () => { + const state = fixtureState(); + state.match_phase = "Completed"; + + await saveGame("terminal-pending", state); + + expect(idbSet).not.toHaveBeenCalled(); + expect(idbDel).not.toHaveBeenCalled(); + }); + + it("rejects a legacy P2P host snapshot without a session key", async () => { + vi.mocked(idbGet).mockResolvedValueOnce({ + gameId: "legacy-p2p", + roomCode: "ABCDE", + }); + + await expect(loadP2PHostSession("legacy-p2p")).resolves.toBeNull(); + }); }); diff --git a/client/src/services/__tests__/intergameCommandLedger.test.ts b/client/src/services/__tests__/intergameCommandLedger.test.ts new file mode 100644 index 0000000000..a648eeae60 --- /dev/null +++ b/client/src/services/__tests__/intergameCommandLedger.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { + commandAcknowledgement, + consumeIntergamePermit, + draftIntergameDigest, + IntergameCommandController, +} from "../intergameCommandLedger"; + +function heldCommand() { + const controller = new IntergameCommandController(); + const command = controller.hold({ + commandId: "command-1", + matchId: "traditional-match", + gameNumber: 2, + seat: 1, + payload: { type: "ChoosePlayDraw", playFirst: false }, + launchPayload: { match: "traditional-match", seat: 1 }, + launchDigest: draftIntergameDigest({ match: "traditional-match", seat: 1 }), + }); + return { controller, command, acknowledgement: commandAcknowledgement(command) }; +} + +describe("IntergameCommandController", () => { + it("releases a Traditional between-games command exactly once", () => { + const { controller, command, acknowledgement } = heldCommand(); + expect(controller.authorize(command.commandId, acknowledgement)?.status).toBe("Authorized"); + const permit = controller.begin(command.commandId, acknowledgement); + expect(permit).not.toBeNull(); + expect(consumeIntergamePermit(permit!, acknowledgement)).toBe(true); + expect(controller.receipt(command.commandId, acknowledgement, "receipt-1")?.status).toBe("Receipted"); + expect(controller.begin(command.commandId, acknowledgement)).toBeNull(); + }); + + it("rejects forged acknowledgements and a stale launch digest", () => { + const { controller, command, acknowledgement } = heldCommand(); + expect(controller.authorize(command.commandId, { ...acknowledgement, payloadDigest: "forged" })).toBeNull(); + expect(controller.authorize(command.commandId, { ...acknowledgement, launchDigest: "stale" })).toBeNull(); + expect(controller.snapshot()[0].status).toBe("Pending"); + }); + + it("does not replay a command interrupted after the pre-execution transition", () => { + const { controller, command, acknowledgement } = heldCommand(); + controller.authorize(command.commandId, acknowledgement); + expect(controller.begin(command.commandId, acknowledgement)).not.toBeNull(); + const recovered = new IntergameCommandController(controller.snapshot()); + recovered.recover(); + expect(recovered.snapshot()[0]).toMatchObject({ status: "Receipted", receiptId: "recovered" }); + expect(recovered.begin(command.commandId, acknowledgement)).toBeNull(); + }); +}); diff --git a/client/src/services/__tests__/multiplayerSession.test.ts b/client/src/services/__tests__/multiplayerSession.test.ts index d12524ab9e..902b534455 100644 --- a/client/src/services/__tests__/multiplayerSession.test.ts +++ b/client/src/services/__tests__/multiplayerSession.test.ts @@ -50,6 +50,7 @@ describe("multiplayer WebSocket session persistence", () => { const session = { gameCode: "ABCDE", playerToken: "token", + fullKey: { game_code: "ABCDE", generation: 1 }, serverUrl: "wss://play.example/ws", timestamp: Date.now(), }; @@ -65,6 +66,7 @@ describe("multiplayer WebSocket session persistence", () => { JSON.stringify({ gameCode: "ABCDE", playerToken: "token", + fullKey: { game_code: "ABCDE", generation: 1 }, serverUrl: "wss://play.example/ws", timestamp: 0, }), @@ -74,6 +76,21 @@ describe("multiplayer WebSocket session persistence", () => { expect(localStorage.getItem(WS_SESSION_STORAGE_KEY)).toBeNull(); }); + it("migrates legacy reconnect records by invalidating records without a Full session key", () => { + localStorage.setItem( + WS_SESSION_STORAGE_KEY, + JSON.stringify({ + gameCode: "ABCDE", + playerToken: "legacy-token", + serverUrl: "wss://play.example/ws", + timestamp: Date.now(), + }), + ); + + expect(loadWsSession()).toBeNull(); + expect(localStorage.getItem(WS_SESSION_STORAGE_KEY)).toBeNull(); + }); + it("does not crash app startup when localStorage reads are blocked", () => { setLocalStorage({ getItem: () => { @@ -96,6 +113,7 @@ describe("multiplayer WebSocket session persistence", () => { saveWsSession({ gameCode: "ABCDE", playerToken: "token", + fullKey: { game_code: "ABCDE", generation: 1 }, serverUrl: "wss://play.example/ws", timestamp: Date.now(), }); diff --git a/client/src/services/__tests__/p2pSession.test.ts b/client/src/services/__tests__/p2pSession.test.ts new file mode 100644 index 0000000000..12a99e635c --- /dev/null +++ b/client/src/services/__tests__/p2pSession.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + claimP2PHostLease, + ownsP2PHostLease, + releaseP2PHostLease, +} from "../p2pSession"; + +const originalLocalStorageDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + "localStorage", +); + +function setMemoryLocalStorage(): void { + const items = new Map(); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => items.get(key) ?? null, + setItem: (key: string, value: string) => items.set(key, value), + removeItem: (key: string) => items.delete(key), + }, + }); +} + +beforeEach(() => { + setMemoryLocalStorage(); +}); + +afterEach(() => { + if (originalLocalStorageDescriptor) { + Object.defineProperty(globalThis, "localStorage", originalLocalStorageDescriptor); + } +}); + +describe("P2P host leases", () => { + it("fences a stale host and does not revive it when the current host cleans up", () => { + const stale = claimP2PHostLease("shared-session-key"); + const current = claimP2PHostLease("shared-session-key"); + + expect(ownsP2PHostLease(stale)).toBe(false); + expect(ownsP2PHostLease(current)).toBe(true); + + releaseP2PHostLease(stale); + expect(ownsP2PHostLease(current)).toBe(true); + + releaseP2PHostLease(current); + expect(ownsP2PHostLease(stale)).toBe(false); + }); +}); diff --git a/client/src/services/__tests__/p2pTerminalResult.test.ts b/client/src/services/__tests__/p2pTerminalResult.test.ts new file mode 100644 index 0000000000..3f57d5fdca --- /dev/null +++ b/client/src/services/__tests__/p2pTerminalResult.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("idb-keyval", () => ({ + createStore: vi.fn(() => ({})), + del: vi.fn().mockResolvedValue(undefined), + get: vi.fn().mockResolvedValue(undefined), + set: vi.fn().mockResolvedValue(undefined), +})); + +import { get as idbGet, set as idbSet } from "idb-keyval"; +import { + commitP2PTerminalResult, + isValidP2PTerminalResult, + type P2PTerminalResult, +} from "../p2pTerminalResult"; + +const result: P2PTerminalResult = { + key: "p2p-session-1", + lease: { sessionKey: "p2p-session-1", hostIncarnation: "host-2" }, + recipient: 0, + revision: 12, + terminalId: "terminal-1", + finalStateCommitment: "sha256:abc123", + display: { winner: 0, reason: "Match conceded" }, +}; + +describe("P2P terminal result persistence", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("pins the first key/lease/revision/terminal-id statement", async () => { + expect(await commitP2PTerminalResult(result)).toBe(true); + expect(idbSet).toHaveBeenCalledWith( + "phase-p2p-terminal:p2p-session-1", + result, + expect.anything(), + ); + }); + + it("rejects a later conflicting terminal id for the same key", async () => { + vi.mocked(idbGet).mockResolvedValueOnce(result); + expect( + await commitP2PTerminalResult({ ...result, terminalId: "terminal-2" }), + ).toBe(false); + expect(idbSet).not.toHaveBeenCalled(); + }); + + it("rejects a result whose lease does not bind its key", () => { + expect(isValidP2PTerminalResult({ + ...result, + lease: { ...result.lease, sessionKey: "other-session" }, + })).toBe(false); + }); +}); diff --git a/client/src/services/draftPersistence.ts b/client/src/services/draftPersistence.ts index e1cad62868..fe176b1e97 100644 --- a/client/src/services/draftPersistence.ts +++ b/client/src/services/draftPersistence.ts @@ -13,7 +13,9 @@ import { createStore, del, get, set } from "idb-keyval"; import type { PoolInput } from "../adapter/draft-adapter"; +import type { DraftMatchBinding, DraftMatchLaunch, DraftMatchSettlement } from "../network/draftProtocol"; import { ACTIVE_DRAFT_POD_KEY } from "../constants/storage"; +import type { DraftIntergameCommand } from "./intergameCommandLedger"; export type { PoolInput } from "../adapter/draft-adapter"; @@ -47,6 +49,34 @@ export interface PersistedDraftHostSession { draftSessionJson: string | null; /** Pool source for re-initialization on resume (Set pool JSON or Cube list + settings). */ poolInput: PoolInput; + /** Pod-issued match capabilities, retained across host recovery. */ + matchBindings?: DraftMatchBinding[]; + /** Result receipts are written before the draft reducer observes them. */ + settlementOutbox?: DraftMatchSettlement[]; + settlementReceipts?: Array<{ matchId: string; receiptId: string; revision: number }>; + /** Write-ahead Bo3 commands survive recovery without becoming replayable. */ + intergameCommands?: DraftIntergameCommand[]; + /** Host-owned intergame phase data; required to reject stale recovery writes. */ + bo3State?: Array<{ + matchId: string; + seatA: number; + seatB: number; + submittedA: boolean; + submittedB: boolean; + loserSeat: number | null; + gameNumber: number; + score: { p0_wins: number; p1_wins: number; draws: number }; + /** Current deck partitions, used by the authoritative timeout default. */ + decks?: Array<{ + seat: number; + main: Array<{ name: string; count: number }>; + sideboard: Array<{ name: string; count: number }>; + }>; + }>; + launchDigests?: Array<{ matchId: string; seat: number; digest: string }>; + /** Full immutable launch records let a recovered host issue timeout commands + * through the same launch-bound intergame ledger. */ + matchLaunches?: Array<{ matchId: string; seat: number; launch: DraftMatchLaunch }>; } /** @@ -88,6 +118,8 @@ export interface ActiveDraftPodMeta { const DRAFT_HOST_PREFIX = "phase-draft-host:"; const DRAFT_GUEST_PREFIX = "phase-draft-guest:"; +const DRAFT_SETTLEMENT_PREFIX = "phase-draft-settlement:"; +const DRAFT_INTERGAME_PREFIX = "phase-draft-intergame:"; const HOST_SESSION_TTL_MS = 24 * 60 * 60 * 1000; /** Guest token TTL — 4 hours matches the game session TTL. */ const GUEST_SESSION_TTL_MS = 4 * 60 * 60 * 1000; @@ -213,3 +245,75 @@ export async function clearDraftGuestSession(hostPeerId: string): Promise await del(DRAFT_GUEST_PREFIX + hostPeerId, getDraftStore()); } catch { /* best-effort */ } } + +/** A participant-owned outbox survives a reload until the pod host acks it. */ +export async function saveDraftSettlementOutbox(settlement: DraftMatchSettlement): Promise { + try { + await set(draftSettlementKey(settlement.binding), settlement, getDraftStore()); + } catch (err) { + console.warn("[draftPersistence] settlement outbox write failed:", err); + } +} + +export async function loadDraftSettlementOutbox( + binding: DraftMatchBinding, +): Promise { + try { + const settlement = await get(draftSettlementKey(binding), getDraftStore()); + return settlement && sameSettlementBinding(settlement.binding, binding) ? settlement : null; + } catch { + return null; + } +} + +export async function clearDraftSettlementOutbox(binding: DraftMatchBinding): Promise { + try { + await del(draftSettlementKey(binding), getDraftStore()); + } catch { + /* best-effort; retry remains safe */ + } +} + +/** Participant-owned journal retained until every command is receipted. */ +export async function saveDraftIntergameCommands( + matchId: string, + commands: DraftIntergameCommand[], +): Promise { + try { + await set(DRAFT_INTERGAME_PREFIX + matchId, commands, getDraftStore()); + } catch (err) { + console.warn("[draftPersistence] intergame command write failed:", err); + } +} + +export async function loadDraftIntergameCommands(matchId: string): Promise { + try { + return await get(DRAFT_INTERGAME_PREFIX + matchId, getDraftStore()) ?? []; + } catch { + return []; + } +} + +export async function clearDraftIntergameCommands(matchId: string): Promise { + try { + await del(DRAFT_INTERGAME_PREFIX + matchId, getDraftStore()); + } catch { + /* best-effort */ + } +} + +function draftSettlementKey(binding: DraftMatchBinding): string { + return `${DRAFT_SETTLEMENT_PREFIX}${binding.podId}:${binding.matchId}`; +} + +/** Legacy or cross-round outboxes are never eligible for settlement replay. */ +function sameSettlementBinding(left: DraftMatchBinding, right: DraftMatchBinding): boolean { + return left.podId === right.podId + && left.matchId === right.matchId + && left.round === right.round + && left.sessionKey === right.sessionKey + && left.lease === right.lease + && left.nonce === right.nonce + && left.revision === right.revision + && left.matchAuthoritySeat === right.matchAuthoritySeat; +} diff --git a/client/src/services/fullTerminalResult.ts b/client/src/services/fullTerminalResult.ts new file mode 100644 index 0000000000..b0ccfff153 --- /dev/null +++ b/client/src/services/fullTerminalResult.ts @@ -0,0 +1,114 @@ +import { createStore, del, get, set } from "idb-keyval"; + +/** + * Recipient-scoped terminal delivery issued by the Full server. This is kept + * separate from normal game persistence: a terminal frame must never be + * mistaken for an engine snapshot that can be resumed. + */ +export interface FullTerminalDelivery { + key: { game_code: string; generation: number }; + terminal_revision: number; + delivery_id: string; + credential: string; + display: { + winner: number | null; + reason: string; + ranked_result?: unknown; + }; +} + +const FULL_TERMINAL_PREFIX = "phase-full-terminal:"; +let terminalStore: ReturnType | undefined; + +function getTerminalStore(): ReturnType { + if (!terminalStore) { + terminalStore = createStore("phase-full-terminal", "phase-full-terminal"); + } + return terminalStore; +} + +function recordKey(key: FullTerminalDelivery["key"]): string { + return `${FULL_TERMINAL_PREFIX}${key.game_code}:${key.generation}`; +} + +/** A legacy state snapshot is never a terminal delivery capability. */ +export function isValidFullTerminalDelivery(value: unknown): value is FullTerminalDelivery { + if (!value || typeof value !== "object") return false; + const delivery = value as Partial; + const key = delivery.key; + const display = delivery.display; + return key !== undefined + && display !== undefined + && typeof key.game_code === "string" + && key.game_code.length > 0 + && typeof key.generation === "number" + && Number.isSafeInteger(key.generation) + && key.generation > 0 + && typeof delivery.terminal_revision === "number" + && Number.isSafeInteger(delivery.terminal_revision) + && delivery.terminal_revision >= 0 + && typeof delivery.delivery_id === "string" + && delivery.delivery_id.length > 0 + && typeof delivery.credential === "string" + && delivery.credential.length > 0 + && typeof display.reason === "string" + && (typeof display.winner === "number" || display.winner === null); +} + +/** + * Commits a delivery before normal websocket session state is cleared. Equal + * deliveries are idempotent; a different terminal for the same key must use + * the explicit replacement operation below. + */ +export async function commitFullTerminalDelivery( + delivery: FullTerminalDelivery, +): Promise { + if (!isValidFullTerminalDelivery(delivery)) return false; + const key = recordKey(delivery.key); + try { + const existing = await get(key, getTerminalStore()); + if (existing) { + return existing.delivery_id === delivery.delivery_id + && existing.credential === delivery.credential; + } + await set(key, delivery, getTerminalStore()); + return true; + } catch { + return false; + } +} + +export async function loadFullTerminalDelivery( + key: FullTerminalDelivery["key"], +): Promise { + try { + const delivery = await get(recordKey(key), getTerminalStore()); + return delivery && isValidFullTerminalDelivery(delivery) ? delivery : null; + } catch { + return null; + } +} + +/** Replaces a stale cached delivery only after the server has issued a newer tuple. */ +export async function replaceFullTerminalDelivery( + delivery: FullTerminalDelivery, +): Promise { + if (!isValidFullTerminalDelivery(delivery)) return false; + try { + await set(recordKey(delivery.key), delivery, getTerminalStore()); + return true; + } catch { + return false; + } +} + +export async function clearFullTerminalDelivery( + key: FullTerminalDelivery["key"], +): Promise { + try { + await del(recordKey(key), getTerminalStore()); + } catch { + // A terminal result is durable best-effort browser state. Server delivery + // remains the authority and can be re-read with its credential. + } +} diff --git a/client/src/services/gamePersistence.ts b/client/src/services/gamePersistence.ts index 537b1cb61f..fead7e58ec 100644 --- a/client/src/services/gamePersistence.ts +++ b/client/src/services/gamePersistence.ts @@ -9,6 +9,8 @@ import type { PlayerId, } from "../adapter/types"; import type { SeatState } from "../multiplayer/seatTypes"; +import type { FullSessionKey } from "./multiplayerSession"; +import type { P2PSessionKey } from "./p2pSession"; import { ACTIVE_GAME_KEY, GAME_CHECKPOINTS_PREFIX, GAME_KEY_PREFIX } from "../constants/storage"; /** Snapshot of an AI seat's configuration at game-start time. The per-seat @@ -36,6 +38,7 @@ export interface NativeSoloSession { gameCode: string; playerId: PlayerId; playerToken: string; + fullKey: FullSessionKey; } export interface ActiveGameMeta { @@ -80,6 +83,8 @@ export interface PersistedP2PHostSession { gameId: string; /** Bare 5-char room code; the PeerJS prefix is reattached by `hostRoom`. */ roomCode: string; + /** Stable authority identity. A resumed host claims a fresh incarnation. */ + sessionKey: P2PSessionKey; brokerGameCode?: string; useBroker: boolean; /** PlayerId.0 → token. PlayerId 0 is the host's own slot. */ @@ -109,6 +114,7 @@ export interface PersistedP2PHostSession { export interface NativeP2PServerSession { gameCode: string; + fullKey: FullSessionKey; /** Native player token keyed by the matching P2P player id. */ playerTokens: Record; } @@ -144,7 +150,9 @@ export async function saveGame(gameId: string, state: PersistedGameState): Promi publicState.match_phase === "Completed" || (!publicState.match_phase && publicState.waiting_for.type === "GameOver") ) { - await clearGame(gameId); + // A terminal StateUpdate can arrive before its recipient-specific GameOver + // envelope. The latter carries the terminal access record, so this path + // must not clear resumable state before that record has been committed. return; } try { @@ -215,7 +223,8 @@ export async function loadP2PHostSession( P2P_HOST_KEY_PREFIX + gameId, getGameStore(), ); - return s ?? null; + if (!s || typeof s.sessionKey !== "string" || s.sessionKey.length === 0) return null; + return s; } catch { return null; } diff --git a/client/src/services/intergameCommandLedger.ts b/client/src/services/intergameCommandLedger.ts new file mode 100644 index 0000000000..2e2b649eac --- /dev/null +++ b/client/src/services/intergameCommandLedger.ts @@ -0,0 +1,179 @@ +import type { DeckCardCount } from "../adapter/types"; + +/** + * A pod-issued command is deliberately distinct from an engine action. The + * draft coordinator owns its lifecycle; the game adapter only receives a + * command after the coordinator has made it executable. + */ +export type DraftIntergameCommandPayload = + | { type: "SubmitSideboard"; main: DeckCardCount[]; sideboard: DeckCardCount[] } + | { type: "ChoosePlayDraw"; playFirst: boolean }; + +export type DraftIntergameCommandStatus = + | "Pending" + | "Authorized" + | "Executing" + | "Receipted"; + +export interface DraftIntergameCommand { + commandId: string; + matchId: string; + gameNumber: number; + seat: number; + payload: DraftIntergameCommandPayload; + /** Held launch descriptor; its digest binds the command to this exact match. */ + launchPayload: unknown; + /** Bound to the original match launch so commands cannot cross matches. */ + launchDigest: string; + payloadDigest: string; + status: DraftIntergameCommandStatus; + receiptId?: string; +} + +/** The immutable acknowledgement predicate echoed by the executor. */ +export interface DraftIntergameCommandAck { + commandId: string; + matchId: string; + gameNumber: number; + seat: number; + launchDigest: string; + payloadDigest: string; +} + +/** Stable enough for a persisted integrity binding, without treating it as crypto. */ +export function draftIntergameDigest(value: unknown): string { + const text = JSON.stringify(canonicalize(value)); + let hash = 0x811c9dc5; + for (let index = 0; index < text.length; index++) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return `d${(hash >>> 0).toString(16).padStart(8, "0")}`; +} + +export function commandAcknowledgement(command: DraftIntergameCommand): DraftIntergameCommandAck { + return { + commandId: command.commandId, + matchId: command.matchId, + gameNumber: command.gameNumber, + seat: command.seat, + launchDigest: command.launchDigest, + payloadDigest: command.payloadDigest, + }; +} + +export function matchesCommandAcknowledgement( + command: DraftIntergameCommand, + acknowledgement: DraftIntergameCommandAck, +): boolean { + const expected = commandAcknowledgement(command); + return expected.commandId === acknowledgement.commandId + && expected.matchId === acknowledgement.matchId + && expected.gameNumber === acknowledgement.gameNumber + && expected.seat === acknowledgement.seat + && expected.launchDigest === acknowledgement.launchDigest + && expected.payloadDigest === acknowledgement.payloadDigest; +} + +/** + * This issuer is intentionally private to this module. A caller can present a + * serializable command, but only a permit created by the matching controller + * can cross the final local execution gate. + */ +const permits = new WeakMap(); + +export class IntergameCommandController { + private readonly commands = new Map(); + + constructor(commands: readonly DraftIntergameCommand[] = []) { + for (const command of commands) this.commands.set(command.commandId, command); + } + + snapshot(): DraftIntergameCommand[] { + return [...this.commands.values()].map((command) => ({ ...command })); + } + + hold(command: Omit): DraftIntergameCommand { + const held: DraftIntergameCommand = { + ...command, + payloadDigest: draftIntergameDigest(command.payload), + launchPayload: immutablePayload(command.launchPayload), + status: "Pending", + }; + this.commands.set(held.commandId, held); + return held; + } + + authorize(commandId: string, acknowledgement: DraftIntergameCommandAck): DraftIntergameCommand | null { + const command = this.commands.get(commandId); + if (!command || command.status !== "Pending" || !matchesCommandAcknowledgement(command, acknowledgement)) { + return null; + } + const authorized = { ...command, status: "Authorized" as const }; + this.commands.set(commandId, authorized); + return authorized; + } + + /** Re-checks immutable launch + payload bindings immediately before the sink. */ + begin(commandId: string, acknowledgement: DraftIntergameCommandAck): object | null { + const command = this.commands.get(commandId); + if (!command || command.status !== "Authorized" || !matchesCommandAcknowledgement(command, acknowledgement)) { + return null; + } + const executing = { ...command, status: "Executing" as const }; + this.commands.set(commandId, executing); + const permit = {}; + permits.set(permit, acknowledgement); + return permit; + } + + receipt(commandId: string, acknowledgement: DraftIntergameCommandAck, receiptId: string): DraftIntergameCommand | null { + const command = this.commands.get(commandId); + if (!command || command.status !== "Executing" || !matchesCommandAcknowledgement(command, acknowledgement)) { + return null; + } + const receipted = { ...command, status: "Receipted" as const, receiptId }; + this.commands.set(commandId, receipted); + return receipted; + } + + /** A recovered Executing command stays non-replayable until the receipt arrives. */ + recover(): void { + for (const command of this.commands.values()) { + if (command.status === "Executing") { + this.commands.set(command.commandId, { ...command, status: "Receipted", receiptId: command.receiptId ?? "recovered" }); + } + } + } +} + +export function consumeIntergamePermit( + permit: object, + acknowledgement: DraftIntergameCommandAck, +): boolean { + const issued = permits.get(permit); + if (!issued) return false; + permits.delete(permit); + return issued.commandId === acknowledgement.commandId + && issued.matchId === acknowledgement.matchId + && issued.gameNumber === acknowledgement.gameNumber + && issued.seat === acknowledgement.seat + && issued.launchDigest === acknowledgement.launchDigest + && issued.payloadDigest === acknowledgement.payloadDigest; +} + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, canonicalize(child)]), + ); + } + return value; +} + +function immutablePayload(value: unknown): unknown { + return JSON.parse(JSON.stringify(value)) as unknown; +} diff --git a/client/src/services/multiplayerSession.ts b/client/src/services/multiplayerSession.ts index 8d4d58ccab..c52b23bd1c 100644 --- a/client/src/services/multiplayerSession.ts +++ b/client/src/services/multiplayerSession.ts @@ -9,9 +9,16 @@ export interface WsHostSessionData { matchType: MatchType; } +/** Exact server-issued identity for a resumable Full session. */ +export interface FullSessionKey { + game_code: string; + generation: number; +} + export interface WsSessionData { gameCode: string; playerToken: string; + fullKey: FullSessionKey; serverUrl: string; timestamp: number; hostSession?: WsHostSessionData; @@ -28,7 +35,13 @@ export function loadWsSession(): WsSessionData | null { if (!raw) return null; const session = JSON.parse(raw) as WsSessionData; - if (!isWsSessionValid(session)) { + if ( + !isWsSessionValid(session) + || !session.fullKey + || session.fullKey.game_code !== session.gameCode + || !Number.isInteger(session.fullKey.generation) + || session.fullKey.generation < 1 + ) { clearWsSession(); return null; } diff --git a/client/src/services/p2pSession.ts b/client/src/services/p2pSession.ts index 8796c007c4..5014586265 100644 --- a/client/src/services/p2pSession.ts +++ b/client/src/services/p2pSession.ts @@ -15,6 +15,8 @@ import { createStore, del, get, set } from "idb-keyval"; const STORAGE_PREFIX = "phase-p2p-session:"; +const HOST_LEASE_PREFIX = "phase-p2p-host-lease:"; +const inMemoryHostLeases = new Map(); /** * How long a persisted guest token is valid. Sized for host-resume * realism: a host who crashed, reopened the tab, and dialed back in on @@ -28,9 +30,83 @@ export interface P2PSessionData { hostPeerId: string; playerToken: string; playerId: number; + /** Stable identity of the hosted game, retained across host resume. */ + authority: P2PAuthorityStamp; timestamp: number; } +/** + * Stable, opaque identity for one hosted P2P game. It is deliberately + * independent of a PeerJS id: a resumed host may reclaim the room code while + * receiving a new PeerJS process/incarnation. + */ +export type P2PSessionKey = string; + +/** + * A host lease is a fencing token. A new host resuming the same session key + * replaces the incarnation synchronously; any older host must check this + * stamp before it emits a frame, changes authority state, or persists. + */ +export interface P2PAuthorityStamp { + sessionKey: P2PSessionKey; + hostIncarnation: string; +} + +export function createP2PSessionKey(): P2PSessionKey { + return crypto.randomUUID(); +} + +function leaseStorageKey(sessionKey: P2PSessionKey): string { + return HOST_LEASE_PREFIX + sessionKey; +} + +function readHostLease(sessionKey: P2PSessionKey): P2PAuthorityStamp | null { + try { + const raw = localStorage.getItem(leaseStorageKey(sessionKey)); + if (!raw) return null; + const stamp = JSON.parse(raw) as Partial; + return stamp.sessionKey === sessionKey && typeof stamp.hostIncarnation === "string" + ? { sessionKey, hostIncarnation: stamp.hostIncarnation } + : null; + } catch { + return null; + } +} + +/** Claim the sole local authority lease for a stable P2P session. */ +export function claimP2PHostLease(sessionKey: P2PSessionKey): P2PAuthorityStamp { + const authority = { sessionKey, hostIncarnation: crypto.randomUUID() }; + inMemoryHostLeases.set(sessionKey, authority.hostIncarnation); + try { + localStorage.setItem(leaseStorageKey(sessionKey), JSON.stringify(authority)); + } catch (err) { + // The in-memory stamp still fences this adapter's frames. Storage failure + // only removes cross-tab resume fencing, and must not prevent hosting. + console.warn("[p2pSession] host lease write failed:", err); + } + return authority; +} + +export function ownsP2PHostLease(authority: P2PAuthorityStamp): boolean { + const current = readHostLease(authority.sessionKey); + if (current) return current.hostIncarnation === authority.hostIncarnation; + // Storage can be unavailable (private mode, quota policy). The module-local + // fallback preserves same-tab fencing; it intentionally does not revive an + // older host after the current incarnation has released its lease. + return inMemoryHostLeases.get(authority.sessionKey) === authority.hostIncarnation; +} + +/** Release only the caller's incarnation; never erase a newer resumed host. */ +export function releaseP2PHostLease(authority: P2PAuthorityStamp): void { + if (!ownsP2PHostLease(authority)) return; + inMemoryHostLeases.delete(authority.sessionKey); + try { + localStorage.removeItem(leaseStorageKey(authority.sessionKey)); + } catch { + /* best-effort */ + } +} + let _store: ReturnType | undefined; function getSessionStore(): ReturnType { @@ -50,12 +126,13 @@ function isFresh(session: P2PSessionData): boolean { export async function saveP2PSession( hostPeerId: string, - data: { playerToken: string; playerId: number }, + data: { playerToken: string; playerId: number; authority: P2PAuthorityStamp }, ): Promise { const session: P2PSessionData = { hostPeerId, playerToken: data.playerToken, playerId: data.playerId, + authority: data.authority, timestamp: Date.now(), }; try { @@ -69,7 +146,7 @@ export async function loadP2PSession(hostPeerId: string): Promise(storageKey(hostPeerId), getSessionStore()); if (!session) return null; - if (!isFresh(session)) { + if (!isFresh(session) || !session.authority?.sessionKey || !session.authority.hostIncarnation) { await clearP2PSession(hostPeerId); return null; } diff --git a/client/src/services/p2pTerminalResult.ts b/client/src/services/p2pTerminalResult.ts new file mode 100644 index 0000000000..ec256802b0 --- /dev/null +++ b/client/src/services/p2pTerminalResult.ts @@ -0,0 +1,121 @@ +import { createStore, del, get, set } from "idb-keyval"; + +import type { GameState, PlayerId } from "../adapter/types"; +import type { P2PAuthorityStamp, P2PSessionKey } from "./p2pSession"; + +/** + * The host's terminal statement for one P2P authority incarnation. Unlike a + * normal state update this is durable browser state: once accepted it fences + * reconnect and is the only terminal display record a tab may use. + */ +export interface P2PTerminalResult { + key: P2PSessionKey; + lease: P2PAuthorityStamp; + /** Player whose filtered terminal state this commitment authenticates. */ + recipient: PlayerId; + revision: number; + terminalId: string; + finalStateCommitment: string; + display: { + winner: PlayerId | null; + reason: string; + }; +} + +const P2P_TERMINAL_PREFIX = "phase-p2p-terminal:"; +let terminalStore: ReturnType | undefined; + +function getTerminalStore(): ReturnType { + if (!terminalStore) { + terminalStore = createStore("phase-p2p-terminal", "phase-p2p-terminal"); + } + return terminalStore; +} + +function recordKey(key: P2PSessionKey): string { + return `${P2P_TERMINAL_PREFIX}${key}`; +} + +/** The terminal frame binds one key to the host incarnation that issued it. */ +export function isValidP2PTerminalResult(value: unknown): value is P2PTerminalResult { + if (!value || typeof value !== "object") return false; + const result = value as Partial; + return typeof result.key === "string" + && result.key.length > 0 + && typeof result.terminalId === "string" + && result.terminalId.length > 0 + && typeof result.revision === "number" + && Number.isSafeInteger(result.revision) + && result.revision >= 0 + && typeof result.recipient === "number" + && Number.isSafeInteger(result.recipient) + && result.recipient >= 0 + && typeof result.finalStateCommitment === "string" + && result.finalStateCommitment.startsWith("sha256:") + && typeof result.lease?.sessionKey === "string" + && result.lease.sessionKey === result.key + && typeof result.lease.hostIncarnation === "string" + && result.lease.hostIncarnation.length > 0 + && typeof result.display?.reason === "string" + && (typeof result.display?.winner === "number" || result.display?.winner === null); +} + +function sameTerminalResult(left: P2PTerminalResult, right: P2PTerminalResult): boolean { + return left.key === right.key + && left.lease.sessionKey === right.lease.sessionKey + && left.lease.hostIncarnation === right.lease.hostIncarnation + && left.recipient === right.recipient + && left.revision === right.revision + && left.terminalId === right.terminalId + && left.finalStateCommitment === right.finalStateCommitment + && left.display.winner === right.display.winner + && left.display.reason === right.display.reason; +} + +/** + * First valid terminal ID wins. Replays of exactly that statement are + * idempotent; a different ID or a mutated same-ID statement is rejected. + */ +export async function commitP2PTerminalResult(result: P2PTerminalResult): Promise { + if (!isValidP2PTerminalResult(result)) return false; + try { + const key = recordKey(result.key); + const existing = await get(key, getTerminalStore()); + if (existing) return sameTerminalResult(existing, result); + await set(key, result, getTerminalStore()); + return true; + } catch { + return false; + } +} + +export async function loadP2PTerminalResult(key: P2PSessionKey): Promise { + try { + const result = await get(recordKey(key), getTerminalStore()); + return result && isValidP2PTerminalResult(result) ? result : null; + } catch { + return null; + } +} + +export async function clearP2PTerminalResult(key: P2PSessionKey): Promise { + try { + await del(recordKey(key), getTerminalStore()); + } catch { + // The terminal result only suppresses reconnect in this browser. A failed + // deletion must not turn an explicit Return to Menu into an app error. + } +} + +/** + * SHA-256 commitment to the exact terminal state delivered over the ordered + * P2P channel. JSON is already the transport's canonical state encoding, so + * hashing this representation detects a terminal claim paired with another + * final state without adding a second state serializer. + */ +export async function p2pFinalStateCommitment(state: GameState): Promise { + const bytes = new TextEncoder().encode(JSON.stringify(state)); + const digest = await crypto.subtle.digest("SHA-256", bytes); + const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); + return `sha256:${hex}`; +} diff --git a/client/src/stores/__tests__/multiplayerDraftStore.test.ts b/client/src/stores/__tests__/multiplayerDraftStore.test.ts index 31eadf58bc..8b12cafdbd 100644 --- a/client/src/stores/__tests__/multiplayerDraftStore.test.ts +++ b/client/src/stores/__tests__/multiplayerDraftStore.test.ts @@ -30,6 +30,8 @@ const mockHostAdapter = { requestPause: vi.fn(), requestResume: vi.fn(), overrideMatchResult: vi.fn(async () => {}), + submitMatchSettlement: vi.fn(async () => {}), + submitAuthorized: vi.fn(), dispose: vi.fn(async () => {}), status: "idle" as const, roomCode: null, @@ -46,6 +48,8 @@ const mockGuestAdapter = { initialize: vi.fn(async () => {}), submitPick: vi.fn(async () => {}), submitDeck: vi.fn(async () => {}), + submitAuthorized: vi.fn(), + acknowledgeAuthorized: vi.fn(), dispose: vi.fn(async () => {}), status: "idle" as const, seatIndex: null, @@ -269,12 +273,19 @@ describe("multiplayerDraftStore", () => { ai_decks: [], }, matchConfig: { match_type: "Bo1" }, + binding: { + podId: "draft-1", matchId: "match-1", round: 1, + sessionKey: "session-1", lease: "lease-1", nonce: "nonce-1", + revision: 0, matchAuthoritySeat: 0, + }, }, }); await useMultiplayerDraftStore.getState().reportActiveMatchGameResult(1); - expect(mockHostAdapter.overrideMatchResult).toHaveBeenCalledWith("match-1", 4); + expect(mockHostAdapter.submitMatchSettlement).toHaveBeenCalledWith(expect.objectContaining({ + winnerSeat: 4, + })); }); it("reports active match concessions as opponent wins", async () => { @@ -301,12 +312,19 @@ describe("multiplayerDraftStore", () => { ai_decks: [], }, matchConfig: { match_type: "Bo1" }, + binding: { + podId: "draft-1", matchId: "match-2", round: 1, + sessionKey: "session-2", lease: "lease-2", nonce: "nonce-2", + revision: 0, matchAuthoritySeat: 0, + }, }, }); await useMultiplayerDraftStore.getState().reportActiveMatchConcession(); - expect(mockHostAdapter.overrideMatchResult).toHaveBeenCalledWith("match-2", 5); + expect(mockHostAdapter.submitMatchSettlement).toHaveBeenCalledWith(expect.objectContaining({ + winnerSeat: 5, + })); }); }); diff --git a/client/src/stores/__tests__/multiplayerStore.test.ts b/client/src/stores/__tests__/multiplayerStore.test.ts index 01e75c371f..e5bd0c550d 100644 --- a/client/src/stores/__tests__/multiplayerStore.test.ts +++ b/client/src/stores/__tests__/multiplayerStore.test.ts @@ -302,11 +302,13 @@ describe("multiplayerStore", () => { emitServerMessage("GameCreated", { game_code: "ABCDE", player_token: "host-token", + full_key: { game_code: "ABCDE", generation: 1 }, }); expect(loadWsSession()).toMatchObject({ gameCode: "ABCDE", playerToken: "host-token", + fullKey: { game_code: "ABCDE", generation: 1 }, serverUrl: "ws://localhost:8787", hostIsPublic: true, hostSession: { @@ -321,6 +323,7 @@ describe("multiplayerStore", () => { saveWsSession({ gameCode: "ABCDE", playerToken: "host-token", + fullKey: { game_code: "ABCDE", generation: 1 }, serverUrl: "ws://localhost:8787", timestamp: Date.now(), hostIsPublic: true, @@ -339,6 +342,7 @@ describe("multiplayerStore", () => { data: { game_code: "ABCDE", player_token: "host-token", + full_key: { game_code: "ABCDE", generation: 1 }, }, }); @@ -349,6 +353,7 @@ describe("multiplayerStore", () => { emitServerMessage("GameCreated", { game_code: "ABCDE", player_token: "host-token", + full_key: { game_code: "ABCDE", generation: 1 }, }); emitServerMessage("PlayerSlotsUpdate", { slots }); @@ -371,6 +376,7 @@ describe("multiplayerStore", () => { saveWsSession({ gameCode: "ABCDE", playerToken: "host-token", + fullKey: { game_code: "ABCDE", generation: 1 }, serverUrl: "ws://localhost:8787", timestamp: Date.now(), }); @@ -392,6 +398,7 @@ describe("multiplayerStore", () => { emitServerMessage("GameCreated", { game_code: "ABCDE", player_token: "host-token", + full_key: { game_code: "ABCDE", generation: 1 }, }); emitServerMessage("GameStarted", {}); @@ -399,6 +406,7 @@ describe("multiplayerStore", () => { expect(loadWsSession()).toMatchObject({ gameCode: "ABCDE", playerToken: "host-token", + fullKey: { game_code: "ABCDE", generation: 1 }, serverUrl: "ws://localhost:8787", }); expect(loadWsSession()?.hostSession).toBeUndefined(); diff --git a/client/src/stores/multiplayerDraftStore.ts b/client/src/stores/multiplayerDraftStore.ts index e3e9a269e6..810f680ac1 100644 --- a/client/src/stores/multiplayerDraftStore.ts +++ b/client/src/stores/multiplayerDraftStore.ts @@ -21,8 +21,8 @@ import type { SeatPublicView, StandingEntry, } from "../adapter/draft-adapter"; -import type { EngineAdapter, GameEvent, GameLogEntry, MatchScore, SubmitResult } from "../adapter/types"; -import type { DraftMatchLaunch, DraftPauseReason } from "../network/draftProtocol"; +import type { EngineAdapter, GameAction, GameEvent, GameLogEntry, MatchScore, SubmitResult } from "../adapter/types"; +import type { DraftMatchLaunch, DraftMatchSettlement, DraftPauseReason } from "../network/draftProtocol"; import type { AISeatBinding } from "../game/controllers/aiController"; import { createGameLoopController, type GameLoopController } from "../game/controllers/gameLoopController"; import { processRemoteUpdate } from "../game/dispatch"; @@ -41,11 +41,25 @@ import { } from "../adapter/draftPodGuestAdapter"; import { clearActiveDraftPod, + clearDraftSettlementOutbox, + loadDraftIntergameCommands, loadActiveDraftPod, + loadDraftSettlementOutbox, saveActiveDraftPod, + saveDraftIntergameCommands, + saveDraftSettlementOutbox, type ActiveDraftPodMeta, type ActiveDraftPodPhase, } from "../services/draftPersistence"; +import { + commandAcknowledgement, + consumeIntergamePermit, + draftIntergameDigest, + IntergameCommandController, + type DraftIntergameCommand, + type DraftIntergameCommandAck, + type DraftIntergameCommandPayload, +} from "../services/intergameCommandLedger"; import { FORMAT_DEFAULTS } from "./multiplayerStore"; // ── Types ────────────────────────────────────────────────────────────── @@ -158,8 +172,8 @@ interface MultiplayerDraftActions { reportMatchResult: (matchId: string, winnerSeat: number | null) => Promise; /** Both: report the active game result using the current draft match pairing. */ reportActiveMatchGameResult: (gameWinner: number | null) => Promise; - /** Both: concede the active draft match and report the opponent as winner. */ - reportActiveMatchConcession: () => Promise; + /** Settles a bound match concession for the authenticated game seat. */ + reportActiveMatchConcession: (concedingGamePlayer?: number) => Promise; /** Host: advance to the next round (Casual mode). */ advanceRound: () => void; /** Host: override a match result (Casual mode). */ @@ -172,6 +186,10 @@ interface MultiplayerDraftActions { choosePlayDraw: (matchId: string, playFirst: boolean) => void; /** Both: handle between-games prompt from match adapter. */ handleBetweenGamesPrompt: (prompt: { matchId: string; gameNumber: number; score: MatchScore; loserSeat: number | null; timerMs: number }) => void; + /** Durable ingress; raw sideboard/play-draw transport is intentionally not exposed. */ + submitIntergameCommand: (payload: DraftIntergameCommandPayload) => Promise; + /** Executes only a pod-issued authorization after re-checking its immutable ack. */ + submitAuthorized: (command: DraftIntergameCommand, acknowledgement: DraftIntergameCommandAck) => Promise; } // ── Module-level adapter refs ────────────────────────────────────────── @@ -179,9 +197,18 @@ interface MultiplayerDraftActions { let activeHostAdapter: DraftPodHostAdapter | null = null; let activeGuestAdapter: DraftPodGuestAdapter | null = null; let activeMatchController: GameLoopController | null = null; -const reportedMatchResultIds = new Set(); +const intergameControllers = new Map(); const DRAFT_MATCH_FORMAT_CONFIG = FORMAT_DEFAULTS.Limited; +function intergameAction(payload: DraftIntergameCommandPayload): GameAction { + switch (payload.type) { + case "SubmitSideboard": + return { type: "SubmitSideboard", data: { main: payload.main, sideboard: payload.sideboard } }; + case "ChoosePlayDraw": + return { type: "ChoosePlayDraw", data: { play_first: payload.playFirst } }; + } +} + const RARITY_SCORE: Record = { mythic: 4, rare: 3, @@ -248,24 +275,23 @@ function chooseAutoPickCard(view: DraftPlayerView | null): string | null { return bestCard.instance_id; } -function opponentSeatForLaunch(launch: DraftMatchLaunch): number { - return launch.type === "Bot" ? launch.botSeat : launch.opponentSeat; +function seatForLaunchGamePlayer(launch: DraftMatchLaunch, gamePlayer: number): number { + switch (launch.type) { + case "HumanHost": + return gamePlayer === 0 ? launch.localSeat : launch.opponentSeat; + case "HumanGuest": + return gamePlayer === 1 ? launch.localSeat : launch.opponentSeat; + case "Bot": + return gamePlayer === 0 ? launch.localSeat : launch.botSeat; + } } function winnerSeatForLaunch(launch: DraftMatchLaunch, gameWinner: number | null): number | null { - if (gameWinner === null) return null; - return gameWinner === 0 ? launch.localSeat : opponentSeatForLaunch(launch); -} - -function guestWinnerSeatForLaunch(launch: DraftMatchLaunch, gameWinner: number | null): number | null { - if (gameWinner === null) return null; - return gameWinner === 0 ? opponentSeatForLaunch(launch) : launch.localSeat; + return gameWinner === null ? null : seatForLaunchGamePlayer(launch, gameWinner); } function winnerSeatForGameResult(launch: DraftMatchLaunch, gameWinner: number | null): number | null { - if (gameWinner === null) return null; - const localGamePlayerId = launch.type === "HumanGuest" ? 1 : 0; - return gameWinner === localGamePlayerId ? launch.localSeat : opponentSeatForLaunch(launch); + return gameWinner === null ? null : seatForLaunchGamePlayer(launch, gameWinner); } function disposeMatchController(): void { @@ -273,6 +299,18 @@ function disposeMatchController(): void { activeMatchController = null; } +async function retryDraftSettlement(launch: DraftMatchLaunch, role: DraftRole): Promise { + if (launch.binding.matchAuthoritySeat !== launch.localSeat) return; + const settlement = await loadDraftSettlementOutbox(launch.binding); + if (!settlement) return; + if (role === "host" && activeHostAdapter) { + await activeHostAdapter.submitMatchSettlement(settlement); + await clearDraftSettlementOutbox(launch.binding); + } else if (role === "guest" && activeGuestAdapter) { + activeGuestAdapter.sendMatchSettlement(settlement); + } +} + /** The engine-side AI seat for a Bot draft match (the local player is game * player 0, the bot is 1). Single authority shared by the live game-loop * controller and the Resolve All batch drain, so the drain can never play @@ -641,6 +679,15 @@ export const useMultiplayerDraftStore = create< 2, // 1v1 match DRAFT_MATCH_FORMAT_CONFIG, matchPairing.matchConfig, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { + onConcede: (concedingGamePlayer) => get().reportActiveMatchConcession(concedingGamePlayer), + }, ); let resolveRoomFull!: () => void; @@ -681,6 +728,13 @@ export const useMultiplayerDraftStore = create< matchPairing.localSeat, matchPairing.opponentSeat, ); + } else { + activeGuestAdapter?.handleMatchBetweenGames( + matchPairing.matchId, + gameNumber, + score, + loserSeat, + ); } // Also transition the host's own UI to betweenGames get().handleBetweenGamesPrompt({ @@ -721,6 +775,12 @@ export const useMultiplayerDraftStore = create< peer, conn.peer, conn, + undefined, + undefined, + undefined, + undefined, + undefined, + true, ); matchAdapter.onEvent((event) => { @@ -733,7 +793,7 @@ export const useMultiplayerDraftStore = create< if (wf.type === "GameOver") { // Guest reports as backup (host's report is authoritative) - const winnerSeat = guestWinnerSeatForLaunch(matchPairing, wf.data.winner); + const winnerSeat = winnerSeatForLaunch(matchPairing, wf.data.winner); void get().reportMatchResult(matchPairing.matchId, winnerSeat); } // BetweenGamesSideboard: guest receives sideboard prompt via draft pod channel @@ -741,7 +801,7 @@ export const useMultiplayerDraftStore = create< } if (event.type === "gameOver") { // Connection failure — report as match loss - const winnerSeat = guestWinnerSeatForLaunch(matchPairing, event.winner); + const winnerSeat = winnerSeatForLaunch(matchPairing, event.winner); void get().reportMatchResult(matchPairing.matchId, winnerSeat); } }); @@ -773,19 +833,24 @@ export const useMultiplayerDraftStore = create< }, reportMatchResult: (matchId, winnerSeat) => { - const { role } = get(); - if (reportedMatchResultIds.has(matchId)) return Promise.resolve(); - if (role === "host" && activeHostAdapter) { - reportedMatchResultIds.add(matchId); - return activeHostAdapter.overrideMatchResult(matchId, winnerSeat).catch((err) => { - reportedMatchResultIds.delete(matchId); - throw err; - }); - } else if (role === "guest" && activeGuestAdapter) { - reportedMatchResultIds.add(matchId); - activeGuestAdapter.sendMatchResult(matchId, winnerSeat); - } - return Promise.resolve(); + const { role, matchPairing } = get(); + if (!matchPairing || matchPairing.matchId !== matchId) return Promise.resolve(); + if (matchPairing.binding.matchAuthoritySeat !== matchPairing.localSeat) return Promise.resolve(); + return (async () => { + const existing = await loadDraftSettlementOutbox(matchPairing.binding); + const settlement: DraftMatchSettlement = existing ?? { + binding: matchPairing.binding, + receiptId: crypto.randomUUID(), + winnerSeat, + }; + await saveDraftSettlementOutbox(settlement); + if (role === "host" && activeHostAdapter) { + await activeHostAdapter.submitMatchSettlement(settlement); + await clearDraftSettlementOutbox(matchPairing.binding); + } else if (role === "guest" && activeGuestAdapter) { + activeGuestAdapter.sendMatchSettlement(settlement); + } + })(); }, reportActiveMatchGameResult: async (gameWinner) => { @@ -797,10 +862,13 @@ export const useMultiplayerDraftStore = create< ); }, - reportActiveMatchConcession: async () => { + reportActiveMatchConcession: async (concedingGamePlayer = 0) => { const { matchPairing, reportMatchResult } = get(); if (!matchPairing) return; - await reportMatchResult(matchPairing.matchId, opponentSeatForLaunch(matchPairing)); + await reportMatchResult( + matchPairing.matchId, + seatForLaunchGamePlayer(matchPairing, concedingGamePlayer === 0 ? 1 : 0), + ); }, advanceRound: () => { @@ -819,23 +887,72 @@ export const useMultiplayerDraftStore = create< }, submitSideboard: (matchId, mainDeck, sideboard) => { - const { role } = get(); - if (role === "host" && activeHostAdapter) { - // Host submits to own P2PDraftHost via DraftPodHostAdapter forwarder (seat 0). - activeHostAdapter.handleSideboardSubmit(0, matchId, mainDeck, sideboard); - } else if (role === "guest" && activeGuestAdapter) { - activeGuestAdapter.sendSideboardSubmit(matchId, mainDeck, sideboard); - } - set({ sideboardSubmitted: true }); + void matchId; + const counts = new Map(); + for (const name of mainDeck) counts.set(name, (counts.get(name) ?? 0) + 1); + void get().submitIntergameCommand({ + type: "SubmitSideboard", + main: [...counts].map(([name, count]) => ({ name, count })), + sideboard, + }); }, choosePlayDraw: (matchId, playFirst) => { - const { role } = get(); - if (role === "host" && activeHostAdapter) { - // Host as loser chooses play/draw via DraftPodHostAdapter forwarder (seat 0). - activeHostAdapter.handlePlayDrawChosen(0, matchId, playFirst); - } else if (role === "guest" && activeGuestAdapter) { - activeGuestAdapter.sendPlayDrawChoice(matchId, playFirst); + void matchId; + void get().submitIntergameCommand({ type: "ChoosePlayDraw", playFirst }); + }, + + submitIntergameCommand: async (payload) => { + const state = get(); + const launch = state.matchPairing; + const gameNumber = state.sideboardPrompt?.gameNumber ?? state.playDrawPrompt?.gameNumber; + if (!launch || gameNumber === undefined || state.seatIndex === null) return; + let controller = intergameControllers.get(launch.matchId); + if (!controller) { + controller = new IntergameCommandController(await loadDraftIntergameCommands(launch.matchId)); + controller.recover(); + intergameControllers.set(launch.matchId, controller); + } + const command = controller.hold({ + commandId: crypto.randomUUID(), matchId: launch.matchId, gameNumber, + seat: state.seatIndex, payload, launchPayload: launch, launchDigest: draftIntergameDigest(launch), + }); + await saveDraftIntergameCommands(launch.matchId, controller.snapshot()); + if (state.role === "host") activeHostAdapter?.submitAuthorized(state.seatIndex, command); + else activeGuestAdapter?.submitAuthorized(command); + if (payload.type === "SubmitSideboard") set({ sideboardSubmitted: true }); + }, + + submitAuthorized: async (command, acknowledgement) => { + const state = get(); + const launch = state.matchPairing; + if (!launch || command.matchId !== launch.matchId || command.launchDigest !== draftIntergameDigest(launch)) return; + let controller = intergameControllers.get(command.matchId); + if (!controller) { + controller = new IntergameCommandController(await loadDraftIntergameCommands(command.matchId)); + controller.recover(); + intergameControllers.set(command.matchId, controller); + } + const known = controller.snapshot().find((candidate) => candidate.commandId === command.commandId); + if (known?.status === "Pending") controller.authorize(command.commandId, acknowledgement); + else if (!known && command.status === "Authorized") { + controller = new IntergameCommandController([...controller.snapshot(), command]); + intergameControllers.set(command.matchId, controller); + } + const permit = controller.begin(command.commandId, acknowledgement); + if (!permit || !consumeIntergamePermit(permit, acknowledgement)) return; + const adapter = state.matchAdapter as EngineAdapter | null; + if (!adapter) return; + // Re-check immediately before crossing the host, guest, native, or WASM sink. + const actor = launch.type === "HumanGuest" ? 1 : 0; + await adapter.submitAction(intergameAction(command.payload), actor); + const receipted = controller.receipt(command.commandId, acknowledgement, crypto.randomUUID()); + if (!receipted) return; + await saveDraftIntergameCommands(command.matchId, controller.snapshot()); + if (state.role === "host") { + activeHostAdapter?.submitAuthorized(state.seatIndex!, receipted); + } else { + activeGuestAdapter?.acknowledgeAuthorized(commandAcknowledgement(receipted), receipted.receiptId!); } }, @@ -858,7 +975,6 @@ export const useMultiplayerDraftStore = create< leave: async (preserveSession = false) => { // Dispose match adapter first (game P2P connection) disposeMatchAdapter(set); - reportedMatchResultIds.clear(); if (activeHostAdapter) { await activeHostAdapter.dispose({ preserveSession }); @@ -876,7 +992,6 @@ export const useMultiplayerDraftStore = create< reset: () => { disposeMatchAdapter(set); - reportedMatchResultIds.clear(); set(initialState); }, })); @@ -995,6 +1110,7 @@ function handleHostEvent(event: DraftPodHostEvent, set: SetFn): void { break; case "matchStart": set({ matchPairing: event.launch, phase: "matchInProgress" }); + void retryDraftSettlement(event.launch, "host"); break; case "roundAdvanced": disposeMatchAdapter(set); @@ -1064,6 +1180,9 @@ function handleHostEvent(event: DraftPodHostEvent, set: SetFn): void { sideboardSubmitted: false, }); break; + case "bo3AuthorizedCommand": + void useMultiplayerDraftStore.getState().submitAuthorized(event.command, event.acknowledgement); + break; } } @@ -1125,7 +1244,15 @@ function handleGuestEvent(event: DraftPodGuestEvent, set: SetFn): void { matchPairing: event.launch, phase: "matchInProgress", }); + void retryDraftSettlement(event.launch, "guest"); + break; + case "matchSettlementAcknowledged": { + const binding = useMultiplayerDraftStore.getState().matchPairing?.binding; + if (binding?.matchId === event.matchId && binding.revision === event.revision) { + void clearDraftSettlementOutbox(binding); + } break; + } case "kicked": set({ phase: "kicked", error: event.reason }); break; @@ -1172,6 +1299,9 @@ function handleGuestEvent(event: DraftPodGuestEvent, set: SetFn): void { sideboardSubmitted: false, }); break; + case "bo3AuthorizedCommand": + void useMultiplayerDraftStore.getState().submitAuthorized(event.command, event.acknowledgement); + break; case "bo3ScoreUpdate": // Informational — standings update comes via viewUpdated break; diff --git a/client/src/stores/multiplayerStore.ts b/client/src/stores/multiplayerStore.ts index c214804cf9..42d2e093d8 100644 --- a/client/src/stores/multiplayerStore.ts +++ b/client/src/stores/multiplayerStore.ts @@ -554,13 +554,15 @@ function resetServerHostSession(set: MultiplayerSet): void { function savePregameHostSession( get: MultiplayerGet, - data: { game_code: string; player_token: string }, + data: { game_code: string; player_token: string; full_key?: { game_code: string; generation: number } }, ): void { + if (!data.full_key || data.full_key.game_code !== data.game_code) return; const existing = loadWsSession(); const hostSession = get().hostSession ?? existing?.hostSession; saveWsSession({ gameCode: data.game_code, playerToken: data.player_token, + fullKey: data.full_key, serverUrl: get().serverAddress, timestamp: Date.now(), ...(hostSession ? { hostSession } : {}), @@ -574,6 +576,7 @@ function clearPregameHostMetadataFromWsSession(): void { saveWsSession({ gameCode: session.gameCode, playerToken: session.playerToken, + fullKey: session.fullKey, serverUrl: session.serverUrl, timestamp: Date.now(), }); @@ -586,7 +589,11 @@ function handleServerHostMessage( msg: { type: string; data?: unknown }, ): void { if (msg.type === "GameCreated") { - const data = msg.data as { game_code: string; player_token: string }; + const data = msg.data as { + game_code: string; + player_token: string; + full_key?: { game_code: string; generation: number }; + }; savePregameHostSession(get, data); // Reset reconnect counter on successful (re)connection. hostReconnectAttempt = 0; @@ -715,6 +722,7 @@ function attemptServerHostReconnect( data: { game_code: session.gameCode, player_token: session.playerToken, + full_key: session.fullKey, }, }), () => attemptServerHostReconnect(set, get), @@ -922,6 +930,7 @@ export const useMultiplayerStore = create data: { game_code: session.gameCode, player_token: session.playerToken, + full_key: session.fullKey, }, }), () => attemptServerHostReconnect(set, get), diff --git a/client/src/wasm/engine_wasm.d.ts b/client/src/wasm/engine_wasm.d.ts index 4f85978853..a9d4043b3b 100644 --- a/client/src/wasm/engine_wasm.d.ts +++ b/client/src/wasm/engine_wasm.d.ts @@ -108,13 +108,6 @@ export function export_replay_log(): string; */ export function getFormatRegistry(): any; -/** - * Engine-owned AI escape action for the current waiting state. - * Same deadlock-safe fallback the search path uses when scoring cannot choose. - * Returns null when no legal escape exists. - */ -export function get_ai_fallback_action(): any; - /** * Get the AI's chosen action for the current game state. * `difficulty` is one of: "VeryEasy", "Easy", "Medium", "Hard", "VeryHard", @@ -123,6 +116,15 @@ export function get_ai_fallback_action(): any; */ export function get_ai_action(difficulty: string, player_id: number): any; +/** + * Engine-owned AI escape action for the current waiting state. + * + * Returns the same deadlock-safe `fallback_action` the search path uses when + * scoring cannot choose — never invents from legal-action list order. Null + * when no legal escape exists (#6393). + */ +export function get_ai_fallback_action(): any; + /** * Score all candidate actions and return `[GameAction, score]` tuples. * Used by AI workers for root parallelism — each worker scores independently, @@ -456,6 +458,12 @@ export function signatureSpellSelectionPolicy(request: any): any; * applying the action as another player. */ export function submit_action(actor: number, action: any): any; + +/** + * Submit one opaque, engine-authored interaction response. The browser never + * materializes a `GameAction`; only a successful engine reducer result exposes + * the exact action to the replay recorder. + */ export function submit_interaction_js(actor: number, submission: any): any; /** diff --git a/crates/engine/src/game/match_flow.rs b/crates/engine/src/game/match_flow.rs index c99e2f7855..b9aa8b4ccc 100644 --- a/crates/engine/src/game/match_flow.rs +++ b/crates/engine/src/game/match_flow.rs @@ -4,7 +4,9 @@ use crate::game::deck_loading::{load_deck_into_state, DeckEntry, DeckPayload, Pl use crate::types::events::GameEvent; use crate::types::format::SideboardPolicy; use crate::types::game_state::{GameState, PlayerDeckPool, WaitingFor}; -use crate::types::match_config::{DeckCardCount, MatchPhase, MatchType}; +use crate::types::match_config::{ + DeckCardCount, MatchForfeitCause, MatchForfeitResult, MatchPhase, MatchType, +}; use crate::types::player::PlayerId; fn opponent(player: PlayerId) -> PlayerId { @@ -266,6 +268,55 @@ pub fn handle_game_over_transition(state: &mut GameState) { state.waiting_for = between_games_sideboard_prompt(state, PlayerId(0)); } +/// Completes an unfinished two-seat best-of-three through a transport-trusted +/// match forfeit. This is intentionally not a `GameAction`: callers must bind +/// the forfeiting seat to authenticated transport identity before selecting the +/// closed cause. +pub fn apply_trusted_match_forfeit( + state: &mut GameState, + forfeiting_player: PlayerId, + cause: MatchForfeitCause, +) -> Result, String> { + if state.match_config.match_type != MatchType::Bo3 { + return Err("Match forfeits require a best-of-three match".to_string()); + } + if state.players.len() != 2 { + return Err("Match forfeits require exactly two players".to_string()); + } + if state.match_phase == MatchPhase::Completed { + return Err("Match is already complete".to_string()); + } + let winner = match forfeiting_player { + PlayerId(0) => PlayerId(1), + PlayerId(1) => PlayerId(0), + _ => return Err("Forfeiting player is not a match seat".to_string()), + }; + + // A match forfeit ends the match rather than manufacturing a current-game + // elimination. Retain already-earned games and make the winner's clinch + // explicit in the frozen score used by terminal presentation. + if winner == PlayerId(0) { + state.match_score.p0_wins = state.match_score.p0_wins.max(2); + } else { + state.match_score.p1_wins = state.match_score.p1_wins.max(2); + } + state.match_phase = MatchPhase::Completed; + state.match_forfeit_result = Some(MatchForfeitResult { + winner, + forfeiting_player, + cause, + }); + state.sideboard_submitted.clear(); + state.next_game_chooser = None; + state.waiting_for = WaitingFor::GameOver { + winner: Some(winner), + }; + + Ok(vec![GameEvent::GameOver { + winner: Some(winner), + }]) +} + /// CR 100.2a / CR 100.4a / CR 100.5: the size bounds a between-games /// submission must satisfy for `player`. /// @@ -603,6 +654,67 @@ mod tests { assert_eq!(state.match_score.p1_wins, 1); } + #[test] + fn trusted_match_concede_completes_bo3_without_entering_sideboarding() { + let mut state = GameState::new_two_player(7); + state.match_config.match_type = MatchType::Bo3; + state.match_score.p0_wins = 1; + state.sideboard_submitted = vec![PlayerId(0)]; + + let events = + apply_trusted_match_forfeit(&mut state, PlayerId(0), MatchForfeitCause::MatchConcede) + .expect("two-seat Bo3 match concede is trusted"); + + assert_eq!(state.match_phase, MatchPhase::Completed); + assert_eq!(state.match_score.p0_wins, 1); + assert_eq!(state.match_score.p1_wins, 2); + assert!(state.sideboard_submitted.is_empty()); + assert_eq!( + state.waiting_for, + WaitingFor::GameOver { + winner: Some(PlayerId(1)) + } + ); + assert_eq!( + state.match_forfeit_result, + Some(MatchForfeitResult { + winner: PlayerId(1), + forfeiting_player: PlayerId(0), + cause: MatchForfeitCause::MatchConcede, + }) + ); + assert_eq!( + events, + vec![GameEvent::GameOver { + winner: Some(PlayerId(1)) + }] + ); + } + + #[test] + fn trusted_match_forfeit_rejects_non_bo3_and_completed_matches_without_mutation() { + let mut state = GameState::new_two_player(7); + let before = state.clone(); + assert!(apply_trusted_match_forfeit( + &mut state, + PlayerId(0), + MatchForfeitCause::MatchConcede, + ) + .is_err()); + assert_eq!(state, before); + + state.match_config.match_type = MatchType::Bo3; + state.match_phase = MatchPhase::Completed; + let before = state.clone(); + assert!(apply_trusted_match_forfeit( + &mut state, + PlayerId(0), + MatchForfeitCause::MatchConcede, + ) + .is_err()); + assert_eq!(state, before); + } + #[test] fn draw_keeps_existing_chooser() { let mut state = GameState::new_two_player(9); diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index fb6eda280e..e562d0b16e 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -35,7 +35,7 @@ use super::keywords::{Keyword, KeywordKind}; use super::mana::{ ColoredManaCount, ManaColor, ManaCost, ManaPipId, ManaType, ManaUnit, StepEndManaAction, }; -use super::match_config::{MatchConfig, MatchPhase, MatchScore}; +use super::match_config::{MatchConfig, MatchForfeitResult, MatchPhase, MatchScore}; use super::phase::{Phase, PhaseStop, TurnDirection}; use super::player::{Player, PlayerCounterKind, PlayerId}; use super::proposed_event::{ @@ -12208,6 +12208,10 @@ pub struct GameState { pub match_phase: MatchPhase, #[serde(default)] pub match_score: MatchScore, + /// A trusted transport-level match forfeit. Public game actions cannot + /// construct this result; see `match_flow::apply_trusted_match_forfeit`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub match_forfeit_result: Option, #[serde(default = "default_game_number")] pub game_number: u8, #[serde(default)] @@ -16729,6 +16733,7 @@ impl GameState { match_config: MatchConfig::default(), match_phase: MatchPhase::InGame, match_score: MatchScore::default(), + match_forfeit_result: None, game_number: default_game_number(), current_starting_player: starting_player, next_game_chooser: None, @@ -18187,6 +18192,7 @@ fn _gamestate_partition_is_total(s: &GameState) { match_config: _, match_phase: _, match_score: _, + match_forfeit_result: _, game_number: _, current_starting_player: _, next_game_chooser: _, @@ -18476,6 +18482,7 @@ impl PartialEq for GameState { && self.match_config == other.match_config && self.match_phase == other.match_phase && self.match_score == other.match_score + && self.match_forfeit_result == other.match_forfeit_result && self.game_number == other.game_number && self.current_starting_player == other.current_starting_player && self.next_game_chooser == other.next_game_chooser diff --git a/crates/engine/src/types/match_config.rs b/crates/engine/src/types/match_config.rs index f19adf7853..0e9f4467a1 100644 --- a/crates/engine/src/types/match_config.rs +++ b/crates/engine/src/types/match_config.rs @@ -44,6 +44,26 @@ pub struct MatchScore { pub draws: u8, } +/// Server-authorized causes for ending an unfinished best-of-three match. +/// +/// This is deliberately separate from [`crate::types::actions::GameAction::Concede`]: +/// that public action is a current-game concession under CR 104.3a, whereas a +/// match forfeit is admitted only by an authenticated transport boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MatchForfeitCause { + MatchConcede, + HostKick, + DisconnectedForfeit, +} + +/// Immutable result of a trusted match-level forfeit. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct MatchForfeitResult { + pub winner: PlayerId, + pub forfeiting_player: PlayerId, + pub cause: MatchForfeitCause, +} + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub struct DeckCardCount { pub name: String, diff --git a/crates/engine/src/types/mod.rs b/crates/engine/src/types/mod.rs index 7e3c175c8b..a30fd16ab0 100644 --- a/crates/engine/src/types/mod.rs +++ b/crates/engine/src/types/mod.rs @@ -67,7 +67,8 @@ pub use mana::{ ManaSourcePenalty, ManaSourceSelection, ManaType, ManaUnit, SpellMeta, TapsForManaSelection, }; pub use match_config::{ - BetweenGamesPrompt, DeckCardCount, MatchConfig, MatchPhase, MatchScore, MatchType, + BetweenGamesPrompt, DeckCardCount, MatchConfig, MatchForfeitCause, MatchForfeitResult, + MatchPhase, MatchScore, MatchType, }; pub use phase::Phase; pub use player::{Player, PlayerId}; diff --git a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz index e681f411b33f2ed48f6d3ae06312adde2a346590..8996eeb9892c2122e8ee15fc077bc4b867258889 100644 GIT binary patch delta 25519 zcmV(xK2@~9z7h~~#W zNKDAS2l~jv<-vi613}&Xs0%!(D0FX3HC=;15vg*xD$!QlcGgU9?9U?2UExAijhIQ*Yf4l3N+IT&(wl@n| z$GC*-ef1jHE+t#knnkU7SheOi5IeAqeuOFJ(uD;=K+nMNv*;iDCY}g>l*`+nO5|`= zC?llkasc6)?rPO+x|L9N2VZ60+_(Y#`{7GT> zf=)=&o1sOTHsXW&&6dpKCA0W`N?I#8c?{bW{+CsFfLBa3(newrdYxIkH;c#l(LBzw zip;J~QtlY`8<%q{Vp7{5NJGDy_2Pv5FW%cl{ulY5Apf3omHc1j=5>sRbMyW*m!kj5 zw+W^p$($tIe?&h{+=ggF@ha2xe)zi%YY?@wQW>PoqwuTTs`2mG|#Y3 z?t@-Ihh2^Ij-U=Cui_@P_!@^!PI|C2O}u>o<~rihlE1=yRycdD{eDuTgwA?&Jpuz( zy2F7fD`vGu5MZI$(>ya{L|JfN{z|mYSow~>it|u@e>#DX>kSgRmtsvv^M;#>a}Z+D z;68~T^j3W|)MI1-0mcl2TkF`zBfD7N{@3QG&mBkEbIj0^Eh{A|@C97RppPR`bQ`OG z`=U>x2y_Ysbzu&-%-5J5p5CbGt0YnAd8$4!*lC^PPpw*f( zf4Sl0uZ6PEc|+32MRd+4Dui`S`~AOF<4=76j1DQG0f?&eEGgiQAcls#Ul|~SaJR=F z5w&Hk^DV}f0MyZmkJ~^icI#AZ=|^*PlSHQZD~`F_OXYDj_^(6o#EP-g@AOcScsGkA zk&?#yg3{Db48{}xl?JZ{C*zvEFc zD{4;GDaQu~nUqNCF-&vNkR~0Oej>&?Nm{>5K1e-u`d zH7uIkdal%LWfc(Fr08;Gxku44Ol1_?%x2pGtf7KS()rC>EL`6o8Iz)^F+~1Celj-ByDuyt#kyl#n*@9?*iAC7#*qQs7{ybgq8 zuBv?o@>5}^x%eg)6H9_Oq7Q7-3cp0>^GCSXZ(wqh5j_`w^kLgNcGDDw9Zi0M&_riQjCg5&|~x*u2ftIcs3i0}u95#_J07mpscq;hF{< zwmb3*igRpqy>BP*oi7TA&rn)~w1gs2t(z-}L_4FK$Cz1{MWkEz!b2WF6aOz5n063# zG(rm)A~Se@_TzECE!vweR|W9rzb3`+V=b!4B8K@dupsFZPFNe0HtDPv2mBLO1ccMP zJ^H^m@aM-&Y;e_paY}^!JBVegT|9CKC-)ezUBOC&0cwy{xEql zXkFnGpy4O-g*%?J>Wj~>O5s=gP1nwr7tkL?nsb%kFkN|KkxY36mP7U-YMxDkg++f>OADC8)QS#mH(mFx(1Fm(%U4ateH%UQ}OKqE#IHw{S zQjtPH<-rJtgPYu7rDMM_4a-^OYi_$bB_7p(#LA1N{UYbu5q?xSOf+TLy6*?vcsb9oSxSO;N^J%A4J>(!A5k? z2%A;6z=@~~1;nsh2d8=6ynVfY`ooejF;o>>-RYMK|16v zBW^(n$G4DzF2{YS)%R&SCI!V)HgOmO*r7{qo!51~ z#n^-jo~7K_e#5dWxYf0Y=&a;B2*X1AO$ci;_GNajRPYEpUKkEK!q;flpj2h@P`~9> zJ&yD`P3pgrH6&Pxl#`G?3rn~{-Idiis@*6RUH1$i4Pu3w2|2Xm9X43(m}RDOvH864 z00HzX)C}GPV_HH4XcY`xeN{wz?*ApiH1)E|KOWs!I(w61K{*1ZER&roW#}F@4(gc?0ay9 zGvE$fM>mI)DnmVgco#_WhJPqy?B0iQpgWGR{4}=H54K0|NhcDSJ3L0Rq=G@DG@!8- zy>bZ#k&;md_AFe)uJvr2 zoFy^|n^e#VK!D!OFwLV_Ty0DbZceECH<3(R=eYA#ODyatskM*Fw0?98zC0i53WCw~n% zN^%h`Hj+Ajl}_guW_QK^4CACCkAxz^a1Wn{us;)F({$z`Z2Jo7%4mKnHXD`r!)_x@ zd=I~^7cq^+7C@h-+%j7&jy|18n5-Fu20ol)+_q8Y%7_M*dZGv`GZ6LE6ZLJqB`Rwb zDx>YpN87RHqYbByw4$k_87QJTTt+6a56Z}lX}$7)B?}Wxzo%G?d<{DNFtkq8Uh!lv znG}vPMp0310pUtDLl4y#3i6x`c|pD3~+x9a?stZr+rgVce9RBPUa` z0RZRan!SiVv&bQ9hRgqw-7ZLKI+M|?Dm zdd#9!%neUIma-z|i`T&I%$izumVHZhZUa1XvBMyBenzWV{H98qtt)|He)e!m2u%&l zJE{gu4C=h#4I|YMeT7*KMfL_sfT6FpQTZN!vdvo#2RQe!X6!R;Q#>YeOkV2dh~^~< z8j}%yM|I*<^8jg>n}=sAT9Q2CnxdjBB}cSY0Tcnc^AX3(a8d5E=17PS!!FgSqi<1} zF@9sV6)9e>&|E=IkpE7ZD zlH$uhnJ6a}!&K2Q-___try2@erL$L92ns)3=jg7Yx)0pBM0E)!a;-xuv=F9$AWQsk zQ8iC=lmIRY;8#R-2e+gRSGFuiJx#CKfN=bYFQ!q)NLtF5msQ8nEe{GPnRDS|h15;M z=~1UIRMNLh<@B2Z;HGcNn2&z_Z@%GqL{e+Ct0ofhWHuJ(Cc_-I*j_F{XIeDf ze%G+`&Bl1g@0Nn;q3J`Uq0%#dF($Ap;7|I4=s>`(~UR^H!YPn{o2K+I#D~Ec4F^)Pp&xo5ro=#x!rn$-33bn@!x^?3^6qW}I$? zZ>D>*+4J>VaSCp=_bmNpoQzwolXWwUu&kSLy4Bfgo9&gNC|S&2;7754;3`7Rl=8E> zsUdlhNMv2? z&}8y~+4LhLH&Qw}ZJ^VCOUiVbOi#QN)5y`&iCMCfiCb_BZPG}Uk`H6H-tht_mb~fG zM+(-mL)$iHqwO?jf_mECe8cu!cl7}%9CtDsfRZBO!EEF9-7};wcWrRWvQk8+GOoS=grAHkIf!P?(U?e}a)UAEUu`8G+@WZkI8RA4Pwp zN%mgJ4w>vnf$9#dOOlHE6g(;dD-!1Qn{a<&F!8ZEe}x7lnnqTlfQST;EXn@7tE=4< z*dRA+B|Hw~aG?x;5p2!J%R6vCY}4YPn#{(Bm1&Y*`RCJVdZv1sf%sEFXc3y8OEu8FQFNZN2B1eoVtye4R!f7pGY$*l8_Y4QBC&yo(esIIx2gR@f`TmjsKTjg4mLaP(Uf5e9s|VHG*q zhB;=~88UeIG>;IpP%30Nax?TN zhliTsL6FBgAWq+s`AT-ZAN5LGmd3nxhc6=`@FVQ_U8n42d@PF_DouG_~MIe6%W)blIwF>ySzCm$*)Io$^fFk_Ho5Qzx zSx^GB<+!V^6Ji{>B1%eGum#jo_)ADoHJKg;m8qm>>#KFiqW2I#n@-J{PVFtKnj-qn z3jaiZjeb&$dWWX%3zGS6T0L1TCBrsvGGdeGOj41~Zjl+-l~WVD`ixA6ib;VueMafK z5{js>DWzyp0_+E7Fj5CKQZexf6~sIJUq$>q9LhCC_fo~H{N+Ujo4`9z-+UlNa!hm9Q{PTi#)I+jR*VV7bi|q^KUU zaf7z;0Hs{WOP~_+R+9tIDu0LDsicyG$Y7YR!`vbW2~s6ky@}wlzu&|9z`} zrV%!MxEf#svZjnN^;s;ViF7S@fG+#XE-A)uJxQC&h!Aalk?F9QL>LqZ%+c*$k0v{C z1?fQd6t7>wHWZ~UGT?$$`$;P-BWYuG79k`}QV6D#PbFtqjZS@BazBH)iKbNEZ~54| zu5#G6@2Dh)B?(oMGu4jHRyuc1n~0`=gd9~YQb*2L`?7(hlAPOCFHu6St%sq6oUL>l zyPnF?`E3}V0&0P)l$x&uRFloS^m7{0z}r2!GQ38hfmdcW$)8MTXzNn5tXK-iBc=@V zugB4@%P6bxY!r$4&EHc`vTko}xXRg}Zl^flCZI$Rze;~TsWQ51h* zi^r)1F(Q1jV59H9BEq~hFfZYxuY`*c=~0JoGEICPoOskZoc+KC?dT^>5!f#6TOB1~ zwJVCete>}eEE>%;S_Szfb6!~+$0QR3iZWkczgdA;x~~!}uE5_|_MTmT!F`@zIH{yX zvKb7Tyljq#nP358_7|Al4J+#Kwah=Gb-xJGB|*ujXuEUKHvNDS^0}vRSBLV;v9RB5 zgKGMQx!Od%K5}q*{uG(=qn3DpiRsPH1f>(1ys)TB)))p8?ZY=Twr04i4!Tb(;%cGa zp?S5Y$d2llv#7tpENt3;{1GE?{S`Ny;Dmvu#B1&z)CajZa{nWNb)eeU*^RDW>L}7xK z6m8q5^UsW$-{kQwWz7f%;&6y5tm6<%(P|@niMB~f@A04Xc%{Lo8ff$E32t^z|5tl~ zW3xK`9o3=gAA)~>YyO23bsb&$4bjA(&{?g)6++|XLttH>MJe51v`9l8L8HKIjdZ;V z=%#zEeq$OhkEH>NB#$*d1NiD6tki^K1xKRovXZ6fZV}J??prgEzaMR5^88m+EEqJY zG}+L5RSYHqbXFWE(80C+MJsSvYH&Zo?WBQJ z!CUbcqD)O7lfY*f#f~ve-#YuV%y;$WO2}7LUOhj!qROkN}n-Sn0}2c>_@^QG4Wzh=9t-V zJ-GB3!{U8^g)ILA&uZ|VSr4|a29hB%L%0E(x`e|t)^LoPtm^t5L=txhyq(5R75pG7 zx$lp}lW#VV6PzCYaqF(V9v}o~4M%Y>GCb@7321i_BK(?0*^_Q*az=mYV-|lEj0A|_ z%W^U#9ChPygR|{%xZF)In+CVui_4~6lN5c1t!Z6<&K3(UUZ>^h7HDGp8cXm&3S>7P z<)9bGIS{)JO$Ssr!}P36bhnafanAv#O@&oB%(g66383aV05ukc?G6eXzJ%5ZsK8pC zuP~(;>rX!ys?vI|aftv8NPzA?g8tR5Lu~o02%RV5qa*OS1{stJt?)~<5n+`|!FA6G zu5+<};K~G#uH^EZR@7;VT6j6=6u+4Sx0S5pGdj_~@@$euPGH0ut=1IDJx{bRwrDnm z%Mv~KOg0IvGi#fupsw#EGD3mDZNW5p^gD=D^@-Oj6fIuXRa?V4#!Zt;>Drh?%8k1v$=FMs^g zRO#+olsqeNWRkUzAU2eEa-n|FBkfp*deUtNT-$M6UT9i6qmOc^uhxnz40@b7o7d6a8^{eXgghh zo3>2<72?Exu(BQvSY67#b3i#ulYTfe`%$Bxl`;z}m{~TCkAIHO*jKPjMAh-nW#5)P?!sxFL3@- z!TENVrparR@!CY@>MuLCV}pWpIg)gLX}Uaw8xwa1iFPqIFXptw1sB=jX7g$^-8Dhu zS9+DV&gz(#qmsCetPVSZqOUy4G9bvU@VA4c_U`rYaP+T#c!J%ob?50>RO-_<9a<8Z zVr|V}6EgV4UPC8nso^z{jBx}Fys+R1ERx=e#vRMyPb_C|gc^(xJq}t_Ye`*yY2u)d z8z__>kR1N!6PB0t-L~Bq``yrH$k43?WBSuuQfUcC^&dEXYHDe^T~3l7(Z~tFz=~y~%Mb{I=rVk;P;rMb}bH5i@0# zQuNF(Oi<$}T5Wkv zMAFlBXUT z>03_Qgn?I*Xse8jAHSu0URxBKs+GwapDL&uMtd#eQ!j719091p+Y|=a<7#;LaZ>S~ zEHc4p>bqENZ5e@ZIg3j`n+eSW5I64=$U6alz#yip9PD=yo5u@W zjwkP-#!ck0PYS(7jDFl*IgP443srRPtETG16`peT@)vBojJK|LN--CSZT&DqvJ8!5 z<9*HM>NnsmCMr=mL~d)ID36xGF{f{bCPrRvqx~BDYwRj+@FfiH3gs34v3^@2&+%7r z9?DPIwT?EP*VEL0Wj<~qav?}}sWlC|R8aGEUmZ19Zwfdopyv6uqmo*n@J+p-X?Q58 zUF(6Roi9ql9GYih`u1wfKceiLYAK&+c40f_Ma?d*n&p$s{4bb2h=-aEKGG1lx~4Q5 z+d1o=wif^N;-CI$!#c4cr4VrRo7F5$#~Hpwm2V!WC`q+{vZ>_C={lW$T5V=H(w}5h z2wI2wtXDnDu~%Ggs-jJaz{FHsU`#YL6$CGyIYJE^wVkkuAy%`=jmU5stcf%Ws=xO60=NXhngOr8Tlb! zTs6e4cADCMqjLPV?!-Xrjw>n0tSSAWg0yRy`ZSGisQ(`pjpOq7iCJ>3tX%C&`-+L* zfXN9iqm{HFc7Cg&?Xnb$!>=g-V64_T#3N`0;ef{kF*|NOPQv7;y1K9TE0DIry*h4QSvqMwgLW zVK}<%?A2rPtwIzI=m5Xcuvg>k#llzuUzdx;PnO3ZmOP4M+3WVO==^Xa@=H?z&e8{! zL;-!?JAMEHWL+Y4EXwHfkb&!}qu*lt5f@tlm!hQnu1@6@L#En^o)Wxhjc72Sl#|^S zV;(_&_qeTtuDY#5w-~2)VLeMxvf#S%jlox?7O$sh8rBts7Dv#>x_YO|ES{~|a2uOq z%wAZD&dfA<5o)e_qj)$Z>}s3hKvbXM&m0=*ICBS(dVzntNV>qb=3>@= z#y9Ab&Bq;kBmCbAH0C4=&z{rM44al~Ud(9Bi`aTFFa0YfN{w6E#J(~rJ;m2%B06)} zimt88ZTN<-uR6pEtdd4Ekzm6+Yz>Yj`0yeQ8bmXu3zbsd+^q5JJZPQRMH5}DQ4i$i z?i_he8~k%9OOxEk6oWmx``8F9?<}8x2Z_mH^|~p?zZCyjU}}M>D=tm8M>pHfeds+H!l@B&)#>5n=wG#Z zxxOau9H^Wo>k?!u6m^7U`Dm2@Z7t<|7J&XuR%aW&_j;D&ttv*1fX|4T>fr2u!0jZI z!Y06Fi6v8>@zIF`xj*)7N#1#Ba`ny>S6$HLErC2yyHLM>Yt#QeuX%>L4^4BKujXLLO~DQ9DF>=aMOxRB@d zQT|7)tXr}2oVHPV)aDA^Tf5vQWwhZvniX3d7stqglD|=u#NboPbMk@}j|~c6k`j~o zF)9_;k!8Ggtq`yA)w^Yr1m2Fp!@9H_s_<&um)=pWL)(_ia4CLxI~)yvO~ec3F~HM; zSqo+@m~~-jpY51b6PEOyuS9ATk{r4Y^uTLU3c^>Isk+N+e3zPz>!4|`_rQ?Vbi9L! zj1m%4^}5#LJXmn+H>zuO8{`3|pul&E0Uip~_4l+MeQc1D?!KZwC5v+wcwr?hDq8w* z(}h)<*9)*Lz_I|#1<}5Lwre<(Y9&wvh--NA5(rw(2iv1F5z|6QHWO-e`gpLxbJLpT z3T0Q;1w0n;Sis|gTuiv!i1PO+N|z%?L&5U(gH(f)Lis4h;%;hkr#y>#PutQGctMWe z=zT_*M_kviR-hof){@D+r8D z*-Jf|oeu}olv3z_Lv{SHDGX*~!|rUNL)ecbWmjgv4M*;IZ6(>$k$YDc=JBx_O_3BZ z5gFE_%Zmx597h!?y!D9BWStyQEeig*c^gmIw#~M&hKS{UK>+J?*#H7l82B?Ya5^0u zyE`G-7U>9}IHC_yewzO1VY|9M2U*WEW+CglD#$jsc;7jH4x6|^u&o)~G1#`7Rly5a zqYk-*VJw>EqFG*^IT~2{P~8tqA+2_sO~>pvDp<^NkJ76K5 z-q&DJPqCk3rnD%XdA2B>vB1^>TR)}Ma!`8s)zRSk&zRfr37V$fBuw#cULY!>sa0ZA9b%`>sDihjo_Rr>cdi`s$Rr~bs6BK z@Kvm!ZM`Bc1M$3zW4rd3EY+OGp6LgVeHwS~&ooG-N3nkHC=QmBcsYrGu9Nt{Wb4jo zvh{UynhkXrl?=yR!TjLWW&i+`F$v~pf=9XR8+B}d3H-_%IF+$)UC3C?i`u&V1wS(5 z&sC+vHk+>Ev#ld40B3pJ{j4mShHxc|ccdpQSx?Jpxtx|4Wj%SG=O1M~4at+X&m?&HYCKUAR=c9e z%UY_LzOFap;n+cb3&IjHEELK$8+i2@14C+m5Mf>e1vS7WA!LEE1;(z__Z;27Oy9e{ z!jIvZX4CKxrF7Co)HCgq#NcKMh;SV_`>?T3A55mby5~8Me`^-Ned8pJ%k@8b-u004 z0|p~coP4v{l_kqI>~_G}Ii|lt%|G$Sp8xYdey?rvRCrsbK;{{WeZ1r#Sa*KXnXC4H z*Vr?V$81p#!gsdEo`d^-55mqb;j#d5SC0Wrt_(bLQIfqFTgosH9HnH?>vsrWNAg`X zDszU90|xFgZHtC;G`uN_J3%87UFf*+i%TjT)QtT|xE8nrs0K7|x8eAtHq)@26&BfE zGk6a6_e%P~D2d`#1SpNG$ZkI>fV^AUR1v^)!<5jbwY0K=5#bG{ik zaE9cdo+okGoZ(3)??h~oQFR`yz+LeLJD_&7Y@^6yu*;m}?ODk?t~nEVJOZTC)om^7 zm`}9D@8HN9xvCw?LDL~r&;mHYZzmV&Zdf?F{WXhg2T!QSRVV*|@-dF^e~Hh3tMRYX zFtse_EK_)~ypo`%fT!DO@i~2WHDt|BVczNQ5Obd_N21OcimeD?%@x-CvfqxQCX?$w&N7!2AH4z%uHG*E0oEeC}>$zq0& z@doM#LwtdsRg){(^wCQ{hhB%af=7{BYKx;Zc+@{Dv)&@K%bocrxicHH-JF{>4{)M;PNtbEM4=%mYP2yvNetQ!>)@}G;tK^yabS|!ccJTF;_<9zd zdy%i=US#U?+>3NKXs?5VO2Oz0mowjmE12)X<@|IPE;t^udWP&DLtXaIqQ5Qr+kN#n z>oWaqL=4@ba$@U!9$s3&v{8G1e+6L0IP#{&c2A~y z@t!>&%r%T0r>}c|!Q_&t&l6lgedXsxix5=B^qBhnAq>+CL@Cjw8OwrO3vOLvb%z_O z*$*VV+!oM;pc}R)G$$^FZ~?4#Yf!_*w!XXh6xH8{E#Xh|czh#-PwxT;a#_E;3&Jzc zya+Y@NP{g1e@?=GfwMIGFp$9`u~xS~>Ixo<@xDg_hB!bQCf49;R^=f2v>o6-Q3>*z z6d<Dow@$21HisYgJ;0011``TiZo{6`!MvWv9Ow>)JUza%~9>p!D#qv0lk^(fVe zxS?B~C6YMkfXC>Zkp6Dlb6g!o$tRshmm0DZ3?9Ex$-=0ArVC|RO(5%=P248~4B1a0 z8ng9|_whAl)HHo`b@RFw^6XASm62_%Obc$~lzb^DjiKS_6Psz7j%8dw3a&0j9W!8n zQQo~l%xHAm%G{-eo_-9zXMVNvlwm&|0x!Q$4VjJm=u}l+xG+xI zkZtn<@}o!Z8P?geb?`mzebOdO+@`9qeoHD+(;u{AbIM{g!yKKv^zp5aU1ECqsY{P@ z$5Wnd*i*sEr~k{-BPX4@m(U$Lb>G=u;}C{kK)Q`b=`R>0g$_EhMQC&)bj=yuPysA| zl04S<41Dij7;q%oE-P6|mJcyY6Rsvao($LaM9L{Vz+WG|emKh4G+%@E0H-)^C+&qU zF8qb~c~7m!Gzr>z!>Lkpvu-3UQ05J1!4;Zjz4}P?NmBnItK=5}2V=xbybi@?d>8GLA@~ zp7sGJQ{HtJw?L8QHNE10uO>xe!(SEN=bi-5Ml<1IHtSv8=p{1zoF~|QC2(5ApCu54 z0Y>Q)ecTao4WVhOjuO={?_5#m&2(3=ep%T0xOXC9R0;fj!2!7D`4_yj<%-L4C_WrX zveSahuuaQf0S@pWf?kX0PHDtKX>EUHV{t_XM84o`-EO`d(^@67?Pgbfr!$ZXZ0Tb1D(N;g0C-S=KA1*pmWl#- zQ_1&Tk&bVFo^(ZCL$!^+_Dy|c=3+}E(1S*eA<#|X-;;+Hz)?2`Ro9q-s@3LM@$Ai% z3>Tue;w1WQ;+541L@)754RKeuhNX?J@~W7aLH|%tK>Sq9mwQiBHoH+N)!=Tk|W{#VCRKG4f`si`+JU^0JAH$#LWCQZ*%2ta&W#6{H~3B?amPIJ`(B?kkN3q9?RUqF*a@0oz;3SS)l>&-ggCnA99gx-(ncDhM_z-r zl>1A6#L4C(9caU|GOxScM?e{Lw~tUg42&Ph@^Z@y@s^BCK~zlQ%2%7}`H`8`F?Zps z?m((Alu1ijK-YPAvnb2x#|G+SE?brMK?NpP@SZ}%77dcAVucz|GmeZsisJf;W` z5xYlojZqHh*I^}k(>K-VO1+(ua1FC=z1~isK$NEWMnFZi&)|m@=;&I!!)mY`QTEDz zF~V*zqO-du!$V-+JxJN}?;fPgAxH$^;Cn^oWnvW9<*{j%JdkYL7D%dKqCr7nu0AuWQGt0aJbA-aw)_|Ov%0*wkFU9K8Q;0ejqsE zd+s@AI6eI1#u+vOQUqs;L^>E51+~e4Xe`8>U(+ai(k)HS=%HpJVzq?s@&C8?Wj&4~ zNt*BdD~Nnq>>{Ir_n~@f)GP*tshKWx_tO9if)c3+R#Kvhlrl1U(EokyaOa^TLZmwt z%4!0Q>I{kc-06q8nYr0#wRXWmnXDvO#0F3kDVAvl--pW4gxJCE1$7hS+V5U}@t8OL zyyq*H;DXu&N0V`$WD}&v;^s5JVqZxo~3t6vo~|w&)oKR!EJw(BpJyP7H@B^8sjDM zb$8v;^?a6If9=x&=df76DP4De()g;gG)A`&`hm3&NLdd)-|Pw%*iZ*-D|D?>i}9nW z{m!ojtXr_p_aY#Pgy4Phd(7f?7PogOZbQ@hIB^?IFK#2va?!s#xrn-ap2nL)QRGhv zX8rH?xn9v%N;R=b-O6G!YmYKq3|zSPt11o))TY6 zkF6&beQ-UoM!L|{X${JJmyghy1Ejq~4Rj^G$USEbpfdV(9?!)dx0+B6X0^v~3L_o}~_Ahi0P`j9d8KLgk@S zq4LYL)K=s(kq6&pkCDrNZ&ACzapI1Z_l#TPtu`}ccpX3fK96}&$DC3YNEsRN?z_Vh^pciV1GBbXx2yO_CuZ)`mn5g-1Qhg9De3mPl=R_1;hEGj-Hy;a zt`EB%9C9+gz%u<{QE#wSdH9I~amYJ#jx9U17uyo%{cC$d$1oxbVDBWTfUgflI}81j zA#pYu-xtHG_FO=w1Q{IAH^8sC@xK5co;ZwoOAnKAaW4(l>D1LC?&tP5O+2ECldW+a zC|;ChQoz)uuSU5PDf#)91P_em6N#McTedo=mXsG=0)wBVUt6cBLzD4wC4UCHF$9KV zMGa#v^W&q`p-rb!3rM1O>a=M@ml67A80ar!B#P^zrq8_i=J-KA#`4cn?_S-*j?W|r zFS`7P!Cf%N9Q5j6!?dZVwQI{qYiTX8Ky|MU+jyDmSI@+@Uu5S;@dGbR+rv-)wZ?q@ z&F9}A;rt8L&p+F|ctVZF^il(TnC)2?UO4NNPbJsVc*2cQNxJ7+q~uS%c$)d<Uy^@-#4yBLbDC@YCEsCC%?f^+r|6AF@9;N~H_F+s z-J+PrG6P{FDk{okxHI$8%augLcc;a?a$34IjT$17ljg|nDLeS-?L)BpOIAhe?6TG6QT~6XSLES^PlL`1h%qwU{ zKFVn6+E``2jn_rKyHnDR`QlvP#d&)%juM`(a+Bgm5N{-W`HGsnzumxM4jG0ffATUl zcmzK8gMP6m#$j7!1;rqXQTk)vx%bX#+%~jx{HsDpXw-#qflK zxEs~#AYW#--($F}P_ugq*RktbHGbH!7dt9o$P}Q$%~;7&h68f=n?PdQjoL$kT<$|y zrdk+26A1+t^caND@frwB8rs8QQ0}--_jI~ohi~fkx2`x-D9~=PX#2TXDjdm~#v3Gp z+oz?0;=sSQJKw`;A~V|>jaZx(e9E-%!pFzR6N12xkE)K7dv!;D{(_O`Bq_d~4MCl( ze>8rzjm@Fn3r!t!80G6WJu_gPJj2ljUxO8ryufsnRq~V>FYv8~2-)6xTR4s%ia66E zdrDS4C95vVeXTnr|4fi+XkQF5oi0+oF`g^fpmqEk9JC-f-Vs_~vn?k&=G!kl4|L6s z{QWs=>JB{+u+jd1$`EZBIjdWI+enua0B7X;IxE(o$6EjU2jk~`EhgMZgnPVwe3Rjk zo!6TmB_w_d&uomB@=&D467{QM{ZM7=43}@76bOk8!=g^J2kE9KRf&hXbFor?hJAO~ zuE>B*xb9I;^W$YsE4k6bG86-eAw-bDBIbur*GvKm%4G(BD}-|%n z4^~N+S2Eo=ldjh_d!SArGri=;w>SSFX1B96y)!{@x1>&eCX> zMt4M;4|aIJJpDKZ4e1Xkwv2I4Sx@WkFJEj zx(q@K9)UF>!O>g2?bin!!GkbJ+P&3NIHGE#^Q#i*7^i62Sd^v8cYG;;UeuVC8|dV| z9yW=8XW*USP55hK|IVQL*0r4$$q7Rn2&Zl!3Z`wq^2~`FKm}{Mg=0n5l<>CcO$u*E zlfpZbj=k;KlfwJ%1Tq05T_!*c{V7nRXaZEna;`y@2hV#-@l;un_Pcrf_MFE*=SALI zUZhA-WAgHd2@5->&zZc)tLsp8pk2^=uYkUP=Ey$FJ`h++g{3>Z&G{O1&p5?(KsJ4( z1^75M-}ZFEVtftu)tLY^(mDVj2=iis5omCoHtu^ImH!1?x9zXwmrk~?@%DfTN@``& zFOhBsp(9Hn!nP?23U=aiOR+9HoUxS5l^5*gv#s{!ngf#9GGwpNoi(b>4xNPWO-_>( z6kJ7IG_bD2WsV8+md*LTe^qk>lTLX>f3P!5kL~>sUc5cQcp{-}M>$o}I6L!kHk!B1 zl{@a3w$!w$x$^Twuxn?DV-;yG#xbr|>v0>7v)H1YK+yx8c4{@HK*0O1n(p$Ji1Ltj zX@H#nQ2A7VFfFti(K>PrX7MzOr_ticKD)O$uIIz{+V)B7CVRJZyX{?{4bK+Be`z|r zupDlj56`V$pAgT_W12SIg`#fwSU(@u>q2SMU4`4mw;`YW^laK&^UWoPET32gI?`bcdkt9h6A$T8%hUl78(>0 zAtZk$b0YcTnA9kW-VaA?!_b%vG;Do5Nl}aeB(E2_g(RwHxvk@fmqET2 zJPX4cD7U|fVs6M)&MAH3!xVAlQ8OGFj=4RK=9g^CRn3?Rwk!*2Eyy!}Fg7~#!HTGa z0Mapa&sl$7Gry2!kMGzHv=@-&!j0>g!FZ@6?{(xVLp6R?54Vs|6?nWrh4}pdI#P{O zE*2G?H^UpNvy1}s{P4KLQ&#J+ZkS2=wheWvDq_j`avis7pSXxR$M%Oo?OB&D?+TJX znS=Qk)ED4o9-7}6+*PBdd`e2I1cf564QlX5&=`L(GjdyEtzeqQ2kw9Q_PjV=SqLyI^2e0@wcLYl-l5Y48p+MjI-Fjrx=JX z_bX?>02oC<$xq{CKkAC)7&&qlGFmTg<%M|QObZ|7TE)K6jeuURB#pe#vx8NWgO!yP zp6~~paAXmJpAv-du>R3pq!l!`%{FY9nv^<-W;H0sNAVR%sI;zNdtT!P?4g3Nqs@Ph z-nlr1b=0~((7@_!u(7JwvD?kVvYhPF>7Y|&l)ffB_UzTq33{I{yv4O~OA|N_`x&T_ z6ZM&`Ke~F_&`Jpd%yAH0d-e!@cgA?0X3LUz3=YdpzTfLDMxfCE-%u#lx+q^x(=q9* zk5HZ4PJJ)e=+xr}ijwGsnbS$oyXb!-NCheYK6I$PzJ^>1J-AhlyS(tq!Q*|o<_G3^ zxi&&(_GWuwdrd(v3gIX@30=OZdIMN|RW!hxGj4Y4iK6{s$|`ar)XbAm;D2@7w$C?N#|)xp$>3(fzjTB=Iq4CB;yW~vku1T` z`Idk+j|AV`>;F|7uypq&T3%ys(3HQiPQ;7W&09VQNlFr+^QJ%j)v&DK2dg(c@a7Hi zT1E~B7>eS6vX5e^jF$vCth28|35RDdk*AYb3GgNwblE z<(#Eh!S=cG*J#>~HW6yiDLUnvB089##$V7ER2m$<92@4*M%F3o(aF1r`q9m?AYr93ZS3m{bt#$0ZFoaT$#hYAGG9Feeqy;y{dkm=x--<3!ei3pX9N!2{MQJEHtPE{L?7wdLHdEzb*X(N>&h%LARWu44*+g^fN^ zCl|wN6P!KLq4O7ch@9N4wy43%+G6cMmb^YWHCbH&d7N#_O}3TQTie=UlthRJZ@FHN zTM|nxmxnH#T7i$J*o0AZp1nT8~P<5l;ND)b-n`VDbIayDIO($AV;He)w)MfECT@8+X_Sl1_{G^Kw| za_Xc;*3uZ?F0HN=lW#@_^J9;{{KwCR;8a!go4kW~&U3r({G>Bh?XM|aj*2{OgoBj% zp?fJKonI;xV_^?6yN{(;q=O6{!CuM+emp6{BHcZ|AF@NnHDjv7mIIN|6Qb9l;!fyL zaiXyfm7xT@Jtu*!-$~$qPUIwr1~~}^GPVkX(i(11j%mn&lWVw{GNvWh3U1YsbE}p- zFPy%mt|4Y$ZHQgnNY_yD-!fE2Acn4am0d9k`jQ27eX4HHqOkn7av>%Hr&nyX56gcb zS<_fNU$HnO&zN3q>D@>}%bvBRSzEe0ZRzZ>?U*+>w(VOT+s@5@j_vSf$98zLV>`O- zvF%KIYzNwpZELDyyRD+4<=FNjtjmjD#FLp6?nbs=I!ELvi{(gF2)-*-VNbN_S-KS&yt2h0ZyT?1$56ippl6-lc}Y z&v6Q9qGGuSVzDoOIy;3B#qA9#$}sWkLs1xXIcMyk2QcSU1P<0D`^E*a0KR0tdLWJ& zP`vif1_mn{&5GmkJV&HKKa8J<8yA20>H6vmRkl3jgQjPFnKcAg9n7?4?cZI zgD=Ki5EpjFo|r+9^|zu34cM`?jmffr9sG?)e|^}JCWXm=Mm)~-8&iNfQ!HWTS>Rdt zE597)RMlVd9m;C4*m(nV2ON|fumEXbKTAq{s+0Jc&l2W;OEbLmJ}w+_JROW46S{`k zAlb%~BIqy{f1`%}M(zBJ&0rQH|C%C=>9P0NWcJyvCWQ~i=DRAwGLtXPa_~*(kUTSnL=-GZm^v8>}uoXr{<@R9WtcH zG{fZz$~nQQ?Dt^}oMny@gW63`fxY&WW7%uz^{ldeUa{A+@psC?#p9ivY@vv@Uf(iR zWGC5PId*n72-^1j>8-8rPJe6LI?#OIl&L{STe`sY(QfH+&Xy)i$kUu9q*Z^GRmtiL z0I*Dly{kD=<=rakmO1-ZKES^sM^Eb3kIYPfZWsJ9G0k;kol&kyJrOBB*&gK;D=B|u z?+FncC%Yz@(6xx1WwXQJJJ=i9hSoED1e<8DRK537ogdHB7yj{WSCpLpVA+Ax1!!-R zGlo;TVEL;RT`##reB{^*@;-mnL@!BFrtYUHT$^xt0_~dbL67ft6gAl7Bhc0Y)hi;` z`}v}TdTJtjwr}6Cu(Tgop^QA|7mc~-1~ioHn*&t}fjg>r+L>195!QVNjRVBYy%s*0 zPrv!}yY1ED{*FjZBoGrO|&Dlu>G?BjSTM zMEnPeyltK19rCHQtySyy-_`oP*<~@iEWSUN#Zh*z3)>u{vt+duu?+ZXGfOBmYbWk) zMkW4Iq*sw|zle&s7_+^D5$B4F@@9h`t7E}}M|t*fdf-_a*D&{)Z*4&^R|k+v_@d>S zz5Q}0Dq>?@&@MK+$8~?92cXIyU6!drhPLQ%TR=VL1wb7qceS40+qNm1;;AOz5jLf< z4bQbs+qg8p19DS4Ei|j=iWbJaLFk-L2sD-KbdHm6$#wc%B3C=6)@niq0m8@$;kBMP zns-1 z4JM7;(0+TZsPTWSncm&qpuAls?jr(heo6iT%hssQ#o5SAh4T?M&DZb5)5xj4E|SK- zGOnqIx>(@2R_Ja_($`N{$<|t|wMnWW=!~A3#j1i>MQ8C_G8LuiUjPgwDF&~MmZVM8 zv0$IOnFw{G>qY2?`_4}{-mK5e8&64v>4y7%lp5pugZF>nAU(#t=SYzAdm7_l;&Qix zwzvB^^JdPxnKN%j2o^t>RG-m)I0Ye)txkWpaa|({+lAKTh3)zc29_&%qvZx6fiqz}J8FB1WOj`K z^|Sn#<;QgAJ1+cogL7ui`H=8B|HqJs`7^-ovC@AqL>f8njir%ub7^$gEN~ZV zX1RYd%auEoD^_$?uJm@5E~(;PD^*6IyQX!54s(*+x!~I6MRrrlqFEW5h1U143{k0x z^Fj-ElrDkgeLreYQt3W}x*d*1LH@5i|nKPGUh%=$m<3z!A zN{?i29Hm&9--%+CZ+2X}1d}2aUI5)kg@G*6zkWi2F< z(JMLguRtCn30};oEp>I85&XgVEH6n;cX}`;TCE`SedkShy(M&x{)7p+1Sj1`TwC?$ z`3lYx8qG)UG2akr;5m5%^dy~SpA^L-9VN6ac<*8vgeBezF+>9YGyd8P@ZYcoi?M&M z@Oq36pQP&XntaxL$woU7W{RGD!1}m`sNyY>($c*Ct;{ip7*jbD1C}1``_yPx1^0J^ zjM=m&^F_Op=3@h+*5 zNuXSdc)8C8775S3?dX<@Ip>95DxLrZ_CRbm$W`6hiZpP_(la?ck^W1J2W`2;)pH5E zH5T8-iC`XqpI(3S*MSb(N{Ir72rjP2TIIWES^3l##i&5R$MOe)aPWF4+8lp$RCvV8 z%jAoKSOt#OA`vVP9br8VK8UV`UzSx>zFZ9-r^zOH62T*pE#!3XyCFU9bB|hq*-)2t zzKajLFIl$Zi_|5RaZ0%8*MOD;|FUJ;-eQ+Db?1qhS#T|nL4)4mJDzFk>~;|N3+^P<2L$!6D0rmOLxty8yO(t|7s5K_ z<^UUv7*S$UM_bG;Dk2AXYk3MI)h8g4&Umi24fY3p~rx3uwEZR3K=6R)FwSRStyE4O$}rKUBeTU44I8o}~@TRax&` zMm)~GBK_=2)H;at0y-GuQKFZB-?tyez;CztSMDarBWW23l&>&9pd%8 z)P^E@)LJb(SSivq=gYC0WDnbK1h`O<#UyKP$ad_ot&7eyt2utlZbCKiC=4ZatHvo{ ze}vF8y0`7Lt`vl2nt`I(@qxK+J2Z4&izy&obg)D0LM!%3D}sL&8=tFnwc^@RI*V~b zje*`~3^Xjh@|B%>!9x5gEh|<^lu(_c0u3ryo-f~9bW?*MOYcOqM_%5PQic6pn%62bSgk? zNjAJN5)&pq9w)pzn6~F?>6_!WH-)lqB8X!!?7GNzuvmX{E}#*Ts}Ju``CP%fEJaM; zUC`fWHnAddDuw^0l~B zZTs^CR-ytD96PMm;3tY;SaGkX8xm$w*7bNyv{2a+#<&iYumsA!Tt*$Y>|_J3Ww%a@ ziQ4cw_8or=$B3E72v1fOJz3OHIM*olbF2)Kuz`ZR7BJv7Vf&^?RnEmc6wq?^O!x}h zM1G`o|oPk!I_xLaNEEfMHu!aKJQ%n8F2@Y|MfKyKSt>)&)q4uh~fpL$B(O z+p!(z7VS7uCn#q0@{(9lpbN`$!2n3HgaDp8XuyA`aY+Fnc(*gw{V2h`RtdcfKiWMe zi|@In!vXHBJ~15o-RpSyD(v>_gKm!TJ=>wmFy{;Vjf)#PDIjQ@?-mdo3m(`VdD(=y zJdZk?BxKZa1rSucEYsJ~c%kLmTJh}mGD-`cDjC7lq!ab zoz~dc?#kVv5Lqvt%G!WlB*F?b?apn7`q95b?z(L^+R;(Dqux~| zDvXs(^?~KW2c2+;cD^4R+IXoAEb`rf)nb3pVaaRIKZI8R6eEQp=|ia~g)LNERv--b z`*On$yz!VIEaKi}i|y$HixjkIP;=$QPS!DIL@7cQTyokab(|ckL`LH(zXo8$lVzOU9qxbwmLC>hIK9LeZD`SRfZP{e3<$Qrydq=rt;0KR<^c0+X9M!j&&~6rFmqugR6hvNZ_jl zUtnb%^oo7W3f21{N;eX>o#8^%WJb++L#f7W58^W@aaPX?4frZ??A!(H4Go}U3ZF}- ze<~RUeR$t$6Z0DQ^9Nzfp;9TPZ7MhGcpuSh=-#SXwz^c+UUV!R#D%yWAmIu0bep}z z>0$enjhX!pBBl^_Vj@r|tKkvB-t6B>Y3U{C6@Ao^9WD0?ldY2%e^6+{ZQJOi@dye5 zO3rKx1}QWHX6WiUTYX_xcMKpJ4`$&tsy?tt9~kI-@*`RP1?ed^hXO3CtUT1k>u7Lv zZcPX7Awl#3CLZ95{3&Vfz!e~U?T~(`9TGFV^+GyMBp~G#zbrr4qp{@#!X4I zOwB!WQ%IDLna(dqe>aELo0JUlML129#RMlBk%SNqd9a>_NTN>B2U|vlkaq}x_RpXy z)z4+MUmZqbI+3TZkVFR<17cQH`LgYGb{rIFUA;G6gYvpeR$oS;xglQl!t}G560h@b zm|CSUh_(xdZ5NVb4dW+xeH={{Xy5YXiOWn7nIay@Uafe6f29>KuzUSO-VZfAFOqnm z4(v)$3$XNu5@g_OhICAL>wZWT^illE- zYO77uEW6=`-zmJ&h<%Gj!W%SVhfO&DVOwONGY=18zKo9i3=^We&V=Y*YdXFlZG)t5 z&^Y*>7fjtmz%FM@>!K#wAZ(pu1;ViRB5f-gUnQ~}Nn<9ao9WWDzDWc&BwiECV%@_- zNG@U{e>qRHp+)rZCVAC3M40|GaSKr}aSN^+o!``P!}L^Znd+b&EdA0W2>t26Lo1pN zJZcY6RdBI)cwYtNn%SYBhfn)>m+vz2cwtzI61t5^vD2YrIM8(Hfi)qzV|kj z6}J&aleZC?(~ni?TT{2;xQ;n_8=aF=ms5X{nRIXBhOR?y36g}dAU_&&19SmDvRys+ zM5f0KGRQ%)%o@{ z<^q3%;$Y9AyBg7PhSx>;BJ=abEH!c4omzZjVKH`Bz}AN)hJ|7F;j&DsbTlL z>N4BEWSJC{idOs7Ld9MSvd zdIXr~=>tPDLQeaDc|p%ibr_QBC}Bd-ml=lBhcO>#568ryiC`=Y!eb~=krM`=;_aCT zV*s8!$t=4iFdmi!+(3A?ISxT4Byc8y+lFdj3dD$-0$n`caXqZDup>EpoMeB2=}LnN ze)OQxX*vquoQuA9KNpgQsZg#t}+Dpnc%k;xVy+IX4hNOuUfI1tOi(+}Yb5Tq; z6dYA0rB;EVl8VZ&qk`-0(1FuAF7mIf_7%evfa;TA8Rqq&XsjWH=)uU3^yD^CwPezs zMRF*n8Wea$bLnj~3BKmppo(M1n69ZG6W(7pCYDIijv463w88Atj_H5upLfT){O(sP zr^lF{sT&hSfbLhf9qlvZg`w^j_f744$VXmvy^xsUrW;cKMh`+uH)LpOUzbs!eJn9i z;T_1!3dXh+P7C^FSJC)?^z1+RC;uaVNHj`VPQH^dT(_k-+Dh$Mfv!e-lk$%77oxmb z8aK7+J5OiZ`cQNG#9x01Kd{VB*>^CllVy?hpNKxU-NM-F7I-1XZn40ZTP&!zGHPnE z0Nob!jjCR z1t9}M{5L#n@Q|K9M*4SO1N@)nUpNA9!q6iG!EXj?j{(X{RepcXQUm*Fh#`6XY~)+v z!ZSE47(YlV&L20RD+W_mP+cRDR&KQJi%(69|2$cB=*2Ci4qO)biux;BHn0~*J;QWT zL-2{h|7XUpMY4S`9Yc=jkH$8`-wJ02zY1D&pns#W^Qp6h0A+u@E`405?>9*((jnm8 zfHu7WwXaMUy90liH<;l)vN)OR$mFRupO#s0J8^%i4Z=homJKs(p!y!t&B$~`Ye0MM zxEEv0Ag$86i=N{P!QWT8#E^TGUR&IlxeG?@k`;{YdSk7Dx@`$I0jlal9+?g((J|;V z&_GJP);V%!c1qGl0hN5E4E&M}H2Sh0(vf!s`JcqkJ{6N2oFDe{l-n5%?K|B=iUiqkr{d^;1EqNP7v)d@;LwE?0CQ` z9|j5eEjn=wu$o`6NpkZ`!fBb9+S7#4FbcAppnRJU+TW3VF6y2h<{7P7@54)^-_&+3P zI>Z0-7B4V(^%9#G3}XB3ujGa@U>IQf|J0G;XOtp10DeE@6^#h)uAg|pvG{4Wc9!P* z`bUGVvL9IcGe|o}p@@^6p!r$7DkPyZhl_u%;rY6bvW C#m-g$ delta 25468 zcmV(#K;*ycz5>|30h|6a^qMMgJ+ihp3t7jwgzSAG1-47cf0hG$Ilv$G0RIhm z$+yvuFvYB~us{gtU8R2xKRI?#m60>>qg>u|R3e9~LKz{IjspnS=~k;|)2)PBH~1>^ zW`PZ8nh#$zVOOB-@;iv?NB+*fw+@IWp<~buRLfte&VYcL{gVu~;nPZ1P*iKWI3t)( zKpJi{;B=k@rvnQ@e`_M;qvnPKIXYhXtWtqJ*QVxRsrUiknx-s%CbrFn)GVITAgI_zqscLa4Hc@;OQ#n(7= za?*pHY2tk$~R(j5*=S=pvFf&dG}p5{3SBMM;i zLQkSe#L9R4Rh)w4a5P#PZsB51U{drSFKf2sdVq~Q{2s3@^rw?HT7hAI*V3h}4E zaYJza+=$uVd2|0jQ%BAGd*4?AEE+ z(vRloCW%b*R~&Pp%5m2cEMtRp|Q2;P?jhow=}nxo%5SJZ~9Q+N&zGAWVNO_%1NAx%0m z{X~p)lC*vyr`T6y2iv)oQyf@p3zLFXb))Y2DXb(vRx&_$Azd(UeMj_=UQ zie$q>j-ByDut{m0yl#n*@9?*iAC7#*qQu^(yyAgluBv?o@>5|>wfH6$6H9_OqT^}P zsYB=UN4VE-)p0fL=7Jb_a6hCSfAr4wpBO43?jRS_IxJ~n+dB4*C{jkp-%(MEiwSos zjINx(^!YH+P);h7NP7U)iGYdUY$@j-TUBh{=IIeNu;_sY`zYfX^!+8z@=v&?0f+65 z{DR^f8(rDj34G^^QrR;U(I73MNL1_QN+QwD=;kqI)@2dt*1hnM2hha-e+vet9Yh_C z&;o|Y44(aX+;5Ba=F3$9{Q0j*@%vbdDzb=S{tGNf`h*kK#-vR;>%{^8gthPBG;fdo zFOGar;s#E9^@_S{uR0rCHDH_)VgC*yTqpF)ZSkP-q>GR2?w)Tszgn|bo~1ubUJP1S z_ylP9iG1OX=dAkTb1RInfA4`2B|&tEiKS|f`?CfCX9+yNL*TibJ$a{prY5cwkB&d4 zT=(uR8?*z|Ah-Zi&8kxP)qc}eqU8njN0H`S(LkB%&p}P=Xt0bVFS`u!0qt z@2j`2G)^EG_zl4qn4z&`ziQO$n67cQQSU?^u4lB}2zZzQPodn^e_@1uE7xUI?nAlF z?rmEYwn#!Z?3~c_k)%%^KDnpSz{Cq2>tgcFZi~0AQHc-htkaP{Hci7ipfQA_Vm#V= zf*49A2BlL2{fSYhg$Os;Hq{5FRUMRkx4X1X5dDB_9dTEnMEFfoP~=kEP$bT&h=x?8 z5KwtA!r|a1w-V{te{W2~a#s18+pbQDM>Vm=ps7D@jY09wbyWN$Ah0M}ONQU0sbYOZ z(St)o0qL5GA%^?t7^1dInRm9ckL6q*#3a^1SYr>MLwtE#=;)5gd zj&Bt50nqUz(4$p(T2C|SC+*$yEy4&ZfVm1FsJd!=|IKN`t1+Jb#V6&Y#cQzT+B3pt z)h%!$DnkJ=>`TFEUN>)F@1Op#WK0azf7bY;&zMWu&1NGE&}b{b3)E2)p*E~QexIn< zcE>~+p@*90f5juY@CYJMXiL2el$fDivw36WMJQ0C&SIic{X*ORxosfAM_({%#1CGIR>z>h_<-s&ue!Z|L@e<;%oo_KV zp@L^AH@4rJ>k4jlEh0KA`3}Oc(0&ubnv8v!-76J5!rl&sgO2bunl&g@nLN~Qc~y@i zy-t(*Pp@PR305NIB&5&65^i^8HI8aGN=4T_14x5dp=Lr3?RbZ+1UqJ#>0E3+FFZg1 z{R%aMH^G>e5CK{R16N-a(VqK%i7-vQtY?o$H@whplXgKl0`?=5u|Y}#*A$ceK`sJ6 z6q7ANDFSm*lUPDG0=8?DkwR1ju7<~b(v#{!IUBUg(*Zulwac5t)z|O9)%5IpaD_AA z4qQjKL6bg1J%7-=oYD`sAIccJ_hB69jw38TjqUV zC7;3S7OiWHAIIn#>wFbPRmHaJbgza9PLqri5tJF_bbl6QzIkW0A}+2y3m36#J=-Q{ ziA=&K6?6g+pm#G&^C%Wq8`FcE6YBm=q*Gh}c>MMIkPE`O;39n0tel6D;YvmwpDR6u zoD?y!J;?+!l%rBP1J98RI!DO)A@zjUGYD|IH|}Zp@=Zt!{;IF&qUZXop}h`zCyY(nxBfzMkW5R+ej1N z!*AJ>!XzFMNif9g(kqPXBGBRUYuYY{W!bH>WDHbDNgHAsTtrN9ZJlRVo zg`0`AaUAuapbjgtr3WW}<9cD+<;TA5Ehk zvnUmF!;_DttcdyIHE=t#rk0&$-;$l%0MA_PFi4%B(P|dIsnTZaN?@3uJ)9CkQv>sk zssR&&Ixl#`NHs)XVOB$ty+IOS=&Nm1zJG^o^OnN_&V8&I`wZI@kBJMa(V*K(kA~iCgithM+hwxi zqdG38y=ur#3Vs_R4%f<=NSj8RVDS=x0LBN^Fh@tA4>9wNvz1mS?WK}e5^Z%cOibei zNXecflP{__Ov;-)i3iMV%TTs^4}VEJt}4#(Zl`%j-p(XNa*@+i`OE4b39OdOr0 z`0`IC%1OmARW!_ZHTux0h5}dV>=hP*!VlLux~r(}19vV_U4n^R>yQd9gnuc>52mh$Cg)p2yog91wCT=-Zab<=Qq z)aeVA^et04{iXo8>660-a0SK{Br{JU{30$aVxno&6{zuZng4e6L&W|C&#!Mr(5Bh z>E3MieEn9Of?Mr9OTQT><5ufr-3%iv>t>v8b++1Od!;B!7PA-lQGYDBicmA9{H$(j zNM0n8nMx+E(>kGx{jIdvGFk8H#*Qc=Rzx@5iKKf~ndoAm$P&>~-W5u0Cj4?#OSg>f z;6UpHOQeXdLA-iINVW9Bb>HrcD|8{Frj3$qSrVrw1xz#qeHV@xGM%JYLWqoxaT_8u znS5Y2{m96Tl#Wgt=zsK*GMy&V6EDRya`bd!mh5EW7TiLcG*YGH!wvE|nJI$G(p0+pNuszpZeEe5}r2p#h1ek(DSQA^{{zvOn+YYBvQo z$jw>_j{`YeD1So)Tl4Yq4%`phv^c0Hv+-eNnxt3$`E;6|sh(z_ew=~rsitY_a3@wy z6SOGhG(F|>Y2ximIZbDVajM@-XolmFYH$=`mImGLWUBXxAhjznr{!c}IJ69BNwQ5z z*d3+eOS-GxroIOra{N6wj5Zws=6M&d30mhLc3)`ntABWwGb8cFa@Z-r*DvcnnTOw4 z`-`@7i~@sH%fiMwMbcn{S^OdIBE<_1?BIkI_KNT&!6Im5qZv9J{nkW;0iSPJMNYP1 zj#-vO>GZb>%#JWtH7jP>y$;+Ghh;@JlbZM*E*f#<>Kt_BRJI_l*VG+NuwzM9C7W;^ z;s27cfq$xfaqoA+roR$gBl(=AQ6cIPi(qj1&r0ZSQ(1Y23?4qsBLpp!3K@>v4E@RB zp=Njx4l3njdz0#JYF|Xa>%SZ_P2s?h)DSH_oizf-&x}%zKDFO&V8=^m? zr30ovpGwoy)zb{r)5N+{B0P?h=DIr<->I~1vwtavubj9Is8baw?FRTLCeVyBJQ9Y) zWdsLDw^r@M%)!8LW@f;H%&2rHC}Uy~$lrlk1iXK(0zR&9P@Eri5TO^K2*2^>@NHfe zlmKlx?yBpA7)P#%l2R6I0kstV5)xERriVdgD(Tt!YF)DEJ;cwZQ*)+MdyA^3h`zJJ zKYvl9pA@6sp=tYqWWJkLPZmqbu+5u{*yK5rRHU<8WCnKS)WohnBh#T`QXo#BQTncg zA}VZ3DO!{O`+*sZ)Ip6@OngEG@lO9&5q}Sda!t{_RPic*c~QY8@DA5gNSv}IrRk<# zhH!ATB?a<*l-AsL_%R`v^3bLq=pz2QXMft{1^i+ zv?{x0o{P-zV3p=r9WT!j;CPv+@q1K|^{v$|Q%JX3^CSk{(pNAASUA6JjS|Cu-+!uU zgiRl=2H1eCDPv517RzWNUCSMy%l@)Uit$@d(xx&ZM4Mk^IxHp;1_c6hbi3E1$qrmW zI?z4E>sPQ1MX8GnxM09;c2BMhuMueAm6=WQC({|)y3{NymcsFfDZ~8h zakT3)$|^h?MPh#Q_tcZD+gls1ayF>jDGs;^D3K3{h*kdWXl<>>&Z5#|w|`@2S~p~8 zV_+2z>DLUt zp@}^-*$=kCcC zaVkNK2%jw2==-mTFfR?vOE~E(;bKI3)Zv><6JG}>9(4|9Kd?bN`bkp+woCg~M@d-i ziXt!T=WQN~Ml+38L4L`cSJuWc$pnF-%-7d%Rv?z{s|1TH@HdvdXMb04pXV1&Dru2y z27@Lqo8w_7Sb&)Q1!i}{iaLBP^UrAAFM@PQQ1U6-?p(A@KcIws?rGfBq5N_z?04Is zn!aJKHc_vS99*71MW+0yB_3d6dh;_u=|mTfU$n}0Tc#0Xq}#SJGoVW27Tn!5+}K`xHm|43jR=(a%H*v8;_G}Lh1 zBP{ty*ymV1zfazAgKNm2hRzIN4(W3$T?qYOdiL-9Cx4Sar27$k{jKqXyhT``FG^)i zq2^t_QIV7;9t(d23kL@tSxI4hCA>`MX@<2!_z(yS;Rkl}ZGXw4%^QMD{{icJJ20$% z(b8RsnOv=?;>#*!F~In$>H0xvABX(oki|)@{nM}JcP(R||J)9$p-0oat9ma{n4l#^ z+xF@FGo$7=dAv(mGlGFQ9AXOVIK)!4+6Z5wZIaS^{3ktLY4E8A+B|!Lo88m@)n4G( ztd4(2b*TD>;D6tmf8j)3N0)v>H1Q{NR%>vD(0KU}Sl4G!O7|Bn(ojdxC@@0Ybfn8wRvX}}`MV~x)MzWN6%HQ`vnk!ZWDWGT8^#52G9)(qtDN86Y@{}mMr22CnW zHuPQGq!%H6AvhV@^`hUBM(I{Wjd>y44+>dZOX&_bb zR{Vu1QxnJ}@EJz2V@%Vx&i*X(U46L{@>P{r&kwGs@+#`MDASGx59Pr1&L7IO-Un2|qk?=@Nycm=@W;R?8 zEn+@ayr-y&sx@)fo2*FvyQ5=j64|_lY+FgVQzot?4q+6Pt(O>$Q#a{&@0V4Ra zoD2y^-FV#KY+Z(uvX_Q zOex0t)6a#fwBBo6B0vKYp!<)Ye|75+TmC9S=ZW~}2z;(V2BktP{1RpO{zP+)LdFpVDl4kA^3;`Itei0b zcLswuykAIw|In=TAJJc{;qU1F_boIblU;fb{v-%nwHM!qa5n1wIU0Ho(e2sOL&Sz z@jMaMHF;?np<*6}wAyZ`ht$_Wt&f4OyzQ?v`ctj!>ZUhLcfr{OXMZZ371b@;PJh>? zEz^I6II$nBtVaV@m$L61P|nh%AI{8v)aYlW%)$z0mW|`%pW`$36)e+{i3_~BxaP;OK<^tynoc~mC zzTKs1@)~8lHj%md%Z}~Xpdek2B!69+E)U_x#GOH+U5w3(IW2L)MRvH^yc$h+P0;w2 zUgfQ`I_Bl5B(5W?!;YZnE03}a2y!d@?I5YWdp$fH{p%l|V7F`Cd3qL=`m{}lmPDpl zTQk^%41TfK&mtOj)HA zJ@X3_)HsS(TV7WYMNHRHNzsCp=BNBeR3`i+Dj355$PLum#SGnrMQ6M*wJ4X0<%}kg z^mJW0Ny8f=+0iiS=$(zEZRxYo_U%pzG+gO0Qw8Utnzm?x(w)vGZv9 zmeV$2;FToWDkI~^Z|R=b7R9D&WwOSn3hIW@Ud#B@%Udo-0IKjdg#q@s8XkU}RJ_#PW1S`#6DvxGkEdx{Mj%|y;u6qiLh}H`&HDuMPJaL}i0LW^`(4E5@dB6Q z$$O}A6M5{DLT?eHA9q(yqiWAW6`lL4srqn*r<}d~1=}v;t?Qjq%tc~bKg^IUL*v+Z zU$eRT4S0)*N>mP!+nOiJqh)Z+>D!@+k(b+OzsCL=yNVlp34^;rd4+$h-&V+T{8gNX z@>6!LqmAeFG=FuOkDG{G2-00@O~Wn~)O_7nN6pon0?rDkdA{waq!uWAQ!i*59tvvL zdSGeii;^&h=9!qjy&Ch6DEp>b$|ss#*p7Kovx}={`6M&{3uX`Ep{9e6Gz6}$DUHT< z&bp_q#Xr6Hr+?b8PHadi1RVWlHA~ZRhHp{jo5v|iQh%*%D!FpHPN$z%n;DMuCm9uj z)}cP@RnKzl71x`pXj39EF%=gW6AfBo`~!qXjJ#tL5z4i=yU>&*S;opM#@k$qe4Urg z26gnM>kzy0{hM2mrOVgu7GnkdblSda=-bYD`j?;T0%&EKB=(WSEECzGrpISSe#jSB z4RNcTrhoRR9KWqQG0?i>O3E>7N`I&z?OLWjP2(Ht|A$56xcq%$mRu_E zjxIZU^_YCC5QPIez^^py)i`^xFqXjAgZtI|{ZtKu3#_3&H&r*~uxUPI-@KveB>nWOsbw#1Y5%jUH-l;N+XKOax#-^@=sI~&b8%!in{1n)6KTq zN!o_2PN7P*gJugP@GZw`FRA1x9u5h++NL-V)o1uKhekTi+ySIs;NLEiF0ie+n18kL z4fFt+1G%|7 zN1oFL{~XHFB=<4JV9)M8HUi5#%YWxVVsco$Zp!g5#eWu}UTykNy+gTj}j#AJSq zO2u_#8LwR{#A|%@ZrLP(w`1_IE-i;Dyc+kVcU0@pw&gNhiXYw%M}I>T@j`hF@U&po zf>{e@T^QPDJ0{hHC4J{Bks5_0hpq!X@S2o@@D*mN?(!PnrKaOLXxi&NFl03y?_eUM zgv3<6uC+K17To%c>RR0fd4MS>@SS3SheCDzJ*`I{8>FPWujo(7;+zFuSP6@YmOk8c zVU_0f0xS!#EWmO>w12Pd8qTCz2^0b18lJoaf|m2a_UKH+v=EZbgc_Yb9&GU3v}U)!?L1K8mrpo0{Ay&!XPbwzLFZkmEOc zpAqH}*EOsaCSWG8S4j9?|Q(0{{=;9-J;u;FQWSMnVH zdfpK@4>LN7A9*-cuQN8K1wPMfclrX-3rPQ5#`30uq3g|1!LZttJQ8iN0LzuAD2aJ( z)tj;9!?8C=Q#)KB9g6#Qm)23n@?DkgM-z7(e-*7^Df1RgF)AVy5fWRrU5nld0%KG5 zQjccm!@)GA6o2|q9Y1UegW1@yJDcbb_9IEzl^Jluk$YZSN%nN)-qnS9e5^)OBn3=F zhV|(3Vgf10QAG-GJ>oN2Cr4C^f`4w_#uK(}vu&&)V!2-sz&c$vfWQ<6{>%)VPRGXX zPDr*zIszz;=!2A>rayYvuCC8P*7J;6$oj4dvdt~tcYlt`oUwL#@+if4N~b*te-oIgXJV%PU4^IBt9_Nx-*(= zecha9LmfsX!!cJdKX|nn003o7g87-?Q7-#N9e-N_zw!o7W$arQGFJ1Vwr+pHkIeXU zRq3$JrmOgD>xc@#Ssr&kD~qNfT*=}c=?P2L({frar{zUiPoC%bM_ErpGNmZ%$!r?6 zaMG-s_H@`+a750N_xL$aRn3YJJ7G|9Y<{&?oABf;MT-{5`i)jg>jwvb7IZo|;7IbX zfPWHOH?EC8zJ|JYd35unw+OuPeC~~tbyQU3x+R>11E(yLnB7*lTXHZKp!~^QsX^e| zye}4Z2qRhI4Oj-M?j~8q%8QzimC5U?sJCnCcGL8&&uaXUVX;EkbfFPnAbo-4RA>aSzv5|u`BgGNB1w&_pYz- zV|b?7G(1EpopcfPO#37;xS0YXTu076Z0yqqlWDK+dCueCngwv*I7#Dj{ZF2EJ>>j= z!N?OQ-)wee$+8W*9dLGz>90`pPyDgx|NM{NYnwb3-qtCQd4^&iFF6R-ou72(s(<}8 z_6+1PThxQ_o$ayb;J)93u=7i}ECAfqV?dKD1J7KPWG}{+G7JPqDH-(o9m3a4(% zoZ;hufxArGqTw73Z;Ikh(1=7AI&S>pk_rbkV?PqE1?~W<0nOWOI6kS(G%RO@MYh)r zo`e1Uk^&o2`KV%V2rYKL;rbO;r+08a4R$wj&w7LIOz&End@6Y6o*$v>ccj3fMC;(zmM{OdGK zEz3E}6kaT^B&aFi>2_LtPTyS(S+i4^x4tH|scRu{b!(PQn zRZt+;p>Sa)3n;WBN!u`^Gm*yaekth((Yr&jd`iu)=VQVb^?QEx8zv0ax@(T$OU>dZ zsmG#vjZrLJB^WzFfa(h0J%532ixbkQy{(>mwdO4bgSLbNt@{@Z6kAZsK_O4FnBil* zf%?G^U*Koe)@bLF#5vf%y;1m=DTn?Ki!23j>oK?A^XQrm;JNoZ;SqRU;WLx zOn(~@LwBg0*m_?%89}l=JY2nZc#H}0>%g1igC2_qSoj5otk!v1=AYM-w$78DIDR+= zjVmtLqgYt*9Q7cp&hq%_3@6FLpsMpRJz?| z6h(-RRiIENcsGnx*Cun8WXUBG^*0lViqluo!gIBUmliN>)PLSz0a!7PylJuBlc`?3 zXU_+74I{_t>)u~5xg_fI1Q$?W`FYVI1XVFTrhb12!}J1CN_1()vf$Q&TbEef;f8AV z0|_s;1vDY(hV2Q>iAy0|0IS^^)Nrw_?`}Rt_4i>*_|rTd-w5H;yTE~5)-UgZ@XRwW zLQOx?U<<;ZlYemFEX_U)WbjC=)$NbEg2!UK?~#BZ4$y{)HMp8pIfy=O2l!7^g1jaL zi0yZ}Hd5s=4FgH)5l}Dy0Ec(Je}^&u5yyh;BJJ}nPnpIqNzeKE&nVnzxQSprN_8S` z=$2=RBn~>@F*+xtzuWd4S4UCuN$1g}hAahx$8S`!Fn_A)LRnT5$oggz_sIZ5_7jN4 zY`x=sd`%fOO&?v|ysm{jyVFo*WE(5fg4;MHUkXZNXgK=BW?H6W8P|`3tBX;`3>aXP zcW)3g8XbL@{!=zbaej@lG=4P-K#K~+l(&r+H+gRIFg=?BEa;IYi~e#K?N_7&?;L6Q zq8A*2Fn?Kj@u*4i%Q3hdgL58(mZy6sQ)Vu&TGXW%oTPU)V_ZQa1INF_?*exmn8;Zi zFKLuT+|^W~{rzE`E(@@X02Y3C=4<@auCazHS^n~(A<_V*G(MHr{zd%fM-I?s-@`PI zVo?pvag7!J8CGu*rXY#!kB?ohcHxiMMtWl`(|-i{{9v$syXyEgt#JATUEN>Nwfj&s zM-#NIRROb)N`0rt6u(D^tuUH)pv!y1InsAC9JFh!G|tLW*ziD9%02?Prrj1O0k=ap z!4=kbi03iV{U?83BL~?t7l!BRZTIq$PC~nj=zf8Cxa7Sbqe8Sx$7n>uf|E z{(l?2zLxA2F^x*YYMVD;jTtjNmo*3>42e~sxoNN?K>shwB&(H!oMWywuqV0mR_^_9 z6P58{9!t26I#O{B3-2V3j)>$rBpe2;sdvgy#c_02 zbW&KHc|oTIogNOI`kAS|=eBieV1KtKS-8&Q{fOdvXPZYkM$p(X#6z#}0>zXnZokytE76y`N9gVq)P`r^@TyhR|v}y$}g+iMBWb|;%B6^q=0u05Zwh3?*NFD z;3EKwz+wMP3(}9b-$c6#4H<1v{H);C8BW3~5#WVLMwlGcG$sMd2XS9YVSmlEU8{k$ zrY$0WXs{dO<#+c!Be>+XjD#pEulRcw`p1-QXk-1aX3Zi+JD_$`chtHuL#1mxm0x*3oU zL`;8$;eXsjX@shuIH9ND_J4nYHlQ7C(PNm#=lUUKDuOf2PrbAS{pYo{QbXGJl+*{yjI|fYB;W| z_@A8WK6~^mAy?Wr&C^=U(mE2_Z>NxS^<0#6>>W5a&yMEb%INbM{(nfHWO0Y{}m+#>3Z%Y&YquB=2t~NeZq6X6O{|ynVMkSl^9|BNC{m zeZa|-cb&y8P-J;cuYdTfNs-v_SB3YvC&9DPOn8{hdRI4ki3~sI33guzoEGtC2?Sw) zQTjw5cSKx6Xqu{{L^aGiSJZhk-4(1~7Ir@Fok$o}0)Jm{0Iqrd1ut#6;<6ly4@Z*h zv>-EV)ACn<1ANk`?HIxX%^eS96-TZLUKl5f>Qc5V!dnp~5$i>xN$)z)23fPnhsIGKd@VpiOGsL0}aKZ?UcN}GT%^D6d?&DRVE@d>hJV3EeBJZPCV~(Ds6mS>Vh9Ay zGw!6A1XXb}6q+eF z(byudHA>U4|9y4+DjzfObg=~euO{rIGK!lKax`t`XyWu#pP(oJ9#o(YrqP#{)XhQFHD;h{wRu)Ndov}& zh3Ks~iGG`SWiR9d8{EBrkN??AByxHm^w||YiY$9WF+&H^bO-U7No=zcJ6-=m+ zy0_X!B`WQ#Q&tMEvQtT04uzJ}-(zXj2_u&cEPzK%YBUBFq;|H?5-10)aB_mT16_dE~)uwuWWM*~DUHGay zkSYwhTvEA~98vX|UTw_*Ti4-YNXADY6vCJnjjR*k!Zqq^=)Jmcd*B(59wd2)D5}FG zttIPxL6k>E6nG6#y*C}peIQ+9O%)UACVz;>HCb=rzJ<&!>n7cw-YE3rD?tqP*LqO_+bS)x)$%S8Z1YYy?=6y zup5l%?5@f15SVulQuh422Ptz15&<~)UQzk@x(PhF4n{^nZGSQv3o+-{G|HZIOOrEtsF{dZEuni<%7KM4SqWH# z08E1vL)ZWR_P(sgaU@Cey?+IfFNhq;-#*=ND`p>i}KcCdRv-Nd-|yMI?a=1o6u zdL5fSaOI|NcERXvAANkjlX)kq1m`X&$qchxndQnKP_A5&y5ng~-I1*keB6==VReqP z@hJTbBLALq%rPQTiPDmmAYF{d=!3|!^iFB^W^VhL+x{-N?T?ZqBU!@Y?aftVyhOfM z*DYPoXX*9VJ_XQO-`^KOH`I@mi_KDh4p$$&m(ltxn~0X+CU(Q!Gsn~ z;C2H&ieW?rcg?oO3Fi-;gC5dt*KG@9!bsqL!#{&I%5m$E|aV6J3M~^o;sJbh%9)+V+IJc3p-II!MBYyx@s3TmQ zcZJIxF@>pJ6kA~y3bRo77p*5|J9_Jh+1|(26N^5$o>(JYXzH{EWxmTt=*)s;7Azks zSfn3aq`)HYndH|Z5+BZi<;QwdzL{_!dDBGhocIWU#6wDBA%b~`xaWPE{T`oW^!V5{=*69;0ScTkQk zJG2+u66XDDdqT%BB9kg{3IW}dHE}ir-533nZgCa?_mhQjFAb)2>S_`9bNibn9&wD5 z!Eqb`PLtko84ipsDKEMN20ux^woZ{klLc}m7J9oe1cqZp4P!3ztv7yiE41 zXJXqgvh$<(ffuIj;ivyvV?O`p^Y4#v{)OYuKij-`LJh|B1AUn7Sr=Y7>qehSuBGvW z8={h^=UOz%pL+2$^UcfWi>5Xl)0w&rIJulB^C#CwCx5UTl2?PENviLZ9cezV=JV=C z=aq5|8=~sZCHUgbZs|Ca`*Lr8MulE5$$qCbtR%xNUOhS1hfdBf$v;417~-ut&9(lL z?=Yxl1wYMG^hTq1_?yTZ z9_pe`c)(FTDNo7v78NuoCzlgsV{t~)zUNEbB3thVH;2WhI_0bSTLPU_a;vR7)k#xl zGvn%V62}SZ9iXTC| zk?`d!YV!W7fyEp$3{B*JWoqyUeC`JQVo!|2w#o{MK^CL*$GmgzoYS~%XaiCFS%9^i z>%&Ism0z-K*Wazw!35KZ{uXKfoobYW{b^R9&7USA?ZAE;@(u(YXMV=CN>|TX?-b1U z#9cJ&rL$i8UFoH2;c^qVcT>2$rh;~bC?zk$WihRBu%>94mfdK7redGcke{yq5pB3_ z8$#hF<(JnPX}wgax-5$R2?wzn)#@N$X13pBxU5jKI||pa>w0YbuwyTFRKSoaK!uyJ zlBEoL;&4FnlHw3M}X$2%+OO5STQyhr^)Uai8w#bioea)a`Fw zai&n9-D1)9bFnxnk~57rNCdY}O9RD$e{FZZhtou6wlx~DI4$^;Y2Ss9kAWuyfgc}L zij$CaM}O{uk>?~SzRHH6PS!sfzuLy;Q16ANjya6-b(@|Uuuh)gXoIi83Q1mII?5_} z%8VEIRzrkrXT2>P#}7rEX^}l8tB#UY7v;Xz9g=@0$TYMshL}zlDc=~+6>QKt{tXUV z5FGCat*_aZ6CLyImyQRz=12bioHcca9thZIe}83&HjJFrExv7}%L#xp@_n5ZYtUn@ z|NVpU^S%}nZY07z-afv`@W{^V&5sfiKZR#D#!Go9Qe%nwRk41kvUP^bH%|(L#D-x} zr`dya)03*iL#qB6_*mM8r!=XTQdx@HH|31p_1{P-5fLF9heae!M* zWqRdmi_h_MS?}+?(BLeMW@&UswE19%cgxccV^BVZbzMEMZ}|`+m5Bh>4bEgL9SSGe z!yjsIlf)u(JMI#a&Ys*^O3hO0Jx{JVwSOnqx`8Lxx|t_8ysanKoWhgqYw_fo<2|{~ zyPn)0c%EmS>(K|EO-(gHokx;wziY-@SD#5jb10#abH@|+|rmO`}k`c_bd>}2?L$U&8TsREwT(;=@yah+)H*M2gaBO`R!(K^t ze9{St+aZfY+Z+Dw2_Ibve{~sz7CZuLLV}~WdfTlJIDiMCm$ZAUr*K5oNat52&@oQY zvau*jmGAgc0KKR&D>u-|ecf*o&ws!>!JF{c#QvQ@^{s0=Es_)ZHV{tTKom^dfaRGJ zH-HM(bPLCdtSRAb)0-6DjwXe7CLMd*vnPf3-3ep@M!HOZ8v0Y9M$rVQj^$i~Di5Cb zl;WwfBJFqc`0Y85f6j}%x4cM^qQ>Oq5fc_Frq7wY$gAs6DbOzHy;neAbAM!?WgiHv zQH7-y-sXG_x@Vl?Iv|@q(gJ*(ns0kLVKKf2`|3;p8fhH>5QKR#!3Z?CP8;_b~s}xnJX{Y%V%5d%QXii zv1Q0!p*w3(n+lzT?@dmV6cb!UT-39!!)1;M^Onu|zJFD7J(Fa4MSoBkrpNYv2ru5A zU_6mfR#8qzX`IS@oQ>vfbLEaZrY$wCYOee|5$vigajYWE#W;r5YCUelaTZ&&6DWE> zX{R2i6bN|V9jCjzC89i}UFsp{KU6*yAWRFbMzoF`gIPSy;%TsWvd`{qj_diby|#VQ zy2;)x-EMo=XT!6FaDSQ(FD!=}=fiWW*C)jD^O&YhccG{oKGx5N^|~0f>8`@)m+A1e$v*)-Aql5Q!jk{8TH`0IcDyOADnUGt^skh|KG zR3okcMz4i=2~Qvio{(XPbJ|zW+3E{6$alr!C14w;860ml^f(@t(^FRMpjZ2`J6EQ4 z!vR_F4W)xN3k`~h5ERu$YKp)S&tK2WaoT`uIY$k^iiGWvZ&LwbBc9y4U>yUK-+CN> zj)IK9fW|*#`T`bUDR{s}AVwn*<>|9Z>Q$05J)IND7ssSVQS`n)VjG6WWT0W|<4KBQ z2q1aA$SovMJ9Vc-$1$jO%!uOu5wQ46Cb9CE03D~$Z*WY{BoG;gL ztM-YDsB>(87}TDrba_{h{K*{5zo5PVFZ0m+#^A0RHRV%MS|unHd2LXGKZ3@8fSHlo z5+jd)H4S+|20A#~1&zM=!}ZjRj=qt2fh7n6qyA1tqv?B{tSWqORI0NI^h6|ljIk-I z^>wHDR1`cdnI0GN7Gzo`0NTo(hTk#P{~Ir1`IsGJDx8BkXOe?`g;~t0L6^T`NM12V z9%TnA-J+T;e2`Sannt^TgWf@ZdBNe~k6U86{w9HF*}z^L^$gu3gn!^sU2fC>DP! z3P`C9&c`71yv;a^?Rz5w(dB;S3>W~TC@A@9oa_f(ksKpO&O%1(#jU&$51eV?qg<=l zH@Xqf%ax>&7kYNEYI3l$vcePofD?`^Lhw_95FXY)nv1l8=C;{}4O5d+2hpqs<@hMR z0tuDY6>QII+<+Za5O%bG`O!NUr?8G%*9RI{$_5*%dL6snJS@w}E*%A(BBS&*;jw40 zeooN)bm1+oja!<)aoEj3jhv{+kpZ!GzZ4==*Yc~r1;@7vL{>yQtBH#k7dANLF2MT8yMS}TC0@QxP$*lYm{F&6 z@z!MC;L7o6d3`4$1_U(hPKD=;=lcO59%vv997ti`&<>@Ii&jpNud{u=$rLk)o+X2u z3IEao^5mpP1d8v_L`Jd%L+4up);tn?bFcqbZNSpqmuPu^y+Kp{#ySx%S~qX`AS5YC z0Od`8`m14C!4Fnvc;L+&;QW-A^a#&|yb1Jt6!jVEwz^dDqJbtuY zv&-;{{~kUpOV9Ct5)2lC^cKW#j=_xL+mx5<>E6CJ+Or$U<3;|(NS^C471||JsiQ$XMHI}6 z9Vir9?9h(PMGEI!yvZdc<6*@@T5!{g(_rZ0X`6By){rCUxZGijNiqI)oXA>m;ils@ zc)*mhBctEP1(9~Mww&9w<$1v^+KSU`d7yLFbxfguu+c~3$;GhR1ZR&Zbp9d_k&~Op zEo!i`wpcrmC9h9TO;%Sx9%tKflWk@7*0y#SB@yDmTdvmw7kandz>H*BG4>dM5x(B@ z?ai5Xu9iG)aRtwGPpUi&8!@F|L$~C^XgDXhT>oJtIP3#DaySHPc<`$%v_Xr-1PR4H zrIYP{#VPWVPMvnwp@kiJ%?R^4_=guM;6>8wjE|=3L+C2a%(>8Lq-h!j3SRA;AQh z*s_M9#Kl&7?xk_A5zt?AwC}r4#qm|d-pxmUv93=@X-fZ`Cq8cjx zTZYO2#LzXbvMUBbU$TI%Pu1;M6qessF2qFO^op(aVfhavYZ{B^D;9_38PlsRy&Gw0 z*|WAZYfE>hEuB5K9rFgqwtcH(+qv0)u^ry**bZ-YY)7{}ww-B@?LhmnZB2D-w^dZM z9NS)mb$QW?crug1-N@F9azqZYSdLVM;JZ>4_EgLyue(Rqob5D+!_DDvZ(I`{meA+c zE$W8eVtLpI=WDa0bVt^n^~icr=$!M&emIS$sBHG>U1}Ko9H)RLDwc~N7W+bf*(ro5 zZf{6YhKXMvio&4FIb#PsfH|ikaIhxXH!g?;@Fnxr198lN;StvgIK!1T*ugV8c#>$&3LlKkcU6RCCSRP%7q##>*j8vSMBxk$smDzTK^sD~MbNhG z8|TSWbI(wej*(^V3I#4Cyh=aJhnVPHouRY~h_F8&9t8AZF?DcH?ow9K8a3?2QD59;`w@ek;Nw!yxot+JWwtatkYwNqy z-`chgG~YL6YS7V^E^vLcTY8wYrO6WVG-nBEm49Vbvibr5EYo4{YK~NSw~D%D&i<7T z@UO_xle+aIGZUcO1%FITbBe4p$~CDcBE=`$qr74z<&W$=A%f#%*CZ3V7Ll`Tb{KpI zdn4P>dWMf+6YWP;@4Zy#$Mf`se|*~&CFeg_b|7^D+S}xe;gl{|{^N?Sms}z~a_j|p zpMPqimn11ucheNEO}IRPcFp&ohj%-Q8f@|rXlsG$6_M-xd{II@HIY5rw{KWj+7GNy zMxOJF#$0p*8cO!ffhvW-9aTJ4rWJaGb>Bhb05Nl~g%9S_Z$ABQd-`2IlLBpLQX_T) z_nb&){h#-xhLgkh{cr(`1O{#+6J=;=w0{L@QiF=ztiN6%-Rpi?*q9QKFZ0}&i zx#FU{*`UYjSg_zxo_(Aic$S7W%zfrtTM*3E0i+VXXt`!@zubujTXeWBpdRxAppKKfT2Jq7+Z0XlRFm%ro6^{Z=US(2T$>S|8GZ44N8~Qt8;NA-x{ShsXr6d z>(m&?)&{vt_qg&BDeL4_w!Q(I?v;`4cVuAZ+m%p=Sd9`P^8eOED*QED8#E&riZ?BbcJb!DZcXu}^ zZjdA_ndw;N(9^>9~B*^(ajd3t>x!Xb8+x?t*GiTn+nKuIjiyurY zSo}K{EGR_MY(#sJi2cdt6b9O^!Yx=#}7F1G+*Ac0sYF#ERI7|Fh0u?!VX zNEzWkSnYFUp=5Wjcxp(Z-{iuexZBENvlN)6zzw8;rB@30--i?c1%K$6fo4v2oA>@7 zd+*Cou?l+t8=lq^mA>E%xF&4u9=7QAfN`-m4TyE-{WD+)E4q~u-5bb1z9kX`P_MoFsQHxORDw-ITIuR)%Jw_5CYDRBGb9(83+1OJI55j~dj}B-1HP z!uOvqPSa2C^M8_imWTS}p$uz!+-I-^gu6M-lc#N23yEZOO3wT%kjF@Z7c**0U7cnG ze{ep_OOn%_9*l`rE69A`c@ti537w-qVL~p!N%s-gR^55Ng7bt%^O1YZH$)nEPTl}L zNoUz7Me#^S32h7ByI2NciMK)wk--0qzcvH>H>|;8tbZ%K9;3r2sXDwSpEX~y(N2V! zqGunlKCU6Ec#EX8G_QXvbIc*eRL;bJrAPZdHQH6d{T(1<_T}z@-}TJhn&<#?1&G0u z<4&6YBRu&69aafhnj%u%UtTlDKKrJo@3iZ4i@8_Kn${Suk$>E9gUt&CH%O$R!OW3Wk_%==i^9cO(`kOxmI&3Q? z3K$}|xE^bj@1AAlQ(qLL0tFw-9|*$1>!E0K(0@_k5ic*3FA8E6I9iKDuskTjdKi2V zT?@Y~tEzmt8a_^wP4XmyMCSh3dfeq6wF0xDE>pgX54$f}w&RP`C6#eXxae1p zmIVK@W!m0imo#RB+Wt1pnn95 z2h{tQJ}2c>O}LI|g3JK6X*D*~YuENxisnDx$O#N+HM z($B6$t%FD}po1|UC3^YyUHf4O{C1mvM9s&m%3n?#b&{&JKO7d;1&*VgjN$%|`Zisxwwto{^tn<;w z=*uE)W~iYfjsc;(1fiHmqTG~61k<@G#^uOnEH_$o8W2npt?^2ig(F*W9pd%8)W%5k zsI^*nuu`OJ&X+?q$qu&P2ymeyi%HhpknPxETNj;aR&)H8-Gt-7qcD`zts19*{SiXX zP;c95U5yZyX$Fd>;sbNtc7JH-ycSbHx@d2Q*o9W?lU4*PHa=JDYQ?psbQa@=8Uww} z5NKF@Z0TLCnZVNGlmQZ-gJ*e^7^g{*pd6^uBjaqtHkPCM=^i3Tk4NHp|((2ellGAQ722HhB-wj>)~ z7>NlJ9}g4W3a0J3T7UZHxb01$?3)PU=ncCr@*OPJoC|1#*Hi?XhVW1@x1mN3S3poAq*_J8Fv>ab-e8*nYVbz)4^ zhNswfFdPGB9wR(iRrF+0L*ZPb*w3LdNWumq+_iuKuL;{XMXGWx<}m^-XU~K`Vw;Fi zJKKb#4NU)-<1x}~omEIRnH4Z>%L)#d#t<8E!6h5B;J|JhtFm`r~$N z$GJs2PNW3I41Zo;5-SRHVR4#o>D*Vc+>x0g{`@EkAHiHF0-$A7Tk(c|60W<-BfNuwP<9kRL} zeHph)a&`#Pf$ok0s**H++B8Mxw!a^;1KwV(u`tIvPPVT@P}#?2nPYnPDmyh6V~m~F z*x2NR6{IU@r*^;4V6X1V-JuX!FW#870li3s6=>SZZO8bde}~+4TQ%Cz8FNRyt4dTD zE1Bv8%YTIrI^hsizV98{c&QC6@~yyXG3c=5HRvD0D*%eoh#~1hjZq3)sJN^^81DDw zh8=jrF+o_woy!*6(+3uf(4s-jl@~jiV$6tAgkx~YX_wS-a;Op+jXMJFE8Z@U-H%oc zU-gW}lTf2V6OXaRL_Qs3Mc!zv)_)g-`oP>aw12dTvERN5`pW_EAh^cHkW~2&Y;$qd zs{>|C)MTUFw)G^3B2j8E>`gF4!J4bHq~Z`^*KXaP$!eAD_V`bY4fuA{G%(`uyNDk; z7F!=8cV`H6#mbJ^>csdN*0rqn`Tl@b8D1m|hql24V^bsgK??zG@}$ln(e3<$pPCYE#On>E@S*>hsdA0>c0y@^YOqb@7!49r&BY{6I z_yQ~ApjYf`R*bz5qI4s1+ZirIO=i@LHlk5@+>1q5*$Q96NUbdqV^0n8N3x z^lwatK^NY)+QhsD{`^50bEs5`X`7>)b-a&gHgs>*EL&Y;)n0Tg9K?mV9U$Qe^e1$i zy~OEZ`;-lteFYIy2$h%!6w2f9U~hJBrL^=C^olO(z>b#t5tF`?7k?PhhTFEGr11y} z0ZPtn3kHp72F%dabGG`ztnL^{~j@YW0IIFW#qSNyX4V25+NKdv8%Ga5G~ z(K0pn%uOLtK4z3(4u5VAtv4wdbhLCp%fcDRz zD%HF^IMchph_9v4-&zygrWR7--+}<%!Eo5ZOpPkiA;*0)I;@USN0nhrA!-@VrRk zfjY1&K`p@2AEO`xUo)g*!drJkswj7hvMV?SaS*JFVLyj0@-~(64iK1;ZU~eYIE!qP zcdS#`io;&lxBUe%O5tYduBhc8*1%SXzGOXea{~KioPGYqd@{Pc}9acIzYm3 zJcy-XU;_^%{dkaQ{3;I6;WQq^(gEUYnBu_JFeZ_PWgnWhr2)h<$AegoqXER#@H&>S z10>Y&I(85>^pC@j-^gx#pxbp`ZAK{PF)=_3!mlP*5Pv=z_?j)A>sVUxJWmT=HylLvnqIb&KEHPHrP>l`Z(hMgB_ThaI`k>yAlGcnyvm!|biBCsLxnphU= z?jJ&O5q}%Wd72F^qK`MptHvS1^rwkih=Pe*aNX$qrj8q?r&`NY2kl_#mmWdrPX``a z(RAQZdw`Aw7kh{IRY0zp9r}6rw2ycBE+dZ@hNX-`w=pSpI&=&Nnhrg%CPa5EPc!-n zOG$h4I*PCL0($67i5{5~qT8l7CAu{!x;-hn6MsyJ?oNpwfT|<35H*QIq|o(cdOF*N z=Dldbp$|M?D|qABSM@*3A#Stm7<@wTPlF>HB^@(o;xBsdS}$ z`xe`lPVny$w`$I ze~G%x_Agl`3MP&WN!?_H$UJu%cAQPG>X#$GzyS_dS89~jtNxr;|V)iJS zrDkzl4~pfNvQspKYb-NlyTyzW;+KVkr z3&-zarM%UvsPQzc;@PpA#1tp@pw4#kf6I~CT;wP>7kVQ|hwEh`vP+cuX_)DBsE8wa z|6C6M^E`cENJhwMA22WInW+waQXM5s2>LR^aQZOj=rs|Hg+X`>B`R{l;8VOk z6JhkglP8&Fw*5X50iA{I0sG`Ha;>@ltaxm zq4P^NMf5~96SMVs0a)6s+aL##bhj5-vOVl%l*AA;jJT!f@ZKKQ1_No$y0PoT$E;`> zxZ9s#d(;Ea-JmympGwVdbtK6_f4SN1%4#nu*DTWy7xe~J6d95xQUK~~ST2g?>CQzl z-B55;m6TcqhDs_bzYYqnw?hX`=eWqfw%S(=Qvj+@f@PT3hoZ5DMnn%rexxV2iK-=& z_AHV^G1Z{JBbrNZqe<{J&w5oHJH~WP{h09nx-qdtigwIEKc)?4pLR@FfB(EY*5!A< zS~)$&^i18DAOdv1y6tG6AukMdzqoH|-$Op~s_TWs3^(17`ZszITDl=aOZ&Qv0_|go zi3;yPURE%+rEpr%FI7e3|IxGmxI<_9o>W<1a*c zvovmM(<@JB+xk#*`@~-ee?PEHrR+PH*2%KSx=%!(+iqcObql-@W4Bn~%PkhvTNyRA zSb%N|`bO2Z-~5t>@PGpVW0coqePb$ORc^rkYkyF_pehlAng!-%)7!mxC5GIi^xERa%v~^|N>(tm>y5Pr>b51=1gNSHd1Mq&qGQl! zpn;Tnt#jneR7%oD0hN5E4E&M}H2Sjc(~)-t`JcqkJ{2X)lP{be0lt$?oU4DgA_m1L zMqYz7qf6P;fRh{~E{wl81@H*`3_=om3orXXb4~DD8cdX~_=q6~$bbdJydTD&mo=7L zHOjS-7^_27!6xm#KemhmMIFJ>Y09+9P9MEy+xI^k^vcK#y%cbWCKo4&b{Kh_e{ps^ zV3iMpg!~qrI0jhFFW4lxe`$XK!hwtHr`*D%7Y4m49X{LA*d+jj8y%NyY2^DKjQ@i7 z|94pZf1~GM+A=y)w(yaG4lYobK#sr{8#ovY(w_=k@w8}hvluvf$rhVl!WzYi#<76q zBo2<|Ea@KHdFM?2^q;W90T!mOm2v!Gjf?z|>rb{{{S96&!Q)Hp`1XIBzbCs#qQQI2 zz5&{!&`X-{e-ejB`^j1L;oqv{h3cWmdUITEtGy%;bj%Z_9aJ181gnYrHm8$t((=XbOX8$ z=jaAbH4^M{t13^hQ}ciEm;d Result Option<&'static | ClientMessage::AbandonGame | ClientMessage::SeatMutate { .. } | ClientMessage::Concede + | ClientMessage::ConcedeMatch + | ClientMessage::BootstrapTerminalDelivery { .. } + | ClientMessage::ReadTerminalResult { .. } + | ClientMessage::AckTerminalDelivery { .. } | ClientMessage::Emote { .. } | ClientMessage::SpectatorJoin { .. } | ClientMessage::RequestTakeback @@ -1121,17 +1133,29 @@ async fn serve() { // replayed — skip the restore pass entirely and let SQLite ignore the // stale rows until operators clean them up manually. if matches!(mode, ServerMode::Full) { - match game_db.load_all() { + match game_db.load_active_full_sessions() { Ok(persisted_games) => { let mut mgr = state.lock().await; let mut lob_guard = lobby.lock().await; let lob = lob_guard.lobby_mut(); let mut restored = 0u32; - for (game_code, json) in &persisted_games { + for snapshot in &persisted_games { + let game_code = &snapshot.key.game_code; + let json = match serde_json::to_string(&snapshot.persisted) { + Ok(json) => json, + Err(error) => { + warn!(game = %game_code, %error, "failed to serialize restored Full session"); + continue; + } + }; info!(game = %game_code, bytes = json.len(), "restoring persisted session"); - match restore_persisted_session(json, db.clone()) { - Ok(session) => { + match restore_persisted_session(&json, db.clone()) { + Ok(mut session) => { + session.full_runtime = Some(FullRuntime { + key: snapshot.key.clone(), + activation_epoch: snapshot.activation_epoch, + }); let lobby_meta = session.lobby_meta.clone(); let is_started = session.game_started; @@ -1178,8 +1202,7 @@ async fn serve() { restored += 1; } Err(e) => { - warn!(game = %game_code, error = %e, "failed to restore session, deleting"); - let _ = game_db.delete_session(game_code); + warn!(game = %game_code, error = %e, "failed to restore active session; retaining fenced row for recovery"); } } } @@ -1250,34 +1273,74 @@ async fn serve() { mgr.reconnect.check_expired() }; if !expired.is_empty() { - // Remove in-memory sessions first (state lock → connections lock order) - { - let mut mgr = bg_state.lock().await; - for game_code in &expired { - mgr.remove_game(game_code); + let terminal_candidates = { + let mgr = bg_state.lock().await; + expired + .iter() + .filter_map(|game_code| { + let session = mgr.sessions.get(game_code)?; + session + .game_started + .then(|| { + terminal_artifact( + session, + None, + "Opponent disconnected (grace period expired)".to_string(), + None, + ) + .map(|artifact| (game_code.clone(), artifact)) + })? + .ok() + }) + .collect::>() + }; + let mut prepared = HashMap::new(); + for (game_code, artifact) in terminal_candidates { + match prepare_full_terminal(&bg_game_db, artifact).await { + Ok(deliveries) => { + prepared.insert(game_code, deliveries); + } + Err(error) => { + error!(game = %game_code, %error, "disconnect terminal preparation failed") + } } } - // Notify connected players and clean up persistence + let removed = { + let mut mgr = bg_state.lock().await; + expired + .iter() + .filter_map(|game_code| { + let session = mgr.sessions.get(game_code)?; + if !session.game_started || prepared.contains_key(game_code) { + mgr.remove_game(game_code) + } else { + None + } + }) + .collect::>() + }; let conns = bg_connections.lock().await; - for game_code in &expired { + for session in &removed { + let game_code = &session.game_code; info!(game = %game_code, reason = "disconnect_expired", "game over"); if let Some(players) = conns.get(game_code) { - let msg = ServerMessage::GameOver { - winner: None, - reason: "Opponent disconnected (grace period expired)".to_string(), - ranked_result: None, - }; - for sender in players.values() { - let _ = sender.send(msg.clone()); + if let Some(deliveries) = prepared.get(game_code) { + for (player, delivery) in deliveries { + if let Some(sender) = players.get(player) { + let _ = sender.send(ServerMessage::TerminalResult { + delivery: Some(delivery.clone()), + }); + } + } } } - if let Err(e) = bg_game_db.delete_session(game_code) { - error!(game = %game_code, error = %e, "failed to delete persisted session"); + if !session.game_started { + retire_unstarted_session_async(&bg_game_db, session); } } let mut specs = bg_game_spectators.lock().await; - for game_code in &expired { - specs.remove(game_code); + for session in &removed { + specs.remove(&session.game_code); } } @@ -1303,9 +1366,16 @@ async fn serve() { info!(count = expired_lobby.len(), "expiring stale lobby games"); let mut mgr = bg_state.lock().await; for game_code in &expired_lobby { - mgr.remove_game(game_code); - if let Err(e) = bg_game_db.delete_session(game_code) { - error!(game = %game_code, error = %e, "failed to delete expired lobby session"); + if mgr + .sessions + .get(game_code) + .is_some_and(|session| !session.game_started) + { + if let Some(session) = mgr.remove_game(game_code) { + retire_unstarted_session_async(&bg_game_db, &session); + } + } else if mgr.sessions.contains_key(game_code) { + error!(game = %game_code, "refusing to retire a started session from lobby expiry"); } } drop(mgr); @@ -1482,17 +1552,17 @@ async fn serve() { let mgr = shutdown_state.lock().await; let mut persisted = 0u32; for (game_code, session) in &mgr.sessions { - let snapshot = session.to_persisted(); - match serde_json::to_string(&snapshot) { - Ok(json) => { - if let Err(e) = shutdown_game_db.save_session(game_code, &json) { - error!(game = %game_code, error = %e, "failed to persist session on shutdown"); - } else { - persisted += 1; - } + let Some(snapshot) = session.full_persist_snapshot() else { + warn!(game = %game_code, "skipping unbound Full session at shutdown"); + continue; + }; + match shutdown_game_db.save_full_session(&snapshot) { + Ok(server_core::FullPersistDisposition::Applied) => persisted += 1, + Ok(disposition) => { + warn!(game = %game_code, ?disposition, "shutdown snapshot was no longer current") } - Err(e) => { - error!(game = %game_code, error = %e, "failed to serialize session on shutdown"); + Err(error) => { + error!(game = %game_code, %error, "failed to persist Full session on shutdown") } } } @@ -2142,6 +2212,7 @@ fn to_server_message(m: lobby_broker::LobbyServerMessage) -> ServerMessage { } => ServerMessage::GameCreated { game_code, player_token, + full_key: None, }, L::Error { message, code } => ServerMessage::Error { message, code }, L::LobbyUpdate { games } => ServerMessage::LobbyUpdate { games }, @@ -2379,24 +2450,64 @@ async fn apply_outbounds( } } -/// Fire-and-forget persistence of a game session to SQLite. -fn persist_session_async( +/// Binds a freshly allocated Full key to its authoritative runtime before any +/// connection can publish it. The initial snapshot uses the same keyed writer +/// as every later mutation; single-user activation additionally obtains the +/// durable singleton epoch that fences a replaced native game. +fn initialize_full_runtime( game_db: &SharedGameDb, - game_code: &str, - session: &server_core::session::GameSession, -) { - let db = game_db.clone(); - let persisted = session.to_persisted(); - let code = game_code.to_string(); - tokio::task::spawn_blocking(move || match serde_json::to_string(&persisted) { - Ok(json) => { - if let Err(e) = db.save_session(&code, &json) { - error!(game = %code, error = %e, "failed to persist game session"); - } - } - Err(e) => { - error!(game = %code, error = %e, "failed to serialize game session"); + session: &mut GameSession, + key: server_core::FullSessionKey, +) -> Result<(), String> { + session.full_runtime = Some(FullRuntime { + key, + activation_epoch: None, + }); + let snapshot = session + .full_persist_snapshot() + .expect("Full runtime was just initialized"); + if game_db.is_single_user() { + let (epoch, disposition) = game_db + .activate_single_user_session(&snapshot) + .map_err(|error| format!("Failed to activate Full session: {error}"))?; + if disposition != server_core::FullPersistDisposition::Applied { + return Err("Full session activation was superseded".to_string()); } + session + .full_runtime + .as_mut() + .expect("Full runtime remains installed") + .activation_epoch = Some(epoch); + } else if game_db + .save_full_session(&snapshot) + .map_err(|error| format!("Failed to persist Full session: {error}"))? + != server_core::FullPersistDisposition::Applied + { + return Err("Full session persistence was superseded".to_string()); + } + Ok(()) +} + +/// Fire-and-forget generation- and revision-fenced persistence of an active +/// Full session. Terminal code never calls this writer after preparation. +fn persist_full_session_async(game_db: &SharedGameDb, session: &GameSession) { + let db = game_db.clone(); + let Some(snapshot) = session.full_persist_snapshot() else { + warn!(game = %session.game_code, "skipping persistence for unbound Full session"); + return; + }; + tokio::task::spawn_blocking(move || match db.save_full_session(&snapshot) { + Ok(server_core::FullPersistDisposition::Applied) => {} + Ok(disposition) => warn!( + game = %snapshot.key.game_code, + ?disposition, + "Full snapshot was no longer current" + ), + Err(error) => error!( + game = %snapshot.key.game_code, + %error, + "failed to persist Full session" + ), }); } @@ -2441,7 +2552,7 @@ async fn create_and_connect_multiplayer_session( connections: &SharedConnections, game_db: &SharedGameDb, req: MultiplayerSessionRequest, -) -> (String, String, u32) { +) -> Result<(String, String, u32, server_core::FullSessionKey), String> { let MultiplayerSessionRequest { resolved, display_name, @@ -2458,7 +2569,7 @@ async fn create_and_connect_multiplayer_session( } = req; // Phase 1 ── state lock; released at end of block. - let (game_code, player_token, initial_player_count) = { + let (game_code, player_token, initial_player_count, full_key) = { let mut mgr = state.lock().await; let (game_code, player_token) = mgr.create_game_n_players( resolved, @@ -2468,6 +2579,13 @@ async fn create_and_connect_multiplayer_session( match_config, format_config, ); + let full_key = match game_db.create_full_session_key(&game_code) { + Ok(key) => key, + Err(error) => { + mgr.remove_game(&game_code); + return Err(format!("Failed to bind game session identity: {error}")); + } + }; info!(game = %game_code, host = %display_name, players = pc, "game created via lobby"); if let Some(session) = mgr.sessions.get_mut(&game_code) { @@ -2504,10 +2622,13 @@ async fn create_and_connect_multiplayer_session( start_when_full, ranked, }); - persist_session_async(game_db, &game_code, session); + if let Err(error) = initialize_full_runtime(game_db, session, full_key.clone()) { + mgr.remove_game(&game_code); + return Err(error); + } } - (game_code, player_token, initial_player_count) + (game_code, player_token, initial_player_count, full_key) }; // state lock released here // Phase 2 ── connections lock; released at end of block. @@ -2519,7 +2640,7 @@ async fn create_and_connect_multiplayer_session( .insert(PlayerId(0), host_tx); } // connections lock released here - (game_code, player_token, initial_player_count) + Ok((game_code, player_token, initial_player_count, full_key)) } /// Broadcast `DraftSpectatorView` to all spectators watching a draft. @@ -2577,13 +2698,86 @@ async fn persist_draft_session_async( }); } -/// Fire-and-forget deletion of a persisted game session. -fn delete_session_async(game_db: &SharedGameDb, game_code: &str) { +/// Immutable terminal result prepared before any recipient is told that a +/// started Full session ended. The database transaction retires the runtime, +/// creates recipient-scoped capabilities, and makes retries idempotent. +fn terminal_artifact( + session: &GameSession, + winner: Option, + reason: String, + ranked_result: Option>, +) -> Result { + let runtime = session + .full_runtime + .as_ref() + .ok_or_else(|| "Full session has no runtime identity".to_string())?; + let recipients = session + .player_tokens + .iter() + .enumerate() + .filter(|(_, token)| !token.is_empty()) + .map(|(player_id, token)| persistence::TerminalRecipient { + player_id: PlayerId(player_id as u8), + pre_terminal_player_token: token.clone(), + }) + .collect(); + Ok(persistence::FullTerminalArtifact { + key: runtime.key.clone(), + terminal_revision: session.state_revision, + display: server_core::TerminalMatchDisplay { + winner, + reason, + ranked_result, + }, + recipients, + }) +} + +async fn prepare_full_terminal( + game_db: &SharedGameDb, + artifact: persistence::FullTerminalArtifact, +) -> Result, String> { + let db = game_db.clone(); + tokio::task::spawn_blocking(move || { + db.prepare_full_terminal(&artifact) + .map_err(|error| format!("Failed to prepare terminal result: {error}"))?; + artifact + .recipients + .iter() + .map(|recipient| { + db.current_terminal_delivery_for_recipient( + &artifact.key, + recipient.player_id, + &recipient.pre_terminal_player_token, + ) + .map_err(|error| format!("Failed to load terminal delivery: {error}"))? + .map(|delivery| (recipient.player_id, delivery)) + .ok_or_else(|| "Prepared terminal delivery is missing".to_string()) + }) + .collect() + }) + .await + .map_err(|error| format!("Terminal persistence task failed: {error}"))? +} + +/// Only a waiting-room session can be retired without a recipient delivery: +/// it has never entered engine play and therefore has no terminal outcome. +fn retire_unstarted_session_async(game_db: &SharedGameDb, session: &GameSession) { + let Some(runtime) = session.full_runtime.clone() else { + warn!(game = %session.game_code, "unbound waiting-room session was removed"); + return; + }; let db = game_db.clone(); - let code = game_code.to_string(); + let game_code = session.game_code.clone(); tokio::task::spawn_blocking(move || { - if let Err(e) = db.delete_session(&code) { - error!(game = %code, error = %e, "failed to delete persisted session"); + match db.retire_unstarted_full_session(&runtime.key, runtime.activation_epoch) { + Ok(server_core::FullPersistDisposition::Applied) => {} + Ok(disposition) => { + warn!(game = %game_code, ?disposition, "waiting-room retirement was not current") + } + Err(error) => { + error!(game = %game_code, %error, "failed to retire waiting-room session") + } } }); } @@ -2685,24 +2879,25 @@ fn ranked_result_for_duel( rating_delta: db, }, ]; - if let Err(e) = game_db.save_ranked_result(&deltas) { - error!(game = %game_code, error = %e, "failed to save ranked result"); - return None; - } - Some(vec![ - RankedPlayerResult { - player_id: 0, - rating_before: ra, - rating_after: ra_next, - rating_delta: da, - }, - RankedPlayerResult { - player_id: 1, - rating_before: rb, - rating_after: rb_next, - rating_delta: db, - }, - ]) + let saved = match game_db.save_ranked_result_idempotent(&deltas) { + Ok(saved) => saved, + Err(e) => { + error!(game = %game_code, error = %e, "failed to save ranked result"); + return None; + } + }; + Some( + saved + .into_iter() + .enumerate() + .map(|(player_id, delta)| RankedPlayerResult { + player_id: player_id as u8, + rating_before: delta.rating_before, + rating_after: delta.rating_after, + rating_delta: delta.rating_delta, + }) + .collect(), + ) } /// If this game_code belongs to a draft tournament, auto-report the match @@ -3096,7 +3291,7 @@ async fn broadcast_game_started( }; session.run_ai(); - persist_session_async(game_db, game_code, session); + persist_full_session_async(game_db, session); ( build_game_started_messages(session), build_spectator_game_started_message(session), @@ -3393,6 +3588,43 @@ async fn handle_client_message( // Unreachable: IgnoreRedundantHello above handled this case. debug!("unreachable ClientHello arm"); } + // These terminal-only messages deliberately do not touch identity, + // SessionManager, connection registration, or reconnect leases. A + // terminal-unavailable bootstrap leaves the caller free to open a + // separate ordinary reconnect socket. + ClientMessage::BootstrapTerminalDelivery { request } => { + let response = match game_db.bootstrap_terminal_delivery(&request) { + Ok(delivery) => ServerMessage::TerminalBootstrapResult { delivery }, + Err(error) => ServerMessage::error(format!("Terminal bootstrap rejected: {error}")), + }; + if let Ok(json) = serde_json::to_string(&response) { + let _ = socket.send(Message::text(json)).await; + } + } + ClientMessage::ReadTerminalResult { credential } => { + let response = match game_db.read_terminal_result(&credential) { + Ok(delivery) => ServerMessage::TerminalResult { delivery }, + Err(error) => ServerMessage::error(format!("Terminal read rejected: {error}")), + }; + if let Ok(json) = serde_json::to_string(&response) { + let _ = socket.send(Message::text(json)).await; + } + } + ClientMessage::AckTerminalDelivery { + delivery_id, + credential, + } => { + let response = match game_db.ack_terminal_delivery(&delivery_id, &credential) { + Ok(true) => ServerMessage::TerminalDeliveryAcknowledged { delivery_id }, + Ok(false) => ServerMessage::error("Terminal acknowledgement rejected".to_string()), + Err(error) => { + ServerMessage::error(format!("Terminal acknowledgement failed: {error}")) + } + }; + if let Ok(json) = serde_json::to_string(&response) { + let _ = socket.send(Message::text(json)).await; + } + } ClientMessage::CreateGame { deck } => { info!(deck_size = deck.main_deck.len(), "CreateGame"); if let Err(reason) = guard_legacy_deck(&deck) { @@ -3429,6 +3661,35 @@ async fn handle_client_message( let mut mgr = state.lock().await; let (game_code, player_token) = mgr.create_game(resolved); + let full_key = match game_db.create_full_session_key(&game_code) { + Ok(key) => key, + Err(error) => { + mgr.remove_game(&game_code); + drop(mgr); + let msg = ServerMessage::error(format!( + "Failed to bind game session identity: {error}" + )); + if let Ok(json) = serde_json::to_string(&msg) { + let _ = socket.send(Message::text(json)).await; + } + return; + } + }; + if let Err(error) = initialize_full_runtime( + game_db, + mgr.sessions + .get_mut(&game_code) + .expect("new game session must exist"), + full_key.clone(), + ) { + mgr.remove_game(&game_code); + drop(mgr); + let msg = ServerMessage::error(error); + if let Ok(json) = serde_json::to_string(&msg) { + let _ = socket.send(Message::text(json)).await; + } + return; + } info!(game = %game_code, "game created"); identity.set_session(game_code.clone(), PlayerId(0), player_token.clone()); @@ -3442,6 +3703,7 @@ async fn handle_client_message( let msg = ServerMessage::GameCreated { game_code: game_code.clone(), player_token: player_token.clone(), + full_key: Some(full_key.clone()), }; if let Ok(json) = serde_json::to_string(&msg) { let _ = socket.send(Message::text(json)).await; @@ -3450,6 +3712,7 @@ async fn handle_client_message( game_code, player_id: PlayerId(0), player_token, + full_key: Some(full_key), }; if let Ok(json) = serde_json::to_string(&attached) { let _ = socket.send(Message::text(json)).await; @@ -3492,7 +3755,7 @@ async fn handle_client_message( let joiner = session.player_for_token(&player_token).unwrap(); let started_messages = if session.is_full() { session.run_ai(); - persist_session_async(game_db, &game_code, session); + persist_full_session_async(game_db, session); // The joiner is excluded from the fan-out send below // (`pid != joiner`), so it receives the contest dice via // its own message here. Snapshot the events before the @@ -3637,28 +3900,22 @@ async fn handle_client_message( } _ => None, }; - let ranked_players = if game_over_winner.is_some() { - ranked_duel_players(session) - } else { - None - }; - - // Persist or delete based on game-over state - if let Some(winner) = game_over_winner { + let terminal = if let Some(winner) = game_over_winner { info!(game = %game_code, winner = ?winner, reason = "game_rules", "game over"); - delete_session_async(game_db, &game_code); - - // Auto-report draft match result if this game belongs to a draft - // (spawn as a separate task to avoid holding the state lock) - let ds = draft_state.clone(); - let cs = connections.clone(); - let gc = game_code.clone(); - tokio::spawn(async move { - report_draft_game_over(&ds, &cs, &gc, winner).await; + let ranked_result = ranked_duel_players(session).and_then(|players| { + ranked_result_for_duel(game_db, &game_code, &players, winner) }); + terminal_artifact( + session, + winner, + "Game ended".to_string(), + ranked_result, + ) + .map(Some) } else { - persist_session_async(game_db, &game_code, session); - } + persist_full_session_async(game_db, session); + Ok(None) + }; let lock_ms = lock_start.elapsed().as_millis(); info!( @@ -3668,15 +3925,17 @@ async fn handle_client_message( "action processed (lock held)" ); - Ok(( - human_revision, - human_result, - ai_results, - eliminated, - player_count, - game_over_winner, - ranked_players, - )) + terminal.map(|terminal| { + ( + human_revision, + human_result, + ai_results, + eliminated, + player_count, + game_over_winner, + terminal, + ) + }) } Err(e) => Err(e), } @@ -3698,15 +3957,8 @@ async fn handle_client_message( eliminated, player_count, game_over_winner, - ranked_players, + terminal, )) => { - let ranked_result = - game_over_winner - .zip(ranked_players) - .and_then(|(winner, players)| { - ranked_result_for_duel(game_db, &game_code, &players, winner) - }); - if let Err(reason) = guard_state_snapshot_broadcast(StateSnapshotParts { state: &raw_state, events: &events, @@ -3723,6 +3975,21 @@ async fn handle_client_message( return; } + let terminal_deliveries = match terminal { + Some(artifact) => match prepare_full_terminal(game_db, artifact).await { + Ok(deliveries) => deliveries, + Err(error) => { + error!(game = %game_code, %error, "terminal preparation failed"); + let msg = ServerMessage::error(error); + if let Ok(json) = serde_json::to_string(&msg) { + let _ = socket.send(Message::text(json)).await; + } + return; + } + }, + None => Vec::new(), + }; + // Filter state per-player outside the lock let filtered_states: Vec<(PlayerId, GameState)> = (0..player_count) .map(|i| { @@ -3803,18 +4070,6 @@ async fn handle_client_message( }); } } - if let (Some(winner), Some(ranked_result)) = - (game_over_winner, ranked_result.clone()) - { - let msg = ServerMessage::GameOver { - winner, - reason: "Game ended".to_string(), - ranked_result: Some(ranked_result), - }; - for sender in players.values() { - let _ = sender.send(msg.clone()); - } - } } } if let Ok(spectator_msg) = build_spectator_state_update_message( @@ -3952,6 +4207,28 @@ async fn handle_client_message( } } } + + if !terminal_deliveries.is_empty() { + let conns = connections.lock().await; + if let Some(players) = conns.get(&game_code) { + for (player, delivery) in &terminal_deliveries { + if let Some(sender) = players.get(player) { + let _ = sender.send(ServerMessage::TerminalResult { + delivery: Some(delivery.clone()), + }); + } + } + } + drop(conns); + report_draft_game_over( + draft_state, + connections, + &game_code, + game_over_winner.flatten(), + ) + .await; + state.lock().await.remove_game(&game_code); + } } Err(e) => { let msg = ServerMessage::ActionRejected { reason: e }; @@ -3965,6 +4242,7 @@ async fn handle_client_message( ClientMessage::Reconnect { game_code, player_token, + full_key, } => { info!(game = %game_code, "Reconnect attempt"); @@ -3975,6 +4253,27 @@ async fn handle_client_message( } return; } + match game_db.load_active_full_key(&game_code) { + Ok(Some(active_key)) if active_key == full_key => {} + Ok(_) => { + let msg = ServerMessage::error( + "Reconnect session identity is no longer current".to_string(), + ); + if let Ok(json) = serde_json::to_string(&msg) { + let _ = socket.send(Message::text(json)).await; + } + return; + } + Err(error) => { + let msg = ServerMessage::error(format!( + "Failed to validate reconnect identity: {error}" + )); + if let Ok(json) = serde_json::to_string(&msg) { + let _ = socket.send(Message::text(json)).await; + } + return; + } + } // Determine game phase and handle reconnect in a single lock // to avoid TOCTOU races (game could fill between check and action). @@ -4033,7 +4332,7 @@ async fn handle_client_message( let ai_results = session.run_ai(); let ai_result = ai_results.last().cloned().map(Box::new); if ai_result.is_some() { - persist_session_async(game_db, &game_code, session); + persist_full_session_async(game_db, session); } // Reconnect: no contest dice (the player must not // re-see the first-player roll). @@ -4070,6 +4369,7 @@ async fn handle_client_message( let msg = ServerMessage::GameCreated { game_code: game_code.clone(), player_token: player_token.clone(), + full_key: Some(full_key.clone()), }; if let Ok(json) = serde_json::to_string(&msg) { let _ = socket.send(Message::text(json)).await; @@ -4078,6 +4378,7 @@ async fn handle_client_message( game_code, player_id: player, player_token, + full_key: Some(full_key), }; if let Ok(json) = serde_json::to_string(&attached) { let _ = socket.send(Message::text(json)).await; @@ -4420,7 +4721,7 @@ async fn handle_client_message( if !ai_requests.is_empty() && ai_requests.len() as u8 == pc - 1 { // --- AI game path: create, start, and run initial AI actions --- - let (game_code, player_token, game_started_msg) = { + let (game_code, player_token, full_key, game_started_msg) = { let mut mgr = state.lock().await; let (game_code, player_token) = mgr.create_game_with_ai( resolved, @@ -4433,6 +4734,17 @@ async fn handle_client_message( db.as_ref(), ); + let full_key = match game_db.create_full_session_key(&game_code) { + Ok(key) => key, + Err(error) => { + mgr.remove_game(&game_code); + let _ = tx.send(ServerMessage::error(format!( + "Failed to bind game session identity: {error}" + ))); + return; + } + }; + let session = mgr.sessions.get_mut(&game_code).unwrap(); session.run_ai(); // Initial start of a Play-vs-AI game: the human seat sees @@ -4442,10 +4754,14 @@ async fn handle_client_message( let game_started_msg = build_game_started_message(session, PlayerId(0), None, start_events); - // Persist the AI game session - persist_session_async(game_db, &game_code, session); + if let Err(error) = initialize_full_runtime(game_db, session, full_key.clone()) + { + mgr.remove_game(&game_code); + let _ = tx.send(ServerMessage::error(error)); + return; + } - (game_code, player_token, game_started_msg) + (game_code, player_token, full_key, game_started_msg) }; // lock dropped identity.set_session(game_code.clone(), PlayerId(0), player_token.clone()); @@ -4462,6 +4778,7 @@ async fn handle_client_message( let created_msg = ServerMessage::GameCreated { game_code: game_code.clone(), player_token: player_token.clone(), + full_key: Some(full_key.clone()), }; if let Ok(json) = serde_json::to_string(&created_msg) { let _ = socket.send(Message::text(json)).await; @@ -4470,6 +4787,7 @@ async fn handle_client_message( game_code: game_code.clone(), player_id: PlayerId(0), player_token, + full_key: Some(full_key), }; if let Ok(json) = serde_json::to_string(&attached_msg) { let _ = socket.send(Message::text(json)).await; @@ -4504,8 +4822,8 @@ async fn handle_client_message( // are released inside `create_and_connect_multiplayer_session` // before it returns, so `broadcast_player_slots` (Phase 4) can // re-acquire them without deadlocking. - let (game_code, player_token, initial_player_count) = - create_and_connect_multiplayer_session( + let (game_code, player_token, initial_player_count, full_key) = + match create_and_connect_multiplayer_session( state, connections, game_db, @@ -4524,7 +4842,17 @@ async fn handle_client_message( host_tx: tx.clone(), }, ) - .await; + .await + { + Ok(session) => session, + Err(error) => { + let msg = ServerMessage::error(error); + if let Ok(json) = serde_json::to_string(&msg) { + let _ = socket.send(Message::text(json)).await; + } + return; + } + }; identity.set_session(game_code.clone(), PlayerId(0), player_token.clone()); @@ -4599,6 +4927,7 @@ async fn handle_client_message( let msg = ServerMessage::GameCreated { game_code: game_code.clone(), player_token: player_token.clone(), + full_key: Some(full_key.clone()), }; if let Ok(json) = serde_json::to_string(&msg) { let _ = socket.send(Message::text(json)).await; @@ -4607,6 +4936,7 @@ async fn handle_client_message( game_code: game_code.clone(), player_id: PlayerId(0), player_token, + full_key: Some(full_key), }; if let Ok(json) = serde_json::to_string(&attached) { let _ = socket.send(Message::text(json)).await; @@ -5083,6 +5413,7 @@ async fn handle_client_message( Waiting { player_token: String, joiner: PlayerId, + full_key: server_core::FullSessionKey, slot_info: Vec, current_count: u32, raw_state: Box, @@ -5129,7 +5460,7 @@ async fn handle_client_message( // holds the joining player. We keep them seated — rolling back // would require deleting their deck/token which is more invasive. // The host can correct the deck(s) and trigger a new start. - persist_session_async(game_db, &game_code, session); + persist_full_session_async(game_db, session); // Capture the message so we can fan it out to all connected // players after the state lock releases (mirrors seat-delta path). bracket_broadcast = @@ -5139,7 +5470,7 @@ async fn handle_client_message( Err(format!("Cannot start cEDH game: {bracket_err}")) } else { // Persist updated session (now has the new player and is started) - persist_session_async(game_db, &game_code, session); + persist_full_session_async(game_db, session); Ok(JoinOutcome::Started { player_token, joiner, @@ -5148,16 +5479,20 @@ async fn handle_client_message( } } else { // Persist updated session (now has the new player, not yet started) - persist_session_async(game_db, &game_code, session); - Ok(JoinOutcome::Waiting { - player_token, - joiner, - slot_info: session.player_slot_info(), - current_count: session.current_player_count(), - raw_state: Box::new(session.state.clone()), - filtered_state: Box::new(filtered_state), - state_revision: session.state_revision, - }) + persist_full_session_async(game_db, session); + match session.full_runtime.as_ref() { + Some(runtime) => Ok(JoinOutcome::Waiting { + player_token, + joiner, + full_key: runtime.key.clone(), + slot_info: session.player_slot_info(), + current_count: session.current_player_count(), + raw_state: Box::new(session.state.clone()), + filtered_state: Box::new(filtered_state), + state_revision: session.state_revision, + }), + None => Err("Full session runtime is unavailable".to_string()), + } } } Err(e) => Err(e), @@ -5168,6 +5503,7 @@ async fn handle_client_message( Ok(JoinOutcome::Waiting { player_token, joiner, + full_key, slot_info, current_count, raw_state, @@ -5188,6 +5524,7 @@ async fn handle_client_message( game_code: game_code.clone(), player_id: joiner, player_token, + full_key: Some(full_key), }; if let Ok(json) = serde_json::to_string(&attached) { let _ = socket.send(Message::text(json)).await; @@ -5316,23 +5653,63 @@ async fn handle_client_message( return; }; - { - let mut mgr = state.lock().await; - if mgr.remove_game(&game_code).is_none() { - let msg = ServerMessage::GameAbandoned { - game_code: game_code.clone(), - }; - if let Ok(json) = serde_json::to_string(&msg) { - let _ = socket.send(Message::text(json)).await; + let terminal = { + let mgr = state.lock().await; + match mgr.sessions.get(&game_code) { + Some(session) if session.game_started => { + match terminal_artifact(session, None, "Game abandoned".to_string(), None) { + Ok(artifact) => Some(artifact), + Err(error) => { + let _ = tx.send(ServerMessage::error(error)); + return; + } + } + } + Some(_) => None, + None => { + let msg = ServerMessage::GameAbandoned { + game_code: game_code.clone(), + }; + if let Ok(json) = serde_json::to_string(&msg) { + let _ = socket.send(Message::text(json)).await; + } + return; + } + } + }; + let terminal_deliveries = match terminal { + Some(artifact) => match prepare_full_terminal(game_db, artifact).await { + Ok(deliveries) => deliveries, + Err(error) => { + error!(game = %game_code, %error, "terminal preparation failed"); + let _ = tx.send(ServerMessage::error(error)); + return; + } + }, + None => Vec::new(), + }; + + let removed = state.lock().await.remove_game(&game_code); + if let Some(session) = removed.filter(|session| !session.game_started) { + retire_unstarted_session_async(game_db, &session); + } + + if !terminal_deliveries.is_empty() { + let conns = connections.lock().await; + if let Some(players) = conns.get(&game_code) { + for (player, delivery) in &terminal_deliveries { + if let Some(sender) = players.get(player) { + let _ = sender.send(ServerMessage::TerminalResult { + delivery: Some(delivery.clone()), + }); + } } - return; } } connections.lock().await.remove(&game_code); game_spectators.lock().await.remove(&game_code); lobby.lock().await.lobby_mut().unregister_game(&game_code); - delete_session_async(game_db, &game_code); let msg = ServerMessage::GameAbandoned { game_code }; if let Ok(json) = serde_json::to_string(&msg) { @@ -5341,9 +5718,15 @@ async fn handle_client_message( } ClientMessage::Concede => { - let game_code = match &identity.game_code { - Some(c) => c.clone(), - None => { + let (game_code, player_token, player_id) = match ( + identity.game_code.clone(), + identity.player_token.clone(), + identity.player_id, + ) { + (Some(game_code), Some(player_token), Some(player_id)) => { + (game_code, player_token, player_id) + } + _ => { let msg = ServerMessage::error("Not in a game".to_string()); if let Ok(json) = serde_json::to_string(&msg) { let _ = socket.send(Message::text(json)).await; @@ -5351,62 +5734,180 @@ async fn handle_client_message( return; } }; - let player_id = match identity.player_id { - Some(p) => p, - None => return, + + info!(game = %game_code, player = ?player_id, "player conceded game"); + let outcome = { + let mut mgr = state.lock().await; + match mgr.handle_action( + &game_code, + &player_token, + engine::types::actions::GameAction::Concede { player_id }, + ) { + Ok(result) => { + let session = mgr + .sessions + .get_mut(&game_code) + .expect("handled concession must retain its session"); + let revision = session.advance_state_revision(); + let winner = match &session.state.waiting_for { + engine::types::game_state::WaitingFor::GameOver { winner } => *winner, + _ => None, + }; + let terminal = if matches!( + &session.state.waiting_for, + engine::types::game_state::WaitingFor::GameOver { .. } + ) { + let ranked_result = ranked_duel_players(session).and_then(|players| { + ranked_result_for_duel(game_db, &game_code, &players, winner) + }); + terminal_artifact( + session, + winner, + "Opponent conceded".to_string(), + ranked_result, + ) + .map(Some) + } else { + persist_full_session_async(game_db, session); + Ok(None) + }; + terminal.map(|terminal| (revision, result, winner, terminal)) + } + Err(error) => Err(error), + } }; - info!(game = %game_code, player = ?player_id, "player conceded"); + match outcome { + Err(reason) => { + if let Ok(json) = + serde_json::to_string(&ServerMessage::ActionRejected { reason }) + { + let _ = socket.send(Message::text(json)).await; + } + } + Ok((revision, result, winner, terminal)) => { + let terminal_deliveries = match terminal { + Some(artifact) => match prepare_full_terminal(game_db, artifact).await { + Ok(deliveries) => deliveries, + Err(error) => { + error!(game = %game_code, %error, "terminal preparation failed"); + let _ = tx.send(ServerMessage::error(error)); + return; + } + }, + None => Vec::new(), + }; + let conns = connections.lock().await; + if let Some(players) = conns.get(&game_code) { + for (player, sender) in players { + if let Ok(update) = + build_state_update_message(&result, revision, *player) + { + let _ = sender.send(update); + } + let _ = sender.send(ServerMessage::Conceded { player: player_id }); + } + for (player, delivery) in &terminal_deliveries { + if let Some(sender) = players.get(player) { + let _ = sender.send(ServerMessage::TerminalResult { + delivery: Some(delivery.clone()), + }); + } + } + } + drop(conns); - let conceded_msg = ServerMessage::Conceded { player: player_id }; - // In 2-player, the opponent wins. In multiplayer, game continues unless only 1 remains. - let mgr_ref = state.lock().await; - let (winner, ranked_players) = if let Some(session) = mgr_ref.sessions.get(&game_code) { - let living: Vec<_> = session - .state - .players - .iter() - .filter(|p| p.id != player_id && !p.is_eliminated) - .map(|p| p.id) - .collect(); - let winner = if living.len() == 1 { - Some(living[0]) - } else { - None - }; - let ranked_players = ranked_duel_players(session); - (winner, ranked_players) - } else { - (None, None) - }; - drop(mgr_ref); - let ranked_result = ranked_players - .as_ref() - .and_then(|players| ranked_result_for_duel(game_db, &game_code, players, winner)); + if !terminal_deliveries.is_empty() { + report_draft_game_over(draft_state, connections, &game_code, winner).await; + state.lock().await.remove_game(&game_code); + } + } + } + } - info!(game = %game_code, winner = ?winner, reason = "concession", "game over"); + ClientMessage::ConcedeMatch => { + let (game_code, player_token) = + match (identity.game_code.clone(), identity.player_token.clone()) { + (Some(game_code), Some(player_token)) => (game_code, player_token), + _ => { + let msg = ServerMessage::error("Not in a game".to_string()); + if let Ok(json) = serde_json::to_string(&msg) { + let _ = socket.send(Message::text(json)).await; + } + return; + } + }; - let game_over_msg = ServerMessage::GameOver { - winner, - reason: "Opponent conceded".to_string(), - ranked_result, + let outcome = { + let mut mgr = state.lock().await; + match mgr.handle_match_concede(&game_code, &player_token) { + Ok((revision, result)) => { + let winner = match &result.0.waiting_for { + engine::types::game_state::WaitingFor::GameOver { winner } => *winner, + _ => None, + }; + let session = mgr + .sessions + .get(&game_code) + .expect("handled match concession must retain its session"); + let ranked_result = winner.and_then(|winner| { + ranked_duel_players(session).and_then(|players| { + ranked_result_for_duel(game_db, &game_code, &players, Some(winner)) + }) + }); + terminal_artifact( + session, + winner, + "Match conceded".to_string(), + ranked_result, + ) + .map(|terminal| (revision, result, winner, terminal)) + } + Err(error) => Err(error), + } }; - let conns = connections.lock().await; - if let Some(players) = conns.get(&game_code) { - for sender in players.values() { - let _ = sender.send(conceded_msg.clone()); - let _ = sender.send(game_over_msg.clone()); + match outcome { + Err(reason) => { + if let Ok(json) = + serde_json::to_string(&ServerMessage::ActionRejected { reason }) + { + let _ = socket.send(Message::text(json)).await; + } } - } - drop(conns); + Ok((revision, result, winner, terminal)) => { + let terminal_deliveries = match prepare_full_terminal(game_db, terminal).await { + Ok(deliveries) => deliveries, + Err(error) => { + error!(game = %game_code, %error, "terminal preparation failed"); + let _ = tx.send(ServerMessage::error(error)); + return; + } + }; + let conns = connections.lock().await; + if let Some(players) = conns.get(&game_code) { + for (player, sender) in players { + if let Ok(update) = + build_state_update_message(&result, revision, *player) + { + let _ = sender.send(update); + } + if let Some((_, delivery)) = + terminal_deliveries.iter().find(|(seat, _)| seat == player) + { + let _ = sender.send(ServerMessage::TerminalResult { + delivery: Some(delivery.clone()), + }); + } + } + } + drop(conns); - // Auto-report draft match result if this game belongs to a draft - report_draft_game_over(draft_state, connections, &game_code, winner).await; + report_draft_game_over(draft_state, connections, &game_code, winner).await; - let mut mgr = state.lock().await; - mgr.remove_game(&game_code); - delete_session_async(game_db, &game_code); + state.lock().await.remove_game(&game_code); + } + } } // GH #1507: multiplayer-safe "request takeback" — see @@ -5451,7 +5952,7 @@ async fn handle_client_message( // happens to persist, and a crash/restart in that window // resurrects the branch the table just agreed to undo. if approved_snapshot.is_some() { - persist_session_async(game_db, &game_code, session); + persist_full_session_async(game_db, session); } drop(mgr); @@ -5537,7 +6038,7 @@ async fn handle_client_message( // GH #1507: persist the rolled-back state immediately — see the // matching comment in the `RequestTakeback` arm above. if approved_snapshot.is_some() { - persist_session_async(game_db, &game_code, session); + persist_full_session_async(game_db, session); } drop(mgr); @@ -5865,7 +6366,7 @@ async fn handle_client_message( let slot_info = session.player_slot_info(); let current_players = session.current_player_count(); let max_players = session.player_count; - persist_session_async(game_db, &game_code, session); + persist_full_session_async(game_db, session); // Keep the token-to-game index consistent: this seat mutation // invalidated these tokens (kicked / replaced / removed seats), @@ -7400,9 +7901,14 @@ mod mode_gate_tests { ClientMessage::Reconnect { game_code: "X".into(), player_token: "t".into(), + full_key: server_core::FullSessionKey { + game_code: "X".into(), + generation: 1, + }, }, ClientMessage::AbandonGame, ClientMessage::Concede, + ClientMessage::ConcedeMatch, ClientMessage::Emote { emote: "GG".into() }, ClientMessage::SpectatorJoin { game_code: "X".into(), @@ -7506,6 +8012,7 @@ mod mode_gate_tests { }, ClientMessage::AbandonGame, ClientMessage::Concede, + ClientMessage::ConcedeMatch, ClientMessage::Ping { timestamp: 0 }, ClientMessage::CreateDraftWithSettings { display_name: "A".into(), @@ -7995,7 +8502,7 @@ mod issue_4548_deadlock_tests { }; let (tx, _rx) = mpsc::unbounded_channel::(); - let (game_code, _token, _count) = create_and_connect_multiplayer_session( + let (game_code, _token, _count, _full_key) = create_and_connect_multiplayer_session( &state, &connections, &game_db, @@ -8014,7 +8521,8 @@ mod issue_4548_deadlock_tests { host_tx: tx, }, ) - .await; + .await + .expect("test session must be created"); // Both state and connections locks must be free at this point. // A regression that holds either guard across the helper's return diff --git a/crates/phase-server/src/persistence.rs b/crates/phase-server/src/persistence.rs index d717c55c27..be46617add 100644 --- a/crates/phase-server/src/persistence.rs +++ b/crates/phase-server/src/persistence.rs @@ -2,7 +2,14 @@ use std::path::Path; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; -use rusqlite::{params, Connection}; +use engine::types::player::PlayerId; +use rusqlite::{params, Connection, OptionalExtension}; +use server_core::{ + CurrentTerminalDelivery, FullPersistDisposition, FullPersistSnapshot, FullSessionKey, + PersistedSession, TerminalBootstrapRequest, TerminalCredential, TerminalDeliveryId, + TerminalMatchDisplay, +}; +use sha2::{Digest, Sha256}; use tracing::{error, info}; /// How the game-session store bounds retention of `game_sessions` rows. @@ -33,7 +40,7 @@ pub struct GameDb { retention: SessionRetention, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct RatingDelta { pub player_key: String, pub game_code: String, @@ -44,19 +51,42 @@ pub struct RatingDelta { pub rating_delta: i32, } +/// Server-private terminal artifact supplied by the future Full finalizer. +/// The pre-terminal tokens are used only during preparation to derive stable +/// recipient credentials; neither raw token nor raw credential reaches SQLite. +#[derive(Debug, Clone)] +pub struct FullTerminalArtifact { + pub key: FullSessionKey, + pub terminal_revision: u64, + pub display: TerminalMatchDisplay, + pub recipients: Vec, +} + +#[derive(Debug, Clone)] +pub struct TerminalRecipient { + pub player_id: PlayerId, + pub pre_terminal_player_token: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PrepareFullTerminalDisposition { + Prepared, + AlreadyPrepared, +} + impl GameDb { + pub fn is_single_user(&self) -> bool { + self.retention == SessionRetention::SingleUser + } + /// Open (or create) the game database at the given path. /// Enables WAL mode and creates the schema if needed. pub fn open(path: &Path, retention: SessionRetention) -> rusqlite::Result { - let conn = Connection::open(path)?; + let mut conn = Connection::open(path)?; conn.execute_batch("PRAGMA journal_mode=WAL;")?; + migrate_game_sessions(&mut conn)?; conn.execute_batch( - "CREATE TABLE IF NOT EXISTS game_sessions ( - game_code TEXT PRIMARY KEY, - session_json TEXT NOT NULL, - updated_at INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS draft_sessions ( + "CREATE TABLE IF NOT EXISTS draft_sessions ( draft_code TEXT PRIMARY KEY, session_json TEXT NOT NULL, updated_at INTEGER NOT NULL @@ -93,37 +123,33 @@ impl GameDb { }) } - /// Persist a game session (upsert). - /// - /// Under [`SessionRetention::SingleUser`] this also prunes every other game - /// session in the same transaction, enforcing the "one active solo game" - /// invariant: starting a new game replaces the previous (now unreachable) - /// one instead of leaving it to accumulate, since single-user mode disables - /// the stale-age purge. - pub fn save_session(&self, game_code: &str, json: &str) -> rusqlite::Result<()> { + /// Legacy persistence path retained while Full runtime callers migrate to + /// generation-fenced snapshots. New lifecycle code must use + /// [`Self::save_full_session`] instead. + #[cfg(test)] + pub(crate) fn save_session(&self, game_code: &str, json: &str) -> rusqlite::Result<()> { let now = now_epoch(); - let mut conn = self.conn.lock().unwrap(); - let tx = conn.transaction()?; - if self.retention == SessionRetention::SingleUser { - tx.execute( - "DELETE FROM game_sessions WHERE game_code != ?1", - params![game_code], - )?; - } - tx.execute( - "INSERT INTO game_sessions (game_code, session_json, updated_at) - VALUES (?1, ?2, ?3) - ON CONFLICT(game_code) DO UPDATE SET session_json = ?2, updated_at = ?3", + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO game_sessions + (game_code, generation, mutation_revision, activation_epoch, retired, session_json, updated_at) + VALUES (?1, 0, 0, NULL, 0, ?2, ?3) + ON CONFLICT(game_code) DO UPDATE SET + session_json = ?2, updated_at = ?3 + WHERE game_sessions.generation = 0 AND game_sessions.retired = 0", params![game_code, json, now], )?; - tx.commit()?; Ok(()) } /// Load all persisted sessions. Returns (game_code, json) pairs. - pub fn load_all(&self) -> rusqlite::Result> { + #[cfg(test)] + pub(crate) fn load_all(&self) -> rusqlite::Result> { let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare("SELECT game_code, session_json FROM game_sessions")?; + let mut stmt = conn.prepare( + "SELECT game_code, session_json FROM game_sessions + WHERE retired = 0 AND session_json IS NOT NULL", + )?; let rows = stmt.query_map([], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) })?; @@ -137,8 +163,10 @@ impl GameDb { Ok(results) } - /// Delete a session by game code. - pub fn delete_session(&self, game_code: &str) -> rusqlite::Result<()> { + /// Legacy destructive removal retained until Full terminal finalization is + /// routed through retirement tombstones. + #[cfg(test)] + pub(crate) fn delete_session(&self, game_code: &str) -> rusqlite::Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "DELETE FROM game_sessions WHERE game_code = ?1", @@ -160,7 +188,7 @@ impl GameDb { let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; let mut deleted = tx.execute( - "DELETE FROM game_sessions WHERE updated_at < ?1", + "DELETE FROM game_sessions WHERE retired = 0 AND updated_at < ?1", params![cutoff], )?; deleted += tx.execute( @@ -175,6 +203,565 @@ impl GameDb { Ok(deleted) } + /// Allocates and durably binds a new Full key to `game_code` before any + /// caller can publish credentials for that session. The placeholder row is + /// intentionally snapshot-less; the first mutation save supplies its + /// authoritative state and revision. + pub fn create_full_session_key(&self, game_code: &str) -> rusqlite::Result { + let now = now_epoch(); + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + let existing_active = tx + .query_row( + "SELECT retired FROM game_sessions WHERE game_code = ?1", + params![game_code], + |row| row.get::<_, bool>(0), + ) + .optional()?; + if existing_active == Some(false) { + return Err(rusqlite::Error::InvalidQuery); + } + let previous = tx + .query_row( + "SELECT generation FROM full_generation_high_water WHERE game_code = ?1", + params![game_code], + |row| row.get::<_, u64>(0), + ) + .optional()? + .unwrap_or(0); + let generation = previous.saturating_add(1); + tx.execute( + "INSERT INTO full_generation_high_water (game_code, generation) + VALUES (?1, ?2) + ON CONFLICT(game_code) DO UPDATE SET generation = excluded.generation", + params![game_code, generation], + )?; + tx.execute( + "INSERT INTO game_sessions + (game_code, generation, mutation_revision, activation_epoch, retired, session_json, updated_at) + VALUES (?1, ?2, 0, NULL, 0, NULL, ?3) + ON CONFLICT(game_code) DO UPDATE SET + generation = excluded.generation, + mutation_revision = 0, + activation_epoch = NULL, + retired = 0, + session_json = NULL, + updated_at = excluded.updated_at + WHERE game_sessions.retired = 1", + params![game_code, generation, now], + )?; + tx.commit()?; + Ok(FullSessionKey { + game_code: game_code.to_string(), + generation, + }) + } + + /// Returns the exact live key for a game code, never reconstructing a + /// generation from the code string or from a client payload. + pub fn load_active_full_key( + &self, + game_code: &str, + ) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT generation FROM game_sessions WHERE game_code = ?1 AND retired = 0", + params![game_code], + |row| { + Ok(FullSessionKey { + game_code: game_code.to_string(), + generation: row.get(0)?, + }) + }, + ) + .optional() + } + + /// Saves a Full authoritative snapshot only when it is newer than the + /// retained lifetime. Equal revisions are intentionally no-ops. + pub fn save_full_session( + &self, + snapshot: &FullPersistSnapshot, + ) -> rusqlite::Result { + let json = serde_json::to_string(&snapshot.persisted) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; + let now = now_epoch(); + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + + if let Some(epoch) = snapshot.activation_epoch { + let active = tx + .query_row( + "SELECT game_code, generation, activation_epoch FROM full_active_session WHERE slot = 1", + [], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, u64>(1)?, + row.get::<_, u64>(2)?, + )) + }, + ) + .optional()?; + if active + != Some(( + snapshot.key.game_code.clone(), + snapshot.key.generation, + epoch, + )) + { + return Ok(FullPersistDisposition::NotCurrentActivation); + } + } + + let changed = tx.execute( + "INSERT INTO game_sessions + (game_code, generation, mutation_revision, activation_epoch, retired, session_json, updated_at) + VALUES (?1, ?2, ?3, ?4, 0, ?5, ?6) + ON CONFLICT(game_code) DO UPDATE SET + generation = excluded.generation, + mutation_revision = excluded.mutation_revision, + activation_epoch = excluded.activation_epoch, + retired = 0, + session_json = excluded.session_json, + updated_at = excluded.updated_at + WHERE excluded.generation > game_sessions.generation + OR (excluded.generation = game_sessions.generation + AND game_sessions.retired = 0 + AND (excluded.mutation_revision > game_sessions.mutation_revision + OR game_sessions.session_json IS NULL))", + params![ + snapshot.key.game_code, + snapshot.key.generation, + snapshot.mutation_revision, + snapshot.activation_epoch, + json, + now, + ], + )?; + if changed == 1 { + tx.execute( + "INSERT INTO full_generation_high_water (game_code, generation) + VALUES (?1, ?2) + ON CONFLICT(game_code) DO UPDATE SET generation = MAX(generation, excluded.generation)", + params![snapshot.key.game_code, snapshot.key.generation], + )?; + tx.commit()?; + Ok(FullPersistDisposition::Applied) + } else { + tx.commit()?; + Ok(FullPersistDisposition::SupersededOrRetired) + } + } + + /// Activates a newly-created Full session in the desktop singleton. The + /// pointer transition and retirement of the previous active row are one + /// SQLite transaction, so a delayed save from the prior activation cannot + /// become current again. + pub fn activate_single_user_session( + &self, + snapshot: &FullPersistSnapshot, + ) -> rusqlite::Result<(u64, FullPersistDisposition)> { + if self.retention != SessionRetention::SingleUser { + return Err(rusqlite::Error::InvalidQuery); + } + + let json = serde_json::to_string(&snapshot.persisted) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; + let now = now_epoch(); + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + let next_epoch = tx + .query_row( + "SELECT activation_epoch FROM full_active_session WHERE slot = 1", + [], + |row| row.get::<_, u64>(0), + ) + .optional()? + .unwrap_or(0) + .saturating_add(1); + + let current_generation = tx + .query_row( + "SELECT generation FROM game_sessions WHERE game_code = ?1", + params![snapshot.key.game_code], + |row| row.get::<_, u64>(0), + ) + .optional()?; + if current_generation.is_some_and(|generation| generation > snapshot.key.generation) { + return Ok((next_epoch, FullPersistDisposition::SupersededOrRetired)); + } + + tx.execute( + "UPDATE game_sessions + SET retired = 1, session_json = NULL, updated_at = ?1 + WHERE (game_code, generation) IN ( + SELECT game_code, generation FROM full_active_session WHERE slot = 1 + ) AND retired = 0", + params![now], + )?; + tx.execute( + "INSERT INTO game_sessions + (game_code, generation, mutation_revision, activation_epoch, retired, session_json, updated_at) + VALUES (?1, ?2, ?3, ?4, 0, ?5, ?6) + ON CONFLICT(game_code) DO UPDATE SET + generation = excluded.generation, + mutation_revision = excluded.mutation_revision, + activation_epoch = excluded.activation_epoch, + retired = 0, + session_json = excluded.session_json, + updated_at = excluded.updated_at + WHERE excluded.generation >= game_sessions.generation", + params![ + snapshot.key.game_code, + snapshot.key.generation, + snapshot.mutation_revision, + next_epoch, + json, + now, + ], + )?; + tx.execute( + "INSERT INTO full_active_session (slot, game_code, generation, activation_epoch) + VALUES (1, ?1, ?2, ?3) + ON CONFLICT(slot) DO UPDATE SET + game_code = excluded.game_code, + generation = excluded.generation, + activation_epoch = excluded.activation_epoch", + params![snapshot.key.game_code, snapshot.key.generation, next_epoch], + )?; + tx.execute( + "INSERT INTO full_generation_high_water (game_code, generation) + VALUES (?1, ?2) + ON CONFLICT(game_code) DO UPDATE SET generation = MAX(generation, excluded.generation)", + params![snapshot.key.game_code, snapshot.key.generation], + )?; + tx.commit()?; + Ok((next_epoch, FullPersistDisposition::Applied)) + } + + /// Retires a never-started Full session while retaining its generation + /// tombstone. A started game must use the terminal finalizer instead. + pub fn retire_unstarted_full_session( + &self, + key: &FullSessionKey, + activation_epoch: Option, + ) -> rusqlite::Result { + let now = now_epoch(); + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + + if let Some(epoch) = activation_epoch { + let active = tx + .query_row( + "SELECT game_code, generation, activation_epoch FROM full_active_session WHERE slot = 1", + [], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, u64>(1)?, + row.get::<_, u64>(2)?, + )) + }, + ) + .optional()?; + if active != Some((key.game_code.clone(), key.generation, epoch)) { + return Ok(FullPersistDisposition::NotCurrentActivation); + } + } + + let row = tx + .query_row( + "SELECT retired, session_json FROM game_sessions + WHERE game_code = ?1 AND generation = ?2", + params![key.game_code, key.generation], + |row| Ok((row.get::<_, bool>(0)?, row.get::<_, Option>(1)?)), + ) + .optional()?; + let Some((retired, json)) = row else { + return Ok(FullPersistDisposition::SupersededOrRetired); + }; + if retired { + return Ok(FullPersistDisposition::SupersededOrRetired); + } + if let Some(json) = json { + let persisted: PersistedSession = serde_json::from_str(&json).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Text, + Box::new(error), + ) + })?; + if persisted.game_started { + return Err(rusqlite::Error::InvalidQuery); + } + } + + tx.execute( + "UPDATE game_sessions + SET retired = 1, session_json = NULL, updated_at = ?1 + WHERE game_code = ?2 AND generation = ?3 AND retired = 0", + params![now, key.game_code, key.generation], + )?; + if activation_epoch.is_some() { + tx.execute("DELETE FROM full_active_session WHERE slot = 1", [])?; + } + tx.commit()?; + Ok(FullPersistDisposition::Applied) + } + + /// Loads only non-retired Full session rows. Terminal tombstones remain in + /// SQLite to fence stale writers but are never reconstructed at startup. + pub fn load_active_full_sessions(&self) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT game_code, generation, mutation_revision, activation_epoch, session_json + FROM game_sessions WHERE retired = 0 AND session_json IS NOT NULL", + )?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, u64>(1)?, + row.get::<_, u64>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, String>(4)?, + )) + })?; + let mut snapshots = Vec::new(); + for row in rows { + let (game_code, generation, mutation_revision, activation_epoch, json) = row?; + match serde_json::from_str(&json) { + Ok(persisted) => snapshots.push(FullPersistSnapshot { + key: FullSessionKey { + game_code, + generation, + }, + mutation_revision, + activation_epoch, + persisted, + }), + Err(error) => error!("Failed to deserialize Full session row: {error}"), + } + } + Ok(snapshots) + } + + /// Atomically records one immutable Full terminal artifact, creates one + /// delivery row per occupied seat, and retires the matching active row. + /// Replaying byte-identical artifact content is idempotent; any changed + /// replay is rejected rather than replacing a result already shown to a + /// recipient. + pub fn prepare_full_terminal( + &self, + artifact: &FullTerminalArtifact, + ) -> rusqlite::Result { + let display_json = serde_json::to_string(&artifact.display) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; + let digest = terminal_artifact_digest(artifact, &display_json); + let now = now_epoch(); + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + + if let Some(existing_digest) = tx + .query_row( + "SELECT artifact_digest FROM terminal_match_results + WHERE game_code = ?1 AND generation = ?2", + params![artifact.key.game_code, artifact.key.generation], + |row| row.get::<_, String>(0), + ) + .optional()? + { + if existing_digest == digest { + tx.commit()?; + return Ok(PrepareFullTerminalDisposition::AlreadyPrepared); + } + return Err(rusqlite::Error::InvalidQuery); + } + + let active = tx + .query_row( + "SELECT retired FROM game_sessions WHERE game_code = ?1 AND generation = ?2", + params![artifact.key.game_code, artifact.key.generation], + |row| row.get::<_, bool>(0), + ) + .optional()?; + if active != Some(false) || artifact.recipients.is_empty() { + return Err(rusqlite::Error::InvalidQuery); + } + + let mut seen_players = std::collections::HashSet::new(); + for recipient in &artifact.recipients { + if recipient.pre_terminal_player_token.is_empty() + || !seen_players.insert(recipient.player_id) + { + return Err(rusqlite::Error::InvalidQuery); + } + } + + tx.execute( + "INSERT INTO terminal_match_results + (game_code, generation, terminal_revision, artifact_digest, display_json, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + artifact.key.game_code, + artifact.key.generation, + artifact.terminal_revision, + digest, + display_json, + now, + ], + )?; + for recipient in &artifact.recipients { + let credential = terminal_credential( + &artifact.key, + artifact.terminal_revision, + recipient.player_id, + &recipient.pre_terminal_player_token, + ); + tx.execute( + "INSERT INTO terminal_match_delivery + (game_code, generation, player_id, terminal_revision, delivery_id, + pre_terminal_token_verifier, credential_verifier, acknowledged_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL)", + params![ + artifact.key.game_code, + artifact.key.generation, + recipient.player_id.0, + artifact.terminal_revision, + terminal_delivery_id( + &artifact.key, + artifact.terminal_revision, + recipient.player_id + ) + .0, + verifier(&recipient.pre_terminal_player_token), + verifier(&credential.0), + ], + )?; + } + tx.execute( + "UPDATE game_sessions + SET retired = 1, session_json = NULL, updated_at = ?1 + WHERE game_code = ?2 AND generation = ?3 AND retired = 0", + params![now, artifact.key.game_code, artifact.key.generation], + )?; + tx.execute( + "DELETE FROM full_active_session WHERE game_code = ?1 AND generation = ?2", + params![artifact.key.game_code, artifact.key.generation], + )?; + tx.commit()?; + Ok(PrepareFullTerminalDisposition::Prepared) + } + + /// Returns a recipient's currently prepared terminal delivery after + /// proving the retained pre-terminal token. This is used by the Full + /// finalizer before it sends a GameOver in Wave 3. + pub fn current_terminal_delivery_for_recipient( + &self, + key: &FullSessionKey, + player_id: PlayerId, + pre_terminal_player_token: &str, + ) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + current_terminal_delivery(&conn, key, player_id, pre_terminal_player_token) + } + + /// Terminal-only bootstrap. A repeated request id must belong to exactly + /// the same key and recipient; it returns the same deterministic delivery + /// tuple and never attaches a normal game session. + pub fn bootstrap_terminal_delivery( + &self, + request: &TerminalBootstrapRequest, + ) -> rusqlite::Result> { + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + let recipient = tx + .query_row( + "SELECT player_id FROM terminal_match_delivery + WHERE game_code = ?1 AND generation = ?2 + AND pre_terminal_token_verifier = ?3", + params![ + request.key.game_code, + request.key.generation, + verifier(&request.player_token), + ], + |row| row.get::<_, u8>(0).map(PlayerId), + ) + .optional()?; + let Some(player_id) = recipient else { + tx.commit()?; + return Ok(None); + }; + + if let Some(existing) = tx + .query_row( + "SELECT game_code, generation, player_id FROM terminal_bootstrap_requests + WHERE request_id = ?1", + params![request.request_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, u64>(1)?, + row.get::<_, u8>(2)?, + )) + }, + ) + .optional()? + { + if existing + != ( + request.key.game_code.clone(), + request.key.generation, + player_id.0, + ) + { + return Err(rusqlite::Error::InvalidQuery); + } + } else { + tx.execute( + "INSERT INTO terminal_bootstrap_requests + (request_id, game_code, generation, player_id, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + request.request_id, + request.key.game_code, + request.key.generation, + player_id.0, + now_epoch(), + ], + )?; + } + let delivery = + current_terminal_delivery(&tx, &request.key, player_id, &request.player_token)?; + tx.commit()?; + Ok(delivery) + } + + /// Looks up a terminal delivery by its recipient-only credential. + pub fn read_terminal_result( + &self, + credential: &TerminalCredential, + ) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + terminal_delivery_by_credential(&conn, credential) + } + + /// Idempotently marks a terminal delivery acknowledged. An invalid + /// delivery-id/credential pair has no effect and reports `false`. + pub fn ack_terminal_delivery( + &self, + delivery_id: &TerminalDeliveryId, + credential: &TerminalCredential, + ) -> rusqlite::Result { + let conn = self.conn.lock().unwrap(); + let changed = conn.execute( + "UPDATE terminal_match_delivery SET acknowledged_at = COALESCE(acknowledged_at, ?1) + WHERE delivery_id = ?2 AND credential_verifier = ?3", + params![now_epoch(), delivery_id.0, verifier(&credential.0)], + )?; + Ok(changed == 1) + } + // ── Draft session persistence ────────────────────────────────────────── /// Persist a draft session (upsert). @@ -282,13 +869,53 @@ impl GameDb { } } - pub fn save_ranked_result(&self, deltas: &[RatingDelta]) -> rusqlite::Result<()> { + /// Records one ranked result exactly once per game. A retry returns the + /// original receipt instead of applying a second rating change. + pub fn save_ranked_result_idempotent( + &self, + deltas: &[RatingDelta], + ) -> rusqlite::Result> { if deltas.is_empty() { - return Ok(()); + return Ok(Vec::new()); } let now = now_epoch(); let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; + let game_code = &deltas[0].game_code; + if deltas.iter().any(|delta| delta.game_code != *game_code) { + return Err(rusqlite::Error::InvalidQuery); + } + let mut existing_statement = tx.prepare( + "SELECT player_key, game_code, opponent_key, won, rating_before, rating_after, rating_delta + FROM ranked_match_history WHERE game_code = ?1 ORDER BY id", + )?; + let existing = existing_statement + .query_map(params![game_code], |row| { + Ok(RatingDelta { + player_key: row.get(0)?, + game_code: row.get(1)?, + opponent_key: row.get(2)?, + won: row.get::<_, i64>(3)? != 0, + rating_before: row.get(4)?, + rating_after: row.get(5)?, + rating_delta: row.get(6)?, + }) + })? + .collect::, _>>()?; + drop(existing_statement); + if !existing.is_empty() { + if existing.len() != deltas.len() + || existing.iter().zip(deltas).any(|(saved, requested)| { + saved.player_key != requested.player_key + || saved.opponent_key != requested.opponent_key + || saved.won != requested.won + }) + { + return Err(rusqlite::Error::InvalidQuery); + } + tx.commit()?; + return Ok(existing); + } for delta in deltas { tx.execute( "INSERT INTO player_ratings (player_key, rating, updated_at) @@ -313,10 +940,250 @@ impl GameDb { )?; } tx.commit()?; - Ok(()) + Ok(deltas.to_vec()) } } +fn migrate_game_sessions(conn: &mut Connection) -> rusqlite::Result<()> { + let game_sessions_exists = conn + .query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'game_sessions'", + [], + |_| Ok(()), + ) + .optional()? + .is_some(); + + if !game_sessions_exists { + return create_full_game_session_schema(conn); + } + + let mut statement = conn.prepare("PRAGMA table_info(game_sessions)")?; + let columns = statement + .query_map([], |row| row.get::<_, String>(1))? + .collect::, _>>()?; + drop(statement); + if columns.iter().any(|column| column == "generation") { + return create_full_game_session_schema(conn); + } + + let transaction = conn.transaction()?; + transaction.execute_batch( + "ALTER TABLE game_sessions RENAME TO game_sessions_legacy; + CREATE TABLE game_sessions ( + game_code TEXT PRIMARY KEY, + generation INTEGER NOT NULL DEFAULT 0, + mutation_revision INTEGER NOT NULL DEFAULT 0, + activation_epoch INTEGER, + retired INTEGER NOT NULL DEFAULT 0, + session_json TEXT, + updated_at INTEGER NOT NULL + ); + INSERT INTO game_sessions + (game_code, generation, mutation_revision, activation_epoch, retired, session_json, updated_at) + SELECT game_code, 0, 0, NULL, 0, session_json, updated_at + FROM game_sessions_legacy; + DROP TABLE game_sessions_legacy;", + )?; + transaction.commit()?; + create_full_game_session_schema(conn) +} + +fn create_full_game_session_schema(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS game_sessions ( + game_code TEXT PRIMARY KEY, + generation INTEGER NOT NULL DEFAULT 0, + mutation_revision INTEGER NOT NULL DEFAULT 0, + activation_epoch INTEGER, + retired INTEGER NOT NULL DEFAULT 0, + session_json TEXT, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS full_generation_high_water ( + game_code TEXT PRIMARY KEY, + generation INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS full_active_session ( + slot INTEGER PRIMARY KEY CHECK (slot = 1), + game_code TEXT NOT NULL, + generation INTEGER NOT NULL, + activation_epoch INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS terminal_match_results ( + game_code TEXT NOT NULL, + generation INTEGER NOT NULL, + terminal_revision INTEGER NOT NULL, + artifact_digest TEXT NOT NULL, + display_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (game_code, generation) + ); + CREATE TABLE IF NOT EXISTS terminal_match_delivery ( + game_code TEXT NOT NULL, + generation INTEGER NOT NULL, + player_id INTEGER NOT NULL, + terminal_revision INTEGER NOT NULL, + delivery_id TEXT NOT NULL UNIQUE, + pre_terminal_token_verifier TEXT NOT NULL, + credential_verifier TEXT NOT NULL, + acknowledged_at INTEGER, + PRIMARY KEY (game_code, generation, player_id) + ); + CREATE TABLE IF NOT EXISTS terminal_bootstrap_requests ( + request_id TEXT PRIMARY KEY, + game_code TEXT NOT NULL, + generation INTEGER NOT NULL, + player_id INTEGER NOT NULL, + created_at INTEGER NOT NULL + );", + ) +} + +fn current_terminal_delivery( + conn: &Connection, + key: &FullSessionKey, + player_id: PlayerId, + pre_terminal_player_token: &str, +) -> rusqlite::Result> { + let row = conn + .query_row( + "SELECT d.terminal_revision, d.delivery_id, r.display_json + FROM terminal_match_delivery d + JOIN terminal_match_results r + ON r.game_code = d.game_code AND r.generation = d.generation + WHERE d.game_code = ?1 AND d.generation = ?2 AND d.player_id = ?3 + AND d.pre_terminal_token_verifier = ?4", + params![ + key.game_code, + key.generation, + player_id.0, + verifier(pre_terminal_player_token), + ], + |row| { + Ok(( + row.get::<_, u64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .optional()?; + let Some((terminal_revision, delivery_id, display_json)) = row else { + return Ok(None); + }; + let display = serde_json::from_str(&display_json).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure(2, rusqlite::types::Type::Text, Box::new(error)) + })?; + Ok(Some(CurrentTerminalDelivery { + key: key.clone(), + terminal_revision, + delivery_id: TerminalDeliveryId(delivery_id), + credential: terminal_credential( + key, + terminal_revision, + player_id, + pre_terminal_player_token, + ), + display, + })) +} + +fn terminal_delivery_by_credential( + conn: &Connection, + credential: &TerminalCredential, +) -> rusqlite::Result> { + let row = conn + .query_row( + "SELECT d.game_code, d.generation, d.player_id, d.terminal_revision, + d.delivery_id, r.display_json + FROM terminal_match_delivery d + JOIN terminal_match_results r + ON r.game_code = d.game_code AND r.generation = d.generation + WHERE d.credential_verifier = ?1", + params![verifier(&credential.0)], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, u64>(1)?, + row.get::<_, u8>(2)?, + row.get::<_, u64>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + )) + }, + ) + .optional()?; + let Some((game_code, generation, _player_id, terminal_revision, delivery_id, display_json)) = + row + else { + return Ok(None); + }; + let display = serde_json::from_str(&display_json).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, Box::new(error)) + })?; + Ok(Some(CurrentTerminalDelivery { + key: FullSessionKey { + game_code, + generation, + }, + terminal_revision, + delivery_id: TerminalDeliveryId(delivery_id), + credential: credential.clone(), + display, + })) +} + +fn terminal_artifact_digest(artifact: &FullTerminalArtifact, display_json: &str) -> String { + let mut material = format!( + "{}:{}:{}:{}", + artifact.key.game_code, artifact.key.generation, artifact.terminal_revision, display_json + ); + let mut recipients = artifact + .recipients + .iter() + .map(|recipient| { + ( + recipient.player_id.0, + verifier(&recipient.pre_terminal_player_token), + ) + }) + .collect::>(); + recipients.sort_unstable(); + for (player_id, token_verifier) in recipients { + material.push_str(&format!(":{player_id}:{token_verifier}")); + } + verifier(&material) +} + +fn terminal_delivery_id( + key: &FullSessionKey, + terminal_revision: u64, + player_id: PlayerId, +) -> TerminalDeliveryId { + TerminalDeliveryId(verifier(&format!( + "terminal-delivery:{}:{}:{terminal_revision}:{}", + key.game_code, key.generation, player_id.0 + ))) +} + +fn terminal_credential( + key: &FullSessionKey, + terminal_revision: u64, + player_id: PlayerId, + pre_terminal_player_token: &str, +) -> TerminalCredential { + TerminalCredential(verifier(&format!( + "terminal-credential:{}:{}:{terminal_revision}:{}:{pre_terminal_player_token}", + key.game_code, key.generation, player_id.0 + ))) +} + +fn verifier(secret: &str) -> String { + let digest = Sha256::digest(secret.as_bytes()); + format!("{digest:x}") +} + fn now_epoch() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -327,6 +1194,7 @@ fn now_epoch() -> u64 { #[cfg(test)] mod tests { use super::*; + use engine::types::game_state::{GameState, PersistedGameState}; use tempfile::NamedTempFile; fn test_db() -> GameDb { @@ -334,6 +1202,38 @@ mod tests { GameDb::open(file.path(), SessionRetention::Multiplayer).unwrap() } + fn full_snapshot( + game_code: &str, + generation: u64, + mutation_revision: u64, + activation_epoch: Option, + game_started: bool, + ) -> FullPersistSnapshot { + FullPersistSnapshot { + key: FullSessionKey { + game_code: game_code.to_string(), + generation, + }, + mutation_revision, + activation_epoch, + persisted: PersistedSession { + game_code: game_code.to_string(), + state_revision: mutation_revision, + state: PersistedGameState::capture(GameState::new_two_player(7)), + player_tokens: vec!["player-0".to_string(), "player-1".to_string()], + display_names: vec!["P0".to_string(), "P1".to_string()], + timer_seconds: None, + player_count: 2, + ai_seats: vec![], + ai_difficulties: Default::default(), + game_started, + start_when_full: true, + ranked: false, + lobby_meta: None, + }, + } + } + #[test] fn save_and_load_roundtrip() { let db = test_db(); @@ -356,11 +1256,13 @@ mod tests { } #[test] - fn single_user_save_prunes_other_game_sessions() { + fn legacy_single_user_save_does_not_prune_other_game_sessions() { let file = NamedTempFile::new().unwrap(); let db = GameDb::open(file.path(), SessionRetention::SingleUser).unwrap(); - // First solo game. + // Legacy callers cannot safely implement the active-session transition: + // they must not delete an unrelated row that may be a retained Full + // tombstone. Wave 3 routes single-user creation through activation. db.save_session("GAME_A", "a").unwrap(); assert_eq!(db.load_all().unwrap().len(), 1); @@ -370,11 +1272,268 @@ mod tests { assert_eq!(all.len(), 1); assert_eq!(all[0].1, "a2"); - // Starting a new game replaces the previous (now orphaned) session. + // A second legacy save keeps both rows until lifecycle code adopts the + // atomic active pointer API below. db.save_session("GAME_B", "b").unwrap(); let all = db.load_all().unwrap(); - assert_eq!(all.len(), 1); - assert_eq!(all[0].0, "GAME_B"); + assert_eq!(all.len(), 2); + assert!(all.iter().any(|(code, _)| code == "GAME_A")); + assert!(all.iter().any(|(code, _)| code == "GAME_B")); + } + + #[test] + fn full_save_rejects_equal_and_stale_mutation_revisions() { + let db = test_db(); + let initial = full_snapshot("FENCE1", 1, 3, None, false); + assert_eq!( + db.save_full_session(&initial).unwrap(), + FullPersistDisposition::Applied + ); + assert_eq!( + db.save_full_session(&full_snapshot("FENCE1", 1, 3, None, false)) + .unwrap(), + FullPersistDisposition::SupersededOrRetired + ); + assert_eq!( + db.save_full_session(&full_snapshot("FENCE1", 1, 2, None, false)) + .unwrap(), + FullPersistDisposition::SupersededOrRetired + ); + assert_eq!( + db.load_active_full_sessions().unwrap()[0].mutation_revision, + 3 + ); + } + + #[test] + fn full_generation_fences_a_delayed_save_from_an_older_lifetime() { + let db = test_db(); + let first = db.create_full_session_key("REUSE1").unwrap(); + db.retire_unstarted_full_session(&first, None).unwrap(); + let second = db.create_full_session_key("REUSE1").unwrap(); + assert_eq!(first.generation, 1); + assert_eq!(second.generation, 2); + assert_eq!( + db.save_full_session(&full_snapshot("REUSE1", second.generation, 1, None, false)) + .unwrap(), + FullPersistDisposition::Applied + ); + assert_eq!( + db.save_full_session(&full_snapshot("REUSE1", first.generation, 99, None, false)) + .unwrap(), + FullPersistDisposition::SupersededOrRetired + ); + assert_eq!( + db.load_active_full_sessions().unwrap()[0].key.generation, + second.generation + ); + } + + #[test] + fn single_user_activation_retires_previous_row_and_fences_late_save() { + let file = NamedTempFile::new().unwrap(); + let db = GameDb::open(file.path(), SessionRetention::SingleUser).unwrap(); + let first = full_snapshot("SOLO_A", 1, 1, None, false); + let (first_epoch, first_result) = db.activate_single_user_session(&first).unwrap(); + assert_eq!(first_result, FullPersistDisposition::Applied); + + let second = full_snapshot("SOLO_B", 1, 1, None, false); + let (second_epoch, second_result) = db.activate_single_user_session(&second).unwrap(); + assert_eq!(second_result, FullPersistDisposition::Applied); + assert!(second_epoch > first_epoch); + + assert_eq!( + db.save_full_session(&full_snapshot("SOLO_A", 1, 2, Some(first_epoch), false)) + .unwrap(), + FullPersistDisposition::NotCurrentActivation + ); + let loaded = db.load_active_full_sessions().unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].key.game_code, "SOLO_B"); + assert_eq!(loaded[0].activation_epoch, Some(second_epoch)); + } + + #[test] + fn retiring_unstarted_full_session_keeps_tombstone_out_of_restore() { + let db = test_db(); + let snapshot = full_snapshot("RETIRE1", 1, 1, None, false); + db.save_full_session(&snapshot).unwrap(); + assert_eq!( + db.retire_unstarted_full_session(&snapshot.key, None) + .unwrap(), + FullPersistDisposition::Applied + ); + assert!(db.load_active_full_sessions().unwrap().is_empty()); + assert_eq!( + db.save_full_session(&full_snapshot("RETIRE1", 1, 2, None, false)) + .unwrap(), + FullPersistDisposition::SupersededOrRetired + ); + } + + #[test] + fn full_key_allocation_is_durable_and_never_derived_from_code() { + let db = test_db(); + let first = db.create_full_session_key("KEY001").unwrap(); + assert_eq!(first.generation, 1); + assert_eq!( + db.load_active_full_key("KEY001").unwrap(), + Some(first.clone()) + ); + assert_eq!( + db.save_full_session(&FullPersistSnapshot { + key: first.clone(), + mutation_revision: 0, + activation_epoch: None, + persisted: full_snapshot("KEY001", 1, 0, None, false).persisted, + }) + .unwrap(), + FullPersistDisposition::Applied + ); + db.retire_unstarted_full_session(&first, None).unwrap(); + let second = db.create_full_session_key("KEY001").unwrap(); + assert_eq!(second.generation, 2); + assert_eq!(db.load_active_full_key("KEY001").unwrap(), Some(second)); + } + + #[test] + fn prepared_terminal_bootstraps_reads_and_acknowledges_idempotently() { + let db = test_db(); + let snapshot = full_snapshot("TERM01", 1, 1, None, true); + db.save_full_session(&snapshot).unwrap(); + let artifact = FullTerminalArtifact { + key: snapshot.key.clone(), + terminal_revision: 2, + display: TerminalMatchDisplay { + winner: Some(PlayerId(1)), + reason: "Match conceded".to_string(), + ranked_result: None, + }, + recipients: vec![ + TerminalRecipient { + player_id: PlayerId(0), + pre_terminal_player_token: "player-0".to_string(), + }, + TerminalRecipient { + player_id: PlayerId(1), + pre_terminal_player_token: "player-1".to_string(), + }, + ], + }; + assert_eq!( + db.prepare_full_terminal(&artifact).unwrap(), + PrepareFullTerminalDisposition::Prepared + ); + assert_eq!( + db.prepare_full_terminal(&artifact).unwrap(), + PrepareFullTerminalDisposition::AlreadyPrepared + ); + + let request = TerminalBootstrapRequest { + key: snapshot.key.clone(), + player_token: "player-0".to_string(), + request_id: "lost-first-frame".to_string(), + }; + let first = db.bootstrap_terminal_delivery(&request).unwrap().unwrap(); + let retry = db.bootstrap_terminal_delivery(&request).unwrap().unwrap(); + assert_eq!(first.delivery_id, retry.delivery_id); + assert_eq!(first.credential, retry.credential); + assert_eq!(first.display.reason, "Match conceded"); + assert!(db.load_active_full_sessions().unwrap().is_empty()); + + let read = db.read_terminal_result(&first.credential).unwrap().unwrap(); + assert_eq!(read.delivery_id, first.delivery_id); + assert!(db + .ack_terminal_delivery(&first.delivery_id, &first.credential) + .unwrap()); + // A lost acknowledgement response retries safely against the same row. + assert!(db + .ack_terminal_delivery(&first.delivery_id, &first.credential) + .unwrap()); + } + + #[test] + fn terminal_bootstrap_rejects_wrong_token_and_changed_artifact() { + let db = test_db(); + let snapshot = full_snapshot("TERM02", 1, 1, None, true); + db.save_full_session(&snapshot).unwrap(); + let artifact = FullTerminalArtifact { + key: snapshot.key.clone(), + terminal_revision: 2, + display: TerminalMatchDisplay { + winner: Some(PlayerId(0)), + reason: "Game ended".to_string(), + ranked_result: None, + }, + recipients: vec![TerminalRecipient { + player_id: PlayerId(0), + pre_terminal_player_token: "player-0".to_string(), + }], + }; + db.prepare_full_terminal(&artifact).unwrap(); + assert!(db + .bootstrap_terminal_delivery(&TerminalBootstrapRequest { + key: snapshot.key.clone(), + player_token: "wrong-token".to_string(), + request_id: "wrong".to_string(), + }) + .unwrap() + .is_none()); + + let mut changed = artifact.clone(); + changed.display.reason = "Changed replay".to_string(); + assert!(matches!( + db.prepare_full_terminal(&changed), + Err(rusqlite::Error::InvalidQuery) + )); + } + + #[test] + fn ranked_result_retry_reuses_the_original_receipt() { + let db = test_db(); + let initial = vec![ + RatingDelta { + player_key: "alice".to_string(), + game_code: "RANK01".to_string(), + opponent_key: "bob".to_string(), + won: true, + rating_before: 1200, + rating_after: 1212, + rating_delta: 12, + }, + RatingDelta { + player_key: "bob".to_string(), + game_code: "RANK01".to_string(), + opponent_key: "alice".to_string(), + won: false, + rating_before: 1200, + rating_after: 1188, + rating_delta: -12, + }, + ]; + assert_eq!( + db.save_ranked_result_idempotent(&initial).unwrap()[0].rating_after, + 1212 + ); + + let retry = vec![ + RatingDelta { + rating_before: 1212, + rating_after: 1224, + rating_delta: 12, + ..initial[0].clone() + }, + RatingDelta { + rating_before: 1188, + rating_after: 1176, + rating_delta: -12, + ..initial[1].clone() + }, + ]; + let receipt = db.save_ranked_result_idempotent(&retry).unwrap(); + assert_eq!(receipt, initial); + assert_eq!(db.load_rating("alice").unwrap(), Some(1212)); + assert_eq!(db.load_rating("bob").unwrap(), Some(1188)); } #[test] diff --git a/crates/server-core/src/client_message_wire_guard.rs b/crates/server-core/src/client_message_wire_guard.rs index 3fe9c116f4..26f497c7e4 100644 --- a/crates/server-core/src/client_message_wire_guard.rs +++ b/crates/server-core/src/client_message_wire_guard.rs @@ -53,14 +53,48 @@ pub fn guard_client_message_before_dispatch( ClientMessage::Reconnect { game_code, player_token, - } => guard_game_reconnect(game_code, player_token), + full_key, + } => { + guard_game_reconnect(game_code, player_token)?; + if full_key.game_code != *game_code || full_key.generation == 0 { + return Err( + "reconnect full_key must match game_code and have a generation".to_string(), + ); + } + Ok(()) + } ClientMessage::SubscribeLobby | ClientMessage::UnsubscribeLobby | ClientMessage::Concede + | ClientMessage::ConcedeMatch | ClientMessage::AbandonGame | ClientMessage::RequestTakeback | ClientMessage::RespondTakeback { .. } | ClientMessage::CancelTakeback => Ok(()), + ClientMessage::BootstrapTerminalDelivery { request } => { + if request.key.game_code.is_empty() + || request.player_token.is_empty() + || request.request_id.is_empty() + { + return Err("terminal bootstrap fields must not be empty".to_string()); + } + Ok(()) + } + ClientMessage::ReadTerminalResult { credential } => { + if credential.0.is_empty() { + return Err("terminal credential must not be empty".to_string()); + } + Ok(()) + } + ClientMessage::AckTerminalDelivery { + delivery_id, + credential, + } => { + if delivery_id.0.is_empty() || credential.0.is_empty() { + return Err("terminal acknowledgement fields must not be empty".to_string()); + } + Ok(()) + } ClientMessage::CreateGameWithSettings { deck, display_name, @@ -239,6 +273,10 @@ pub fn guard_broker_projection_inbound(msg: &ClientMessage) -> Result<(), String | ClientMessage::Reconnect { .. } | ClientMessage::AbandonGame | ClientMessage::Concede + | ClientMessage::ConcedeMatch + | ClientMessage::BootstrapTerminalDelivery { .. } + | ClientMessage::ReadTerminalResult { .. } + | ClientMessage::AckTerminalDelivery { .. } | ClientMessage::Emote { .. } | ClientMessage::SpectatorJoin { .. } | ClientMessage::SeatMutate { .. } diff --git a/crates/server-core/src/lib.rs b/crates/server-core/src/lib.rs index d335f9a74c..83c2f1098b 100644 --- a/crates/server-core/src/lib.rs +++ b/crates/server-core/src/lib.rs @@ -56,14 +56,16 @@ pub use p2p_backup_guard::{ }; pub use persist::{restored_draft_lobby_register_request, PersistedLobbyMeta, PersistedSession}; pub use protocol::{ - AiSeatRequest, ClientMessage, DeckChoice, DeckData, LobbyGame, PlayerSlotInfo, SeatKind, - SeatMutation, SeatView, ServerMessage, + AiSeatRequest, ClientMessage, CurrentTerminalDelivery, DeckChoice, DeckData, LobbyGame, + PlayerSlotInfo, SeatKind, SeatMutation, SeatView, ServerMessage, TerminalBootstrapRequest, + TerminalCredential, TerminalDeliveryId, TerminalMatchDisplay, }; pub use reconnect::ReconnectManager; pub use seat_mutation_wire_guard::guard_seat_mutation; pub use session::{ acting_player, acting_players, generate_game_code, generate_player_token, is_acting, - BroadcastSnapshot, RevisionedActionResult, SessionManager, + BroadcastSnapshot, FullPersistDisposition, FullPersistSnapshot, FullRuntime, FullSessionKey, + RevisionedActionResult, SessionManager, }; pub use spectator_wire_guard::{ guard_draft_spectator_capacity, guard_game_spectator_capacity, guard_spectate_draft, diff --git a/crates/server-core/src/protocol.rs b/crates/server-core/src/protocol.rs index 4e9069a6f0..f8d96e3bf6 100644 --- a/crates/server-core/src/protocol.rs +++ b/crates/server-core/src/protocol.rs @@ -13,6 +13,8 @@ use engine::types::player::PlayerId; use phase_ai::config::AiDifficulty; use serde::{Deserialize, Serialize}; +use crate::session::FullSessionKey; + /// Full game wire protocol version. Kept numerically aligned with the lobby /// broker while state/action messages share the same WebSocket protocol enum. pub const PROTOCOL_VERSION: u32 = lobby_broker::PROTOCOL_VERSION; @@ -86,6 +88,48 @@ pub struct RankedPlayerResult { pub rating_delta: i32, } +/// Recipient-safe presentation of an immutable Full terminal result. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalMatchDisplay { + pub winner: Option, + pub reason: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ranked_result: Option>, +} + +/// Opaque identifier for one recipient's terminal delivery ledger row. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct TerminalDeliveryId(pub String); + +/// Opaque capability for reading and acknowledging a terminal result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct TerminalCredential(pub String); + +/// Immutable terminal access tuple issued only to the matching recipient. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CurrentTerminalDelivery { + pub key: FullSessionKey, + pub terminal_revision: u64, + pub delivery_id: TerminalDeliveryId, + pub credential: TerminalCredential, + pub display: TerminalMatchDisplay, +} + +/// Bootstrap proof held by a reconnecting player before the regular Full +/// session is attached. The request id makes retrying this terminal-only +/// exchange idempotent. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalBootstrapRequest { + pub key: FullSessionKey, + pub player_token: String, + pub request_id: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", content = "data")] pub enum ClientMessage { @@ -116,6 +160,9 @@ pub enum ClientMessage { Reconnect { game_code: String, player_token: String, + /// Server-issued Full session identity. Clients retain this exact key; + /// they must never reconstruct a generation from the game code. + full_key: FullSessionKey, }, /// Permanently removes a full-mode game. The server authorizes this from /// the host's authenticated session; it is used to clean up a host-local @@ -181,6 +228,24 @@ pub enum ClientMessage { release_reservation_token: Option, }, Concede, + /// Authenticated request to concede the entire current best-of-three + /// match. The requester, winner, and trusted cause are deliberately not + /// wire fields; the server binds them from the attached session. + ConcedeMatch, + /// Terminal-only recovery. This deliberately cannot attach a Full game + /// session or fall through to ordinary reconnect handling. + BootstrapTerminalDelivery { + request: TerminalBootstrapRequest, + }, + /// Reads an already-issued recipient delivery using its opaque capability. + ReadTerminalResult { + credential: TerminalCredential, + }, + /// Idempotently acknowledges an already-issued recipient delivery. + AckTerminalDelivery { + delivery_id: TerminalDeliveryId, + credential: TerminalCredential, + }, Emote { emote: String, }, @@ -286,6 +351,10 @@ pub enum ServerMessage { GameCreated { game_code: String, player_token: String, + /// Present only for Full authoritative sessions. Lobby-only brokers do + /// not create a Full runtime and therefore cannot issue this key. + #[serde(default, skip_serializing_if = "Option::is_none")] + full_key: Option, }, /// Confirms the authenticated server seat for one connection before a /// pregame room has started. A host-side P2P bridge binds this identity to @@ -295,6 +364,8 @@ pub enum ServerMessage { game_code: String, player_id: PlayerId, player_token: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + full_key: Option, }, GameStarted { /// Monotonic server-authored snapshot revision. Every viewer of this @@ -335,6 +406,10 @@ pub enum ServerMessage { /// Omitted (None) for hosts (who get it via GameCreated) and reconnects. #[serde(default, skip_serializing_if = "Option::is_none")] player_token: Option, + /// The exact Full identity associated with this state stream. It is + /// omitted only for wire-compatible non-Full producers. + #[serde(default, skip_serializing_if = "Option::is_none")] + full_key: Option, /// Engine events produced by `start_game` — currently the d20 /// first-player contest (`StartingPlayerContest`) event. Populated ONLY /// on the initial post-start broadcast; empty for late joiners and @@ -415,6 +490,22 @@ pub enum ServerMessage { #[serde(default, skip_serializing_if = "Option::is_none")] ranked_result: Option>, }, + /// Terminal-only bootstrap response. `None` means the exact keyed Full + /// session has no prepared terminal artifact, so the caller may attempt + /// its ordinary reconnect path on a separate socket. + TerminalBootstrapResult { + #[serde(default, skip_serializing_if = "Option::is_none")] + delivery: Option, + }, + /// Terminal-only capability read response. + TerminalResult { + #[serde(default, skip_serializing_if = "Option::is_none")] + delivery: Option, + }, + /// Terminal-only acknowledgement receipt. + TerminalDeliveryAcknowledged { + delivery_id: TerminalDeliveryId, + }, Error { message: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -739,6 +830,7 @@ mod tests { let msg = ServerMessage::GameCreated { game_code: "XYZ789".to_string(), player_token: "abc123def456".to_string(), + full_key: None, }; let json = serde_json::to_string(&msg).unwrap(); let parsed: ServerMessage = serde_json::from_str(&json).unwrap(); @@ -746,9 +838,11 @@ mod tests { ServerMessage::GameCreated { game_code, player_token, + full_key, } => { assert_eq!(game_code, "XYZ789"); assert_eq!(player_token, "abc123def456"); + assert!(full_key.is_none()); } _ => panic!("wrong variant"), } @@ -777,6 +871,62 @@ mod tests { } } + #[test] + fn terminal_bootstrap_request_roundtrips_with_exact_full_key() { + let msg = ClientMessage::BootstrapTerminalDelivery { + request: TerminalBootstrapRequest { + key: FullSessionKey { + game_code: "TERM01".to_string(), + generation: 4, + }, + player_token: "pre-terminal-token".to_string(), + request_id: "retry-1".to_string(), + }, + }; + let json = serde_json::to_string(&msg).unwrap(); + let parsed: ClientMessage = serde_json::from_str(&json).unwrap(); + match parsed { + ClientMessage::BootstrapTerminalDelivery { request } => { + assert_eq!(request.key.game_code, "TERM01"); + assert_eq!(request.key.generation, 4); + assert_eq!(request.request_id, "retry-1"); + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn terminal_delivery_response_roundtrips() { + let msg = ServerMessage::TerminalBootstrapResult { + delivery: Some(CurrentTerminalDelivery { + key: FullSessionKey { + game_code: "TERM01".to_string(), + generation: 4, + }, + terminal_revision: 9, + delivery_id: TerminalDeliveryId("delivery-0".to_string()), + credential: TerminalCredential("credential".to_string()), + display: TerminalMatchDisplay { + winner: Some(PlayerId(1)), + reason: "Match conceded".to_string(), + ranked_result: None, + }, + }), + }; + let json = serde_json::to_string(&msg).unwrap(); + let parsed: ServerMessage = serde_json::from_str(&json).unwrap(); + match parsed { + ServerMessage::TerminalBootstrapResult { + delivery: Some(delivery), + } => { + assert_eq!(delivery.key.generation, 4); + assert_eq!(delivery.terminal_revision, 9); + assert_eq!(delivery.delivery_id.0, "delivery-0"); + } + _ => panic!("wrong variant"), + } + } + #[test] fn server_message_tagged_json_format() { let msg = ServerMessage::OpponentReconnected { player: None }; @@ -938,6 +1088,14 @@ mod tests { assert!(matches!(parsed, ClientMessage::Concede)); } + #[test] + fn client_message_concede_match_roundtrips_without_authority_payload() { + let json = serde_json::to_string(&ClientMessage::ConcedeMatch).unwrap(); + assert_eq!(json, r#"{"type":"ConcedeMatch"}"#); + let parsed: ClientMessage = serde_json::from_str(&json).unwrap(); + assert!(matches!(parsed, ClientMessage::ConcedeMatch)); + } + #[test] fn client_message_abandon_game_roundtrips() { let json = serde_json::to_string(&ClientMessage::AbandonGame).unwrap(); @@ -951,6 +1109,7 @@ mod tests { game_code: "ABC123".to_string(), player_id: PlayerId(1), player_token: "token".to_string(), + full_key: None, }; let json = serde_json::to_string(&msg).unwrap(); let parsed: ServerMessage = serde_json::from_str(&json).unwrap(); @@ -959,10 +1118,12 @@ mod tests { game_code, player_id, player_token, + full_key, } => { assert_eq!(game_code, "ABC123"); assert_eq!(player_id, PlayerId(1)); assert_eq!(player_token, "token"); + assert!(full_key.is_none()); } other => panic!("wrong variant: {other:?}"), } @@ -1032,6 +1193,7 @@ mod tests { derived: Default::default(), viewer_interaction: viewer_interaction.clone(), player_token: None, + full_key: None, events: vec![], }; let json = serde_json::to_string(&msg).unwrap(); @@ -1085,6 +1247,7 @@ mod tests { PlayerId(1), ), player_token: None, + full_key: None, events: vec![], }; let json = serde_json::to_string(&msg).unwrap(); diff --git a/crates/server-core/src/session.rs b/crates/server-core/src/session.rs index d4c70278b8..8b33977ad0 100644 --- a/crates/server-core/src/session.rs +++ b/crates/server-core/src/session.rs @@ -9,6 +9,7 @@ use engine::game::deck_loading::{DeckPayload, PlayerDeckPayload}; use engine::game::engine::{apply, start_game}; use engine::game::finalize_public_state; use engine::game::interaction::bind_interaction_authority; +use engine::game::match_flow::apply_trusted_match_forfeit; use engine::game::preview::preview_auto_payment_sources; use engine::game::{load_and_hydrate_decks, rehydrate_game_from_card_db}; use engine::types::actions::GameAction; @@ -20,11 +21,13 @@ use engine::types::interaction::InteractionSessionId; use engine::types::log::GameLogEntry; use engine::types::mana::ManaCost; use engine::types::match_config::MatchConfig; +use engine::types::match_config::MatchForfeitCause; use engine::types::player::PlayerId; use phase_ai::config::{AiConfig, AiDifficulty, Platform}; use phase_ai::session::AiSession; use rand::{Rng, SeedableRng}; use seat_reducer::types::{seat_team_info, DeckChoice, SeatDelta, SeatKind, SeatState}; +use serde::{Deserialize, Serialize}; use tracing::{debug, info, warn}; use crate::filter::filter_state_for_player; @@ -85,6 +88,47 @@ pub type ActionResult = ( /// pairing intact when it fans a snapshot out to multiple viewers. pub type RevisionedActionResult = (u64, ActionResult); +/// Stable identity for one lifetime of a Full authoritative session. +/// +/// A game code can be retired and later reused. Persisting the generation +/// alongside it prevents delayed saves from an earlier lifetime from +/// overwriting the newer session. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct FullSessionKey { + pub game_code: String, + pub generation: u64, +} + +/// A durable Full-server snapshot fenced by both session generation and the +/// session-local mutation revision. `activation_epoch` is populated only by a +/// single-user activation; shared-server sessions intentionally have none. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FullPersistSnapshot { + pub key: FullSessionKey, + pub mutation_revision: u64, + pub activation_epoch: Option, + pub persisted: PersistedSession, +} + +/// Runtime-only persistence authority for one lifetime of a Full session. +/// +/// The key fences delayed writes from a recycled game code. The optional +/// activation epoch is the single-user singleton fence and is stamped only by +/// the persistence owner; neither value is trusted from a game snapshot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FullRuntime { + pub key: FullSessionKey, + pub activation_epoch: Option, +} + +/// Outcome of a generation-fenced Full-server persistence operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum FullPersistDisposition { + Applied, + SupersededOrRetired, + NotCurrentActivation, +} + /// Broadcast-ready fields for a state snapshot taken outside the normal /// `handle_action` flow (e.g. an approved takeback rollback): the raw state, /// legal actions, auto-pass flag, spell costs, and per-object action grouping. @@ -160,6 +204,10 @@ pub enum HostingMode { pub struct GameSession { pub game_code: String, + /// Server-issued durable identity for a Full session lifetime. This is + /// stamped by phase-server after persistence binds the newly-created + /// session; it is not trusted from the serialized game blob. + pub full_runtime: Option, /// Monotonic server-authored revision of the current authoritative state. /// Read-only snapshots reuse this value; mutators advance it before their /// per-viewer views are captured for transport. @@ -242,6 +290,19 @@ impl GameSession { self.state_revision } + /// Builds the only persistence payload for an active Full runtime. + /// Snapshot generation and revision therefore cannot be supplied by a + /// transport caller independently of the authoritative session. + pub fn full_persist_snapshot(&self) -> Option { + let runtime = self.full_runtime.as_ref()?; + Some(FullPersistSnapshot { + key: runtime.key.clone(), + mutation_revision: self.state_revision, + activation_epoch: runtime.activation_epoch, + persisted: self.to_persisted(), + }) + } + /// Returns the player index for the given token, if valid. pub fn player_for_token(&self, token: &str) -> Option { self.player_tokens @@ -850,6 +911,7 @@ impl GameSession { GameSession { game_code: ps.game_code, + full_runtime: None, state_revision: ps.state_revision, state, player_tokens: ps.player_tokens, @@ -987,6 +1049,7 @@ impl SessionManager { let session = GameSession { game_code: game_code.clone(), + full_runtime: None, state_revision: 0, state, player_tokens, @@ -1337,6 +1400,49 @@ impl SessionManager { )) } + /// Applies the payload-free match-concede intent after binding its + /// requester to an authenticated player token. The closed cause is chosen + /// here, never by the wire payload or a game action. + pub fn handle_match_concede( + &mut self, + game_code: &str, + player_token: &str, + ) -> Result { + let session = self + .sessions + .get_mut(game_code) + .ok_or_else(|| format!("Game not found: {game_code}"))?; + let player = session + .player_for_token(player_token) + .ok_or_else(|| "Invalid player token".to_string())?; + if session.pending_takeback.is_some() { + return Err( + "A takeback request is pending — resolve it before conceding the match".to_string(), + ); + } + + let events = apply_trusted_match_forfeit( + &mut session.state, + player, + MatchForfeitCause::MatchConcede, + )?; + let (legal_actions, spell_costs, by_object) = engine_legal_actions_full(&session.state); + let auto_pass = auto_pass_recommended(&session.state, &legal_actions); + let revision = session.advance_state_revision(); + Ok(( + revision, + ( + session.state.clone(), + events, + legal_actions, + Vec::new(), + auto_pass, + spell_costs, + by_object, + ), + )) + } + /// Mark a player as disconnected. pub fn handle_disconnect(&mut self, game_code: &str, player: PlayerId) { if let Some(session) = self.sessions.get_mut(game_code) { @@ -1554,6 +1660,26 @@ mod tests { assert_eq!(token.len(), 32); } + #[test] + fn full_persist_snapshot_roundtrips_exact_generation_and_revision() { + let mut manager = SessionManager::new(); + let (game_code, _) = manager.create_game(make_deck()); + let snapshot = FullPersistSnapshot { + key: FullSessionKey { + game_code, + generation: 7, + }, + mutation_revision: 11, + activation_epoch: Some(3), + persisted: manager.sessions.values().next().unwrap().to_persisted(), + }; + let json = serde_json::to_string(&snapshot).unwrap(); + let restored: FullPersistSnapshot = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.key, snapshot.key); + assert_eq!(restored.mutation_revision, 11); + assert_eq!(restored.activation_epoch, Some(3)); + } + #[test] fn create_then_join_works() { let mut mgr = SessionManager::new(); @@ -3451,6 +3577,7 @@ mod tests { let mut session = GameSession { game_code: "TEST01".to_string(), + full_runtime: None, state_revision: 0, state, player_tokens: vec!["host_token".to_string(), String::new()], diff --git a/crates/server-core/tests/lobby_wire_contract.rs b/crates/server-core/tests/lobby_wire_contract.rs index 893f9a42d1..a9a7032ed8 100644 --- a/crates/server-core/tests/lobby_wire_contract.rs +++ b/crates/server-core/tests/lobby_wire_contract.rs @@ -137,6 +137,7 @@ fn lobby_server_messages_byte_identical_to_canonical() { let sc_gc = sc::ServerMessage::GameCreated { game_code: "GAME01".into(), player_token: "tok".into(), + full_key: None, }; assert_eq!( serde_json::to_string(&lb_gc).unwrap(), From 4eb12b7fbe296b32c20f53cb3858b76f9e7960ce Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:28:51 -0700 Subject: [PATCH 42/63] fix(engine): preserve exile land permission authority (#6819) * fix(engine): preserve exile land permission authority * test: correct exile land permission coverage * fix: remove unrelated trigger migration writes --------- Co-authored-by: matthewevans --- crates/engine/src/game/casting.rs | 71 ++-- crates/engine/src/game/casting_tests.rs | 421 +++++++++++++++++++++++- crates/engine/src/game/engine.rs | 206 ++++++------ 3 files changed, 585 insertions(+), 113 deletions(-) diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 7b567be050..de190a5a4a 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -4205,6 +4205,29 @@ pub fn graveyard_lands_playable_by_permission( results } +/// The elected authority for a land play from exile. The object-attached and +/// static forms use different once-per-turn ledgers, so callers must retain +/// this distinction through the zone move and completion seam. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ExileLandPlayAuthorization { + ObjectAttached { + source: ObjectId, + frequency: CastFrequency, + }, + Static { + source: ObjectId, + frequency: CastFrequency, + }, +} + +impl ExileLandPlayAuthorization { + pub(super) fn source(self) -> ObjectId { + match self { + Self::ObjectAttached { source, .. } | Self::Static { source, .. } => source, + } + } +} + /// CR 305.1 + CR 113.6b + CR 406.6: Find the `StaticMode::ExileCastPermission` /// source (if any) authorizing `player` to play the exiled land `land_id`. Only /// `play_mode: Play` sources admit lands (CR 305.1: lands are played, not cast); @@ -4214,7 +4237,7 @@ fn exile_land_playable_by_static_permission( state: &GameState, player: PlayerId, land_id: ObjectId, -) -> Option { +) -> Option<(ObjectId, CastFrequency)> { if state.cards_exiled_with_source_this_turn.is_empty() && state.exile_links.is_empty() { return None; } @@ -4241,10 +4264,35 @@ fn exile_land_playable_by_static_permission( if !super::filter::matches_target_filter(state, land_id, source.filter, &ctx) { return None; } - Some(source.source_id) + Some((source.source_id, source.frequency)) }) } +/// CR 305.1 + CR 601.2a + CR 113.6b: Elect the exact exile-play authority for +/// `land_id` before the land changes zones. Object-attached permissions take +/// precedence over a static fallback, matching the public legal-actions surface. +pub(super) fn exile_land_play_authorization( + state: &GameState, + player: PlayerId, + land_id: ObjectId, +) -> Option { + let obj = state.objects.get(&land_id)?; + if !obj + .card_types + .core_types + .contains(&crate::types::card_type::CoreType::Land) + { + return None; + } + if let Some((source, frequency)) = + play_from_exile_permission_source(state, obj, player, state.turn_number) + { + return Some(ExileLandPlayAuthorization::ObjectAttached { source, frequency }); + } + let (source, frequency) = exile_land_playable_by_static_permission(state, player, land_id)?; + Some(ExileLandPlayAuthorization::Static { source, frequency }) +} + /// CR 305.1 + CR 601.2a + CR 113.6b: Find exiled lands `player` may play, via /// either the object-tagged `CastingPermission::PlayFromExile` (impulse draw) or /// a battlefield `StaticMode::ExileCastPermission { play_mode: Play }` static @@ -4258,23 +4306,8 @@ pub fn exile_lands_playable_by_permission( .exile .iter() .filter_map(|&obj_id| { - let obj = state.objects.get(&obj_id)?; - if !obj - .card_types - .core_types - .contains(&crate::types::card_type::CoreType::Land) - { - return None; - } - // Object-tagged impulse permission first; fall back to the - // battlefield-static exile-play permission. - if let Some((source, _)) = - play_from_exile_permission_source(state, obj, player, state.turn_number) - { - return Some((obj_id, source)); - } - let source = exile_land_playable_by_static_permission(state, player, obj_id)?; - Some((obj_id, source)) + exile_land_play_authorization(state, player, obj_id) + .map(|authorization| (obj_id, authorization.source())) }) .collect() } diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index b7b7388495..e8731a8dce 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -42506,7 +42506,7 @@ fn add_impulse_exiled_card( frequency: crate::types::statics::CastFrequency::Unlimited, source_id: Some(source_id), invalidation: None, - exiled_by_ability_controller: Some(player), + exiled_by_ability_controller: None, mana_spend_permission: None, card_filter: Some(TargetFilter::Typed(TypedFilter { type_filters: vec![TypeFilter::AnyOf(vec![ @@ -42773,6 +42773,73 @@ fn add_exiled_land(state: &mut GameState, player: PlayerId, name: &str) -> Objec object_id } +fn grant_object_exile_land_play_permission( + state: &mut GameState, + land: ObjectId, + player: PlayerId, + source: ObjectId, + frequency: CastFrequency, +) { + state + .objects + .get_mut(&land) + .unwrap() + .casting_permissions + .push(CastingPermission::PlayFromExile { + duration: crate::types::ability::Duration::UntilEndOfNextTurnOf { + player: crate::types::ability::PlayerScope::Controller, + }, + granted_to: player, + frequency, + source_id: Some(source), + invalidation: None, + exiled_by_ability_controller: None, + mana_spend_permission: None, + card_filter: None, + single_use_group: None, + single_use: false, + cast_cost_raise: None, + land_enter_tapped: crate::types::zones::EtbTapState::Unspecified, + }); +} + +fn add_extra_land_drop_source(state: &mut GameState, player: PlayerId) -> ObjectId { + let source = create_object( + state, + CardId(state.next_object_id), + player, + "Exploration Stand-In".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&source) + .unwrap() + .static_definitions + .push(StaticDefinition::new(StaticMode::MayPlayAdditionalLand)); + source +} + +fn add_exiled_land_with_oracle( + state: &mut GameState, + player: PlayerId, + name: &str, + oracle_text: &str, +) -> ObjectId { + let land = add_exiled_land(state, player, name); + let parsed = crate::parser::oracle::parse_oracle_text( + oracle_text, + name, + &[], + &["Land".to_string()], + &[], + ); + let obj = state.objects.get_mut(&land).unwrap(); + obj.replacement_definitions = parsed.replacements.clone().into(); + obj.base_replacement_definitions = Arc::new(parsed.replacements); + land +} + /// Link `exiled_id` to `source_id` in the persistent `exile_links` pool /// (CR 406.6 / CR 607.2a) — the lifetime tracker the `Persistent` pool /// scope reads, independent of the per-turn rolling list. @@ -43033,6 +43100,358 @@ fn persistent_exile_play_permission_plays_linked_land_through_action() { assert_eq!(obj.played_from_zone, Some(Zone::Exile)); } +/// CR 305.2 + CR 400.7i: Unlimited object-attached permissions authorize each +/// exiled land independently. An additional land drop makes both public +/// `PlayLand` actions legal, and neither may consume a once-per-turn ledger. +#[test] +fn unlimited_object_exile_land_permissions_do_not_consume_a_ledger_slot() { + let mut state = setup_game_at_main_phase(); + let player = PlayerId(0); + add_extra_land_drop_source(&mut state, player); + let source = ObjectId(99_001); + let first = add_exiled_land(&mut state, player, "First Exiled Forest"); + let second = add_exiled_land(&mut state, player, "Second Exiled Forest"); + grant_object_exile_land_play_permission( + &mut state, + first, + player, + source, + CastFrequency::Unlimited, + ); + grant_object_exile_land_play_permission( + &mut state, + second, + player, + source, + CastFrequency::Unlimited, + ); + + for land in [first, second] { + let card_id = state.objects[&land].card_id; + apply_as_current( + &mut state, + GameAction::PlayLand { + object_id: land, + card_id, + }, + ) + .expect("an unlimited object-attached exile permission must remain usable"); + } + + assert!(state.exile_play_permissions_used.is_empty()); + assert!(state.exile_cast_permissions_used.is_empty()); +} + +/// CR 305.2 + CR 400.7i: Object-attached `OncePerTurn` permissions sharing a +/// source use the object-play ledger. The second public action is rejected +/// after the first consumes that exact source's slot. +#[test] +fn bounded_object_exile_land_permissions_share_the_exile_play_ledger() { + let mut state = setup_game_at_main_phase(); + let player = PlayerId(0); + add_extra_land_drop_source(&mut state, player); + let source = ObjectId(99_002); + let first = add_exiled_land(&mut state, player, "First Bounded Exiled Forest"); + let second = add_exiled_land(&mut state, player, "Second Bounded Exiled Forest"); + for land in [first, second] { + grant_object_exile_land_play_permission( + &mut state, + land, + player, + source, + CastFrequency::OncePerTurn, + ); + } + + let first_card_id = state.objects[&first].card_id; + apply_as_current( + &mut state, + GameAction::PlayLand { + object_id: first, + card_id: first_card_id, + }, + ) + .expect("the first bounded object-attached land play must succeed"); + assert!(state.exile_play_permissions_used.contains(&source)); + assert!(!state.exile_cast_permissions_used.contains(&source)); + + let second_card_id = state.objects[&second].card_id; + assert!( + apply_as_current( + &mut state, + GameAction::PlayLand { + object_id: second, + card_id: second_card_id, + }, + ) + .is_err(), + "the shared once-per-turn object permission must reject a second land" + ); +} + +/// CR 305.1 + CR 601.2a + CR 113.6b: A bounded static Play permission uses +/// its `ExileCast` ledger for both land plays and later spell casts from its +/// linked exile pool. +#[test] +fn bounded_static_exile_land_play_locks_the_linked_spell_permission() { + let mut state = setup_game_at_main_phase(); + let player = PlayerId(0); + let source = add_exile_cast_permission_source_with( + &mut state, + player, + "Bounded Matrix of Time", + TargetFilter::Any, + CastFrequency::OncePerTurn, + CardPlayMode::Play, + ExileCastCost::PayNormalCost, + ExileCardPool::Persistent, + ExileCastTiming::YourTurnOnly, + ); + let land = add_exiled_land(&mut state, player, "Bounded Exiled Island"); + let spell = add_exiled_card(&mut state, player, "Bounded Exiled Bear"); + link_exiled_to_source(&mut state, land, source); + link_exiled_to_source(&mut state, spell, source); + + let card_id = state.objects[&land].card_id; + apply_as_current( + &mut state, + GameAction::PlayLand { + object_id: land, + card_id, + }, + ) + .expect("the static permission must authorize its first linked land play"); + + assert!(state.exile_cast_permissions_used.contains(&source)); + assert!(!state.exile_play_permissions_used.contains(&source)); + assert!( + !spell_objects_available_to_cast(&state, player).contains(&spell), + "a land play must consume the same bounded static authority as a spell cast" + ); +} + +/// CR 305.1 + CR 614.12a: A bounded static permission is consumed when the +/// land-entry path drains an as-enters choice after delivery (Thriving Grove), +/// before returning its `NamedChoice` prompt. +#[test] +fn bounded_static_exile_land_play_records_exile_cast_in_post_replacement_drain() { + let mut state = setup_game_at_main_phase(); + let player = PlayerId(0); + let source = add_exile_cast_permission_source_with( + &mut state, + player, + "Bounded Thriving Permission", + TargetFilter::Any, + CastFrequency::OncePerTurn, + CardPlayMode::Play, + ExileCastCost::PayNormalCost, + ExileCardPool::Persistent, + ExileCastTiming::YourTurnOnly, + ); + let land = add_exiled_land_with_oracle( + &mut state, + player, + "Thriving Grove", + "This land enters tapped. As it enters, choose a color other than green.", + ); + link_exiled_to_source(&mut state, land, source); + + let card_id = state.objects[&land].card_id; + let result = apply_as_current( + &mut state, + GameAction::PlayLand { + object_id: land, + card_id, + }, + ) + .expect("the Thriving Grove replacement drain must leave a named choice"); + + assert!(matches!(result.waiting_for, WaitingFor::NamedChoice { .. })); + assert_eq!(state.exile_cast_permissions_used.len(), 1); + assert!(state.exile_cast_permissions_used.contains(&source)); + assert!(state.exile_play_permissions_used.is_empty()); +} + +/// CR 305.1 + CR 614.1c: A bounded static permission is consumed before the +/// immediate shock-land replacement choice is returned. This specifically +/// covers `ReplacementResult::NeedsChoice`, not the nested delivery pause. +#[test] +fn bounded_static_exile_land_play_records_exile_cast_before_replacement_choice() { + let mut state = setup_game_at_main_phase(); + let player = PlayerId(0); + let source = add_exile_cast_permission_source_with( + &mut state, + player, + "Bounded Shockland Permission", + TargetFilter::Any, + CastFrequency::OncePerTurn, + CardPlayMode::Play, + ExileCastCost::PayNormalCost, + ExileCardPool::Persistent, + ExileCastTiming::YourTurnOnly, + ); + let land = add_exiled_land_with_oracle( + &mut state, + player, + "Watery Grave", + "As this land enters, you may pay 2 life. If you don't, it enters tapped.", + ); + link_exiled_to_source(&mut state, land, source); + + let card_id = state.objects[&land].card_id; + let result = apply_as_current( + &mut state, + GameAction::PlayLand { + object_id: land, + card_id, + }, + ) + .expect("the shock-land replacement must return a replacement choice"); + + assert!(matches!( + result.waiting_for, + WaitingFor::ReplacementChoice { .. } + )); + assert_eq!(state.exile_cast_permissions_used.len(), 1); + assert!(state.exile_cast_permissions_used.contains(&source)); + assert!(state.exile_play_permissions_used.is_empty()); +} + +/// CR 305.1 + CR 614.1a + CR 614.1c: A land that has already entered may +/// pause in the delivery tail while choosing an order for counter replacements. +/// The bounded static exile authority is consumed at that committed-play seam, +/// not deferred to the tail continuation that does not retain it. +#[test] +fn bounded_static_exile_land_play_records_exile_cast_before_delivery_tail_choice() { + let mut state = setup_game_at_main_phase(); + let player = PlayerId(0); + let permission_source = add_exile_cast_permission_source_with( + &mut state, + player, + "Bounded Delivery-Pause Permission", + TargetFilter::Any, + CastFrequency::OncePerTurn, + CardPlayMode::Play, + ExileCastCost::PayNormalCost, + ExileCardPool::Persistent, + ExileCastTiming::YourTurnOnly, + ); + let counter_source_card_id = CardId(state.next_object_id); + let counter_source = create_object( + &mut state, + counter_source_card_id, + player, + "Entry Counter Source".to_string(), + Zone::Battlefield, + ); + let entry_counter = StaticDefinition::new(StaticMode::EntersWithAdditionalCounters { + counter_type: CounterType::Plus1Plus1, + count: 1, + }) + .affected(TargetFilter::Typed( + TypedFilter::permanent() + .controller(ControllerRef::You) + .properties(vec![FilterProp::Another]), + )); + { + let source = state.objects.get_mut(&counter_source).unwrap(); + source.static_definitions.push(entry_counter.clone()); + Arc::make_mut(&mut source.base_static_definitions).push(entry_counter); + } + for (name, quantity_modification) in [ + ( + "Counter Doubler", + crate::types::ability::QuantityModification::DOUBLE, + ), + ( + "Counter Incrementer", + crate::types::ability::QuantityModification::Plus { value: 1 }, + ), + ] { + let source_card_id = CardId(state.next_object_id); + let source = create_object( + &mut state, + source_card_id, + player, + name.to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&source) + .unwrap() + .replacement_definitions + .push( + ReplacementDefinition::new(ReplacementEvent::AddCounter) + .quantity_modification(quantity_modification), + ); + } + let land = add_exiled_land(&mut state, player, "Delivery-Paused Exiled Land"); + link_exiled_to_source(&mut state, land, permission_source); + + let card_id = state.objects[&land].card_id; + let result = apply_as_current( + &mut state, + GameAction::PlayLand { + object_id: land, + card_id, + }, + ) + .expect("counter replacement ordering must pause after the land enters"); + + assert!(matches!( + result.waiting_for, + WaitingFor::ReplacementChoice { .. } + )); + assert_eq!(state.objects[&land].zone, Zone::Battlefield); + assert_eq!(state.exile_cast_permissions_used.len(), 1); + assert!(state + .exile_cast_permissions_used + .contains(&permission_source)); + assert!(state.exile_play_permissions_used.is_empty()); +} + +/// CR 601.2a + CR 305.1: When both authorization classes apply, the elected +/// object-attached permission wins. Its unlimited frequency must not consume +/// the bounded static fallback's `ExileCast` slot. +#[test] +fn object_exile_land_authorization_precedes_static_fallback() { + let mut state = setup_game_at_main_phase(); + let player = PlayerId(0); + let static_source = add_exile_cast_permission_source_with( + &mut state, + player, + "Bounded Static Fallback", + TargetFilter::Any, + CastFrequency::OncePerTurn, + CardPlayMode::Play, + ExileCastCost::PayNormalCost, + ExileCardPool::Persistent, + ExileCastTiming::YourTurnOnly, + ); + let land = add_exiled_land(&mut state, player, "Overlapping Exiled Island"); + link_exiled_to_source(&mut state, land, static_source); + grant_object_exile_land_play_permission( + &mut state, + land, + player, + ObjectId(99_003), + CastFrequency::Unlimited, + ); + + let card_id = state.objects[&land].card_id; + apply_as_current( + &mut state, + GameAction::PlayLand { + object_id: land, + card_id, + }, + ) + .expect("the elected unlimited object permission must authorize the land"); + + assert!(!state.exile_cast_permissions_used.contains(&static_source)); +} + /// Build a persistent, your-turn-only, Cast-mode `ExileCastPermission` /// source carrying the Azula, Cunning Usurper concessions: any-type-mana /// spend (CR 609.4b) and flash-grant (CR 702.8a). diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index d92b89085d..45ead28023 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -23,9 +23,9 @@ use crate::types::player::PlayerId; use crate::types::resolution::debug_assert_runtime_resolution_invariants; use crate::types::resolved_commands::{ ResolvedInformationAudience, ResolvedInformationEdit, ResolvedInformationLifetime, - ResolvedRulesCommand, + ResolvedOncePerTurnPermission, ResolvedRulesCommand, }; -use crate::types::statics::StaticMode; +use crate::types::statics::{CastFrequency, StaticMode}; use crate::types::zones::Zone; use super::ability_utils::{ @@ -9169,16 +9169,39 @@ fn record_graveyard_play_permission( } } -fn record_exile_play_permission(state: &mut GameState, source: Option) { - let Some(source_id) = source else { - return; - }; - crate::game::ledger::consume_once_per_turn_permission( - state, - source_id, - crate::types::resolved_commands::ResolvedOncePerTurnPermission::ExilePlay, - ) - .expect("exile play permission must have an unused ledger slot"); +fn record_exile_play_permission( + state: &mut GameState, + authorization: Option, +) { + match authorization { + Some(casting::ExileLandPlayAuthorization::ObjectAttached { + source, + frequency: CastFrequency::OncePerTurn, + }) => crate::game::ledger::consume_once_per_turn_permission( + state, + source, + ResolvedOncePerTurnPermission::ExilePlay, + ) + .expect("object-attached exile play permission must have an unused ledger slot"), + Some(casting::ExileLandPlayAuthorization::Static { + source, + frequency: CastFrequency::OncePerTurn | CastFrequency::OncePerTurnPerPermanentType, + }) => crate::game::ledger::consume_once_per_turn_permission( + state, + source, + ResolvedOncePerTurnPermission::ExileCast, + ) + .expect("static exile play permission must have an unused ledger slot"), + Some(casting::ExileLandPlayAuthorization::ObjectAttached { + frequency: CastFrequency::Unlimited | CastFrequency::OncePerTurnPerPermanentType, + .. + }) + | Some(casting::ExileLandPlayAuthorization::Static { + frequency: CastFrequency::Unlimited, + .. + }) + | None => {} + } } /// CR 305.1 + CR 116.2a + CR 401.5: Consume the per-turn slot when a @@ -9206,6 +9229,42 @@ fn record_top_of_library_land_permission( } } +/// CR 305.1 + CR 116.2a: Finalize a land play once its zone change has +/// committed. A paused delivery tail may still require a choice, but the land +/// is already on the battlefield and no continuation retains the selected play +/// authority, so per-play accounting must happen at this seam. +#[allow(clippy::too_many_arguments)] +fn finalize_committed_land_play( + state: &mut GameState, + player: PlayerId, + object_id: ObjectId, + origin_zone: Zone, + graveyard_permission_source: Option, + exile_play_authorization: Option, + library_permission_source: Option<(ObjectId, CastFrequency)>, + events: &mut Vec, +) { + state.lands_played_this_turn += 1; + record_land_played_from_zone(state, player, object_id, origin_zone); + record_graveyard_play_permission(state, graveyard_permission_source, object_id); + record_exile_play_permission(state, exile_play_authorization); + if let Some((source_id, frequency)) = library_permission_source { + record_top_of_library_land_permission(state, source_id, frequency); + } + let player_data = state + .players + .iter_mut() + .find(|candidate| candidate.id == player) + .expect("priority player exists"); + player_data.lands_played_this_turn += 1; + priority::clear_priority_passes(state); + events.push(GameEvent::LandPlayed { + object_id, + player_id: player, + from_zone: origin_zone, + }); +} + fn mark_land_played_from_zone(state: &mut GameState, object_id: ObjectId, zone: Zone) { if let Some(obj) = state.objects.get_mut(&object_id) { obj.played_from_zone = Some(zone); @@ -9368,15 +9427,12 @@ fn handle_play_land( Some((src_id, frequency)) }); let in_library_with_permission = library_permission_src.is_some(); - let exile_permission_source = if state.exile.contains(&object_id) { - super::casting::exile_lands_playable_by_permission(state, player) - .iter() - .find(|(obj_id, _)| *obj_id == object_id) - .map(|(_, source_id)| *source_id) + let exile_play_authorization = if state.exile.contains(&object_id) { + super::casting::exile_land_play_authorization(state, player, object_id) } else { None }; - let in_exile_with_permission = exile_permission_source.is_some(); + let in_exile_with_permission = exile_play_authorization.is_some(); if !in_hand && !in_graveyard_with_permission @@ -9591,12 +9647,19 @@ fn handle_play_land( // the battlefield (the move precedes the counter pause in the // tail), so stamp the play origin now — matching the pre-token // arm, which stamped before the `apply_etb_counters` - // early-return — then surface the parked prompt; the land - // epilogue must not run yet. + // early-return — then surface the parked prompt. The land + // play itself is already committed. crate::game::zone_pipeline::ZoneDeliveryResult::NeedsChoice(_) => { - // CR 305.1 + CR 400.7i: stamp land-play provenance so - // effects can find the permanent the played land became. - mark_land_played_from_zone(state, object_id, origin_zone); + finalize_committed_land_play( + state, + player, + object_id, + origin_zone, + gy_permission_source, + exile_play_authorization, + library_permission_src, + events, + ); return Ok(state.waiting_for.clone()); } } @@ -9624,27 +9687,16 @@ fn handle_play_land( events, ) { - state.lands_played_this_turn += 1; - record_land_played_from_zone(state, player, object_id, origin_zone); - record_graveyard_play_permission(state, gy_permission_source, object_id); - record_exile_play_permission(state, exile_permission_source); - // CR 305.1 + CR 116.2a + CR 401.5: consume the once-per-turn - // library play slot using the pre-captured source (land play is - // a special action per CR 305.1/116.2a; CR 401.5 top-of-library - // visibility closes after the action; library.front() now points - // to the next card, not the played land). - if let Some((src_id, frequency)) = library_permission_src { - record_top_of_library_land_permission(state, src_id, frequency); - } - if let Some(p) = state.players.iter_mut().find(|p| p.id == player) { - p.lands_played_this_turn += 1; - } - priority::clear_priority_passes(state); - events.push(GameEvent::LandPlayed { + finalize_committed_land_play( + state, + player, object_id, - player_id: player, - from_zone: origin_zone, - }); + origin_zone, + gy_permission_source, + exile_play_authorization, + library_permission_src, + events, + ); return Ok(next_waiting_for); } } @@ -9659,29 +9711,16 @@ fn handle_play_land( // A replacement needs player choice (e.g., shock land "pay 2 life?"). // Increment counters now — the land play is committed, only the ETB // effect is pending. - state.lands_played_this_turn += 1; - record_land_played_from_zone(state, player, object_id, origin_zone); - // CR 604.2: Record once-per-turn graveyard play permission usage. - record_graveyard_play_permission(state, gy_permission_source, object_id); - record_exile_play_permission(state, exile_permission_source); - // CR 305.1 + CR 116.2a + CR 401.5: consume the once-per-turn library - // play slot using the pre-captured source (land play is a special - // action per CR 305.1/116.2a; CR 401.5 top-of-library visibility - // closes after the action; library.front() now points to the next - // card, not the played land). - if let Some((src_id, frequency)) = library_permission_src { - record_top_of_library_land_permission(state, src_id, frequency); - } - if let Some(p) = state.players.iter_mut().find(|p| p.id == player) { - p.lands_played_this_turn += 1; - } - priority::clear_priority_passes(state); - - events.push(GameEvent::LandPlayed { + finalize_committed_land_play( + state, + player, object_id, - player_id: player, - from_zone: origin_zone, - }); + origin_zone, + gy_permission_source, + exile_play_authorization, + library_permission_src, + events, + ); return Ok(super::replacement::replacement_choice_waiting_for( player, state, @@ -9689,35 +9728,16 @@ fn handle_play_land( } } - // Increment land counter - state.lands_played_this_turn += 1; - record_land_played_from_zone(state, player, object_id, origin_zone); - // CR 604.2: Record once-per-turn graveyard play permission usage. - record_graveyard_play_permission(state, gy_permission_source, object_id); - record_exile_play_permission(state, exile_permission_source); - // CR 305.1 + CR 116.2a + CR 401.5: consume the once-per-turn library play - // slot using the pre-captured source (land play is a special action per - // CR 305.1/116.2a; CR 401.5 top-of-library visibility closes after the - // action; library.front() now points to the next card, not the played - // land — post-delivery re-lookup would fail). - if let Some((src_id, frequency)) = library_permission_src { - record_top_of_library_land_permission(state, src_id, frequency); - } - let player_data = state - .players - .iter_mut() - .find(|p| p.id == player) - .expect("priority player exists"); - player_data.lands_played_this_turn += 1; - - // Reset priority passes (action was taken) - priority::clear_priority_passes(state); - - events.push(GameEvent::LandPlayed { + finalize_committed_land_play( + state, + player, object_id, - player_id: player, - from_zone: origin_zone, - }); + origin_zone, + gy_permission_source, + exile_play_authorization, + library_permission_src, + events, + ); // Player retains priority after playing a land Ok(WaitingFor::Priority { player }) From b219669ca44b3451931165adaa6d7d490f131398 Mon Sep 17 00:00:00 2001 From: atoz96 <132365140+atoz96@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:45:56 -0400 Subject: [PATCH 43/63] fix(server-core): redact an object-shaped draftSessionJson too (#6824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit redact_nested_draft_session_json only matched draftSessionJson when it was a JSON *string*: it took `.as_str()` and returned early on None. snapshot_json is an opaque host-supplied blob, so nothing upstream pins that field to a string — a host that sent the identical session inline as an object skipped redaction entirely. the backup row is keyed only by the 6-character draft code and is readable by any caller of GET /p2p-draft-backup/{code}, so what survived was exactly what the module exists to strip: unopened packs_by_seat and config.rng_seed, persisted to sqlite and echoed back. string form -> {"draftSessionJson":"{\"config\":{\"rng_seed\":0}}"} object form -> {"draftSessionJson":{"config":{"rng_seed":42}, "packs_by_seat":[["Black Lotus"]]}} both shapes now route through redact_draft_session_object, and the field keeps the wire type it arrived in. any other shape is left alone rather than erroring — it carries no session to redact. Co-authored-by: atoz96 --- crates/server-core/src/p2p_backup_guard.rs | 78 ++++++++++++++++++---- 1 file changed, 65 insertions(+), 13 deletions(-) diff --git a/crates/server-core/src/p2p_backup_guard.rs b/crates/server-core/src/p2p_backup_guard.rs index 3b15b50cde..e953af1b6f 100644 --- a/crates/server-core/src/p2p_backup_guard.rs +++ b/crates/server-core/src/p2p_backup_guard.rs @@ -96,21 +96,30 @@ fn redact_secret_keys(obj: &mut Map) { } fn redact_nested_draft_session_json(obj: &mut Map) { - let Some(nested_raw) = obj.get(DRAFT_SESSION_JSON_KEY).and_then(|v| v.as_str()) else { + let Some(nested_value) = obj.get_mut(DRAFT_SESSION_JSON_KEY) else { return; }; - let Ok(mut nested) = serde_json::from_str::(nested_raw) else { - return; - }; - let Some(nested_obj) = nested.as_object_mut() else { - return; - }; - redact_draft_session_object(nested_obj); - if let Ok(serialized) = serde_json::to_string(&nested) { - obj.insert( - DRAFT_SESSION_JSON_KEY.to_string(), - Value::String(serialized), - ); + match nested_value { + // Canonical shape: the session serialized into a JSON string. Parse, + // redact, re-serialize so the field keeps its wire type. + Value::String(nested_raw) => { + let Ok(mut nested) = serde_json::from_str::(nested_raw) else { + return; + }; + let Some(nested_obj) = nested.as_object_mut() else { + return; + }; + redact_draft_session_object(nested_obj); + if let Ok(serialized) = serde_json::to_string(&nested) { + *nested_value = Value::String(serialized); + } + } + // The same payload sent inline as an object. `snapshot_json` is an + // opaque host-supplied blob, so nothing upstream pins the field to a + // string — matching only the string shape let a host keep unopened + // packs and the rng seed simply by not encoding them twice. + Value::Object(nested_obj) => redact_draft_session_object(nested_obj), + _ => {} } } @@ -234,6 +243,49 @@ mod tests { assert_eq!(nested_out["status"], "Drafting"); } + #[test] + fn redact_p2p_backup_snapshot_secrets_strips_object_shaped_draft_session() { + // `snapshot_json` is an opaque host-supplied blob, so nothing upstream + // pins `draftSessionJson` to a string. Sending the identical payload + // inline as an object used to skip redaction entirely, persisting the + // unopened packs and the rng seed and echoing both from + // `GET /p2p-draft-backup/{code}`. + let raw = serde_json::json!({ + "draftCode": "ABC123", + "draftSessionJson": { + "draft_code": "ABC123", + "config": { "rng_seed": 42, "pod_size": 8 }, + "packs_by_seat": [[{"card_id": "secret-pack"}]], + "status": "Drafting" + }, + "seatTokens": { "0": "host-secret" } + }); + + let redacted = + redact_p2p_backup_snapshot_secrets(&raw.to_string()).expect("valid snapshot"); + + let parsed: Value = serde_json::from_str(&redacted).unwrap(); + assert!(parsed.get("seatTokens").is_none()); + let nested_out = &parsed["draftSessionJson"]; + assert_eq!(nested_out["config"]["rng_seed"], 0); + assert!(nested_out.get("packs_by_seat").is_none()); + // Untouched fields survive, and the field keeps the shape it arrived in. + assert_eq!(nested_out["status"], "Drafting"); + assert_eq!(nested_out["config"]["pod_size"], 8); + } + + #[test] + fn redact_p2p_backup_snapshot_secrets_leaves_non_session_shapes_alone() { + // A `draftSessionJson` that is neither a string nor an object carries no + // session to redact; it must pass through rather than error. + let raw = serde_json::json!({ "draftSessionJson": 7, "draftStarted": true }); + let redacted = + redact_p2p_backup_snapshot_secrets(&raw.to_string()).expect("valid snapshot"); + let parsed: Value = serde_json::from_str(&redacted).unwrap(); + assert_eq!(parsed["draftSessionJson"], 7); + assert_eq!(parsed["draftStarted"], true); + } + #[test] fn redact_p2p_backup_snapshot_secrets_rejects_non_object() { assert!(redact_p2p_backup_snapshot_secrets("[]").is_err()); From fd7fda7cb511d962f04082d6fc7db3f5cfb79fc0 Mon Sep 17 00:00:00 2001 From: atoz96 <132365140+atoz96@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:02:29 -0400 Subject: [PATCH 44/63] fix(server-core): report each forfeited game once, not once per seat (#6825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(server-core): report each forfeited game once, not once per seat disconnect records are keyed per (game, seat), but check_expired returned a bare game code per record — so a pod whose seats lapse in the same sweep yielded the same code once per seat: check_expired() -> ["GAME01", "GAME01", "GAME01"] the forfeit sweep in phase-server treats each element as one whole game to tear down, so the duplicates fanned the terminal GameOver out to every connected player once per lapsed seat, and repeated remove_game, delete_session and the spectator cleanup for the same code. collapsed at the source: forfeit is a per-game event, so a caller should never have to dedupe. check_expired_with_players is deliberately left per-seat — its entries are already distinct and draft auto-pick acts on each seat individually — and a test now pins that difference so the two do not drift. every expired record is still consumed whether or not it was reported, so the sweep-consumes-the-ledger contract from #6813 is unchanged. * fix(PR-6825): avoid quadratic reconnect expiry sweep Co-authored-by: atoz96 --------- Co-authored-by: atoz96 Co-authored-by: matthewevans --- crates/server-core/src/reconnect.rs | 56 +++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/crates/server-core/src/reconnect.rs b/crates/server-core/src/reconnect.rs index 0d318538ae..3d750be60e 100644 --- a/crates/server-core/src/reconnect.rs +++ b/crates/server-core/src/reconnect.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::time::{Duration, Instant}; use engine::types::player::PlayerId; @@ -74,12 +74,24 @@ impl ReconnectManager { } } - /// Return game codes with expired grace periods (for forfeit processing). + /// Return the distinct game codes with expired grace periods (for forfeit + /// processing), in the order they were observed. + /// + /// Entries are keyed per `(game, seat)`, so a game whose seats lapse + /// together produced one element each. Callers treat a code as "one game to + /// tear down" — emitting it twice sends the terminal `GameOver` to every + /// player once per lapsed seat and repeats the session delete. Forfeit is a + /// per-game event, so collapse here rather than at each call site. + /// [`Self::check_expired_with_players`] stays per-seat: its entries are + /// already distinct, and draft auto-pick acts on each seat individually. pub fn check_expired(&mut self) -> Vec { let mut expired = Vec::new(); + let mut expired_game_codes = HashSet::new(); self.disconnected.retain(|_key, info| { if info.disconnect_time.elapsed() > info.grace_period { - expired.push(info.game_code.clone()); + if expired_game_codes.insert(info.game_code.clone()) { + expired.push(info.game_code.clone()); + } false } else { true @@ -196,6 +208,44 @@ mod tests { assert!(expired.contains(&"GAME02".to_string())); } + #[test] + fn check_expired_reports_a_multi_seat_game_once() { + // Records are keyed per (game, seat), so a pod whose seats lapse + // together yielded the same code once per seat. The forfeit sweep + // treats each element as a whole game to tear down, so duplicates sent + // GameOver to every player once per lapsed seat and repeated the + // session delete. + let mut mgr = ReconnectManager::new(Duration::from_millis(0)); + for seat in 0..3 { + mgr.record_disconnect("GAME01", PlayerId(seat), Duration::from_millis(0)); + } + mgr.record_disconnect("GAME02", PlayerId(0), Duration::from_millis(0)); + std::thread::sleep(Duration::from_millis(2)); + + let expired = mgr.check_expired(); + + assert_eq!(expired.len(), 2, "one entry per game, got {expired:?}"); + assert!(expired.contains(&"GAME01".to_string())); + assert!(expired.contains(&"GAME02".to_string())); + // Every record is still consumed, whether or not it was reported. + assert!(!mgr.is_disconnected("GAME01", PlayerId(1))); + } + + #[test] + fn check_expired_with_players_stays_per_seat() { + // The per-seat sibling must keep one entry per lapsed seat — draft + // auto-pick acts on each seat individually. + let mut mgr = ReconnectManager::new(Duration::from_millis(0)); + for seat in 0..3 { + mgr.record_disconnect("DRAFT1", PlayerId(seat), Duration::from_millis(0)); + } + std::thread::sleep(Duration::from_millis(2)); + + let expired = mgr.check_expired_with_players(); + + assert_eq!(expired.len(), 3, "got {expired:?}"); + } + #[test] fn check_expired_retains_non_expired() { let mut mgr = ReconnectManager::new(Duration::from_secs(120)); From a3d6b10c52d1ca9f9159a0e453581fbbf17ec680 Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:37:51 -0700 Subject: [PATCH 45/63] refactor(parser): emit modal blocks as native ir (#6811) * refactor(parser): emit modal blocks as native ir * fix(parser): correct modal ir emission types * refactor(parser): remove retired modal emitters * fix(parser): remove retired replacement import * fix(parser): correct modal ir test APIs * fix(parser): box native anchor modal ir * fix(parser): preserve modal ir test invariants * fix(parser): scope modal damage anaphora * fix(parser): scope modal damage anaphora from trigger ir * test(parser): constrain modal damage scope override * fix(parser): restrict modal damage player context * test(parser): honor normalized modal header spans * test(parser): normalize modal span coordinates * test(parser): accept spell-shaped modal headers * test(parser): isolate Spree modal fixture * test(parser): model normalized Spree header * test(parser): label modal root assertions * test(parser): use canonical Spree modal header * test(parser): expect normalized Spree source slot * fix(parser): route modal blocks before keywords * test(parser): correct modal document fixtures --------- Co-authored-by: matthewevans --- crates/engine/src/parser/oracle.rs | 131 +- crates/engine/src/parser/oracle_class.rs | 1 + .../src/parser/oracle_effect/conditions.rs | 1 + crates/engine/src/parser/oracle_effect/mod.rs | 16 + crates/engine/src/parser/oracle_ir/ast.rs | 5 + crates/engine/src/parser/oracle_ir/context.rs | 2 +- crates/engine/src/parser/oracle_ir/doc.rs | 18 +- .../src/parser/oracle_ir/effect_chain.rs | 18 + crates/engine/src/parser/oracle_ir/feature.rs | 4 +- crates/engine/src/parser/oracle_ir/trigger.rs | 11 +- crates/engine/src/parser/oracle_modal.rs | 1194 ++++++++++++++++- crates/engine/src/parser/oracle_special.rs | 1 + crates/engine/src/parser/oracle_trigger.rs | 53 +- scripts/prelowered-ratchet.txt | 8 +- 14 files changed, 1306 insertions(+), 157 deletions(-) diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index 8daa321626..c2c77d8aee 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -87,9 +87,9 @@ use super::oracle_keyword::{ }; use super::oracle_level::parse_level_blocks; use super::oracle_modal::{ - extract_ability_word_reminder_body, lower_oracle_block, parse_oracle_block, + extract_ability_word_reminder_body, lower_oracle_block_ir, parse_oracle_block, split_short_label_prefix, strip_ability_word, strip_ability_word_with_name, - strip_flavor_word_with_name, FLAVOR_WORD_COST_LABEL_MAX_WORDS, + strip_flavor_word_with_name, AnchorModeIr, OracleBlockIr, FLAVOR_WORD_COST_LABEL_MAX_WORDS, }; use super::oracle_replacement::{ find_copy_verb_present, lower_as_enters_becomes_choice_modal, @@ -1195,7 +1195,6 @@ fn try_split_and_cant_become_untapped( fn item_replacement(item: &OracleItemIr) -> Option<&ReplacementDefinition> { match &item.node { OracleNodeIr::Replacement(replacement_ir) => Some(&replacement_ir.definition), - OracleNodeIr::PreLoweredReplacement(def) => Some(def), _ => None, } } @@ -1266,7 +1265,6 @@ fn item_trigger(item: &OracleItemIr) -> Option> { fn item_static(item: &OracleItemIr) -> Option<&StaticDefinition> { match &item.node { OracleNodeIr::Static(ir) => Some(&ir.definition), - OracleNodeIr::PreLoweredStatic(def) => Some(def), _ => None, } } @@ -3129,14 +3127,6 @@ pub(crate) fn lower_oracle_ir(ir: &mut OracleDocIr) -> ParsedAbilities { result.triggers.push(def); trigger_ids.push(item.id); } - OracleNodeIr::PreLoweredStatic(def) => { - result.statics.push(def.clone()); - static_ids.push(item.id); - } - OracleNodeIr::PreLoweredReplacement(def) => { - result.replacements.push(def.clone()); - replacement_ids.push(item.id); - } OracleNodeIr::PreLoweredSpell(def) => { let mut def = def.clone(); stamp_printed_ability_slot(&mut def, result.abilities.len()); @@ -3851,10 +3841,6 @@ impl<'a> DocEmitter<'a> { }, ); } - fn trigger_at(&mut self, line: usize, def: TriggerDefinition) { - self.last_trigger = Some(def.clone()); - self.emit_at(line, OracleNodeIr::PreLoweredTrigger(def)); - } /// Mirrors `static_ir_at`: the peek mirror stores the LOWERED definition, so /// the peek reader is unchanged and no `source_text` is invented for a slot /// nothing reads it from. Lowering here is a clone (`lower_trigger_node_ir` @@ -3863,10 +3849,6 @@ impl<'a> DocEmitter<'a> { self.last_trigger = Some(lower_trigger_node_ir(&ir)); self.emit_at(line, OracleNodeIr::Trigger(ir)); } - fn static_at(&mut self, line: usize, def: StaticDefinition) { - self.last_static = Some(def.clone()); - self.emit_at(line, OracleNodeIr::PreLoweredStatic(def)); - } fn static_ir_at(&mut self, line: usize, ir: StaticIr) { self.last_static = Some(lower_static_ir(&ir)); self.emit_at(line, OracleNodeIr::Static(ir)); @@ -3922,39 +3904,6 @@ impl<'a> DocEmitter<'a> { } } - /// Move every vector item a `&mut ParsedAbilities`-taking mutator just pushed - /// into the builder at `item_line`, then clear them. Used for the complex - /// cross-file mutators (modal / enters-replacement lowering) that the (B) - /// tuple-return design does NOT rewrite internally: they still push into a - /// scratch `ParsedAbilities`, and this drains that scratch into source-ordered - /// emission. `result`'s SINGLETON fields (modal/additional_cost/…) are left - /// untouched — the caller handles those. - fn drain_result_vectors(&mut self, item_line: usize, result: &mut ParsedAbilities) { - for def in std::mem::take(&mut result.abilities) { - self.ability_at(item_line, def); - } - for def in std::mem::take(&mut result.triggers) { - self.trigger_at(item_line, def); - } - for def in std::mem::take(&mut result.statics) { - self.static_at(item_line, def); - } - for def in std::mem::take(&mut result.replacements) { - self.replacement_at(item_line, def); - } - for kw in std::mem::take(&mut result.extracted_keywords) { - self.keyword_at(item_line, kw); - } - for r in std::mem::take(&mut result.casting_restrictions) { - self.casting_restriction_at(item_line, r); - } - for o in std::mem::take(&mut result.casting_options) { - self.casting_option_at(item_line, o); - } - } - fn replacement_at(&mut self, line: usize, def: ReplacementDefinition) { - self.emit_at(line, OracleNodeIr::PreLoweredReplacement(def)); - } fn keyword_at(&mut self, line: usize, kw: Keyword) { self.emit_at(line, OracleNodeIr::Keyword(kw)); } @@ -4504,7 +4453,57 @@ pub(crate) fn parse_oracle_ir( continue; } - // Priority 0: Semicolon-separated keyword lines (e.g., "Defender; reach"). + // Priority 0: Modal block (standard "Choose one —" + modes, or Spree + modes). + // Must run before keyword extraction so "Spree" header + follow-on `+` lines + // are consumed as a modal block, not swallowed as a keyword-only line. + if let Some((block, next_i)) = parse_oracle_block(&lines, i) { + match lower_oracle_block_ir(block, card_name, ctx.host_self_reference.clone(), &mut ctx) + { + OracleBlockIr::Activated(ability) => { + emitter.ability_ir_at(item_line, ability); + } + OracleBlockIr::Modal { choice, modes } => { + for mode in modes { + emitter.ability_ir_at( + mode.source_line + .expect("collected modal bullets have source lines"), + *mode.ability, + ); + } + emitter.modal_at(item_line, choice); + } + OracleBlockIr::Triggered(triggers) => { + for trigger in triggers { + emitter.trigger_ir_at(item_line, TriggerNodeIr::Parsed(Box::new(trigger))); + } + } + OracleBlockIr::AsEnters { + replacement, + children, + } => { + emitter.replacement_ir_at(item_line, replacement); + for (line, children) in children { + for child in children { + match child { + AnchorModeIr::Trigger(trigger) => { + emitter.trigger_ir_at(line, TriggerNodeIr::Parsed(trigger)) + } + AnchorModeIr::Static(static_ir) => { + emitter.static_ir_at(line, *static_ir) + } + AnchorModeIr::Unsupported(ability) => { + emitter.ability_ir_at(line, *ability); + } + } + } + } + } + } + i = next_i; + continue; + } + + // Priority 1: Semicolon-separated keyword lines (e.g., "Defender; reach"). // Oracle text uses semicolons exclusively to separate keywords on a single line. // The colon guard prevents splitting activated ability lines like "{T}: Draw a card". if line.contains(';') && !line.contains(':') { @@ -4532,27 +4531,6 @@ pub(crate) fn parse_oracle_ir( } } - // Priority 1: Modal block (standard "Choose one —" + modes, or Spree + modes). - // Must run before keyword extraction so "Spree" header + follow-on `+` lines - // are consumed as a modal block, not swallowed as a keyword-only line. - if let Some((block, next_i)) = parse_oracle_block(&lines, i) { - // Modal lowering still pushes into a scratch `ParsedAbilities`; drain - // its vector output into source-ordered emission at the block's line, - // and capture the `modal` singleton's line for post-loop emission. - lower_oracle_block( - block, - card_name, - ctx.host_self_reference.clone(), - &mut result, - ); - emitter.drain_result_vectors(item_line, &mut result); - if result.modal.is_some() { - modal_line.get_or_insert(item_line); - } - i = next_i; - continue; - } - // Pre-keyword activated ability: "Equip {cost}" / "Equip — {cost}" // (but not "Equipped ..."). // This must run before keyword-only extraction because MTGJSON keyword @@ -6286,6 +6264,7 @@ pub(crate) fn parse_oracle_ir( shell: AbilityShellIr::default(), die_results: vec![], root_transforms: vec![], + modal: None, } } else if let Some(vote) = crate::parser::oracle_vote::parse_vote_block_ir( parse_line, @@ -6298,6 +6277,7 @@ pub(crate) fn parse_oracle_ir( shell: AbilityShellIr::default(), die_results: vec![], root_transforms: vec![], + modal: None, } } else { parse_ability_ir_with_context(parse_line, AbilityKind::Spell, &mut ctx) @@ -7127,6 +7107,7 @@ pub(crate) fn try_parse_equip(line: &str) -> Option { ..AbilityShellIr::default() }, die_results: vec![], + modal: None, root_transforms: vec![], }) } diff --git a/crates/engine/src/parser/oracle_class.rs b/crates/engine/src/parser/oracle_class.rs index 0012add21f..f9c23d346e 100644 --- a/crates/engine/src/parser/oracle_class.rs +++ b/crates/engine/src/parser/oracle_class.rs @@ -165,6 +165,7 @@ pub(crate) fn parse_class_oracle_text( ..AbilityShellIr::default() }, die_results: vec![], + modal: None, root_transforms: vec![], }; items.push(( diff --git a/crates/engine/src/parser/oracle_effect/conditions.rs b/crates/engine/src/parser/oracle_effect/conditions.rs index 39a3ef62a4..322b10b795 100644 --- a/crates/engine/src/parser/oracle_effect/conditions.rs +++ b/crates/engine/src/parser/oracle_effect/conditions.rs @@ -4317,6 +4317,7 @@ pub(crate) fn try_parse_dig_instead_alternative( shell: AbilityShellIr::default(), die_results: vec![], root_transforms: vec![AbilityRootTransform::AppendCondition(condition)], + modal: None, }) } diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 0bb81c1fb3..1d56e95fcd 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -1666,6 +1666,7 @@ pub(crate) fn try_parse_temporal_delayed_trigger_ability( shell: AbilityShellIr::default(), die_results: vec![], root_transforms: vec![], + modal: None, }) } @@ -27029,6 +27030,16 @@ pub(crate) fn lower_ability_ir(ir: &AbilityIr) -> AbilityDefinition { } } apply_ability_root_transforms(&mut def, &ir.root_transforms); + if let Some(modal) = &ir.modal { + def = def.with_modal( + modal.choice.clone(), + modal + .modes + .iter() + .map(|mode| lower_ability_ir(&mode.ability)) + .collect(), + ); + } def } @@ -27211,6 +27222,7 @@ pub(crate) fn parse_ability_ir( shell: AbilityShellIr::default(), die_results: vec![], root_transforms: vec![], + modal: None, }; } if let Some(body) = parse_for_each_attacker_copy_blocker_ir(text, kind, ctx) { @@ -27220,6 +27232,7 @@ pub(crate) fn parse_ability_ir( shell: AbilityShellIr::default(), die_results: vec![], root_transforms: vec![], + modal: None, }; } if let ChainLoweringMode::WithContext = mode { @@ -27230,6 +27243,7 @@ pub(crate) fn parse_ability_ir( shell: AbilityShellIr::default(), die_results: vec![], root_transforms: vec![], + modal: None, }; } } @@ -27245,6 +27259,7 @@ pub(crate) fn parse_ability_ir( }, die_results: vec![], root_transforms: vec![], + modal: None, }; } AbilityIr { @@ -27253,6 +27268,7 @@ pub(crate) fn parse_ability_ir( shell: AbilityShellIr::default(), die_results: vec![], root_transforms: vec![], + modal: None, } } diff --git a/crates/engine/src/parser/oracle_ir/ast.rs b/crates/engine/src/parser/oracle_ir/ast.rs index c2a3e32d50..a2c6c6b0d0 100644 --- a/crates/engine/src/parser/oracle_ir/ast.rs +++ b/crates/engine/src/parser/oracle_ir/ast.rs @@ -1872,6 +1872,11 @@ pub(crate) enum OracleBlockAst { #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub(crate) struct ModeAst { pub(crate) raw: String, + /// Verbatim mode text before any structural distribution rewrite. + pub(crate) source_text: String, + /// Absolute Oracle line for a collected block bullet. Inline `; or` modes + /// have no independent printed line. + pub(crate) source_line: Option, pub(crate) label: Option, pub(crate) body: String, /// Per-mode additional cost (Spree). None for standard `\u{2022}` modes. diff --git a/crates/engine/src/parser/oracle_ir/context.rs b/crates/engine/src/parser/oracle_ir/context.rs index 9c790c8e5e..848615f3f8 100644 --- a/crates/engine/src/parser/oracle_ir/context.rs +++ b/crates/engine/src/parser/oracle_ir/context.rs @@ -20,7 +20,7 @@ pub(crate) enum TokenPtFollowup { /// pronoun/reference resolution ("it", "that creature", "that many"). /// /// Callers set only the fields they need; all fields are Default-able (D-02). -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct ParseContext { /// The current subject (resolved target — "it", "that creature"). pub subject: Option, diff --git a/crates/engine/src/parser/oracle_ir/doc.rs b/crates/engine/src/parser/oracle_ir/doc.rs index 15c65619b3..f1a9347c92 100644 --- a/crates/engine/src/parser/oracle_ir/doc.rs +++ b/crates/engine/src/parser/oracle_ir/doc.rs @@ -31,8 +31,8 @@ use super::static_ir::StaticIr; use super::trigger::TriggerNodeIr; use crate::types::ability::{ AbilityDefinition, AdditionalCost, CastingPermission, CastingRestriction, - ContinuousModification, Effect, ModalChoice, ReplacementDefinition, SolveCondition, - SpellCastingOption, StaticDefinition, TriggerDefinition, VoteSubject, + ContinuousModification, Effect, ModalChoice, SolveCondition, SpellCastingOption, + StaticDefinition, TriggerDefinition, VoteSubject, }; use crate::types::keywords::Keyword; use crate::types::mana::ManaCost; @@ -452,10 +452,6 @@ pub(crate) enum OracleNodeIr { // ----------------------------------------------------------------------- /// Pre-lowered trigger from a preprocessor. UNIT-4 DEBT. PreLoweredTrigger(TriggerDefinition), - /// Pre-lowered static from a preprocessor. UNIT-4 DEBT. - PreLoweredStatic(StaticDefinition), - /// Pre-lowered replacement from a preprocessor. UNIT-4 DEBT. - PreLoweredReplacement(ReplacementDefinition), /// Pre-lowered spell/activated ability from a preprocessor or dispatch path /// that constructs an `AbilityDefinition` directly. UNIT-4 DEBT. PreLoweredSpell(AbilityDefinition), @@ -500,9 +496,7 @@ impl OracleNodeIr { | OracleNodeIr::CastingOption(_) | OracleNodeIr::SolveCondition(_) | OracleNodeIr::StriveCost(_) - | OracleNodeIr::PreLoweredTrigger(_) - | OracleNodeIr::PreLoweredStatic(_) - | OracleNodeIr::PreLoweredReplacement(_) => None, + | OracleNodeIr::PreLoweredTrigger(_) => None, } } @@ -548,9 +542,7 @@ impl OracleNodeIr { | OracleNodeIr::CastingOption(_) | OracleNodeIr::SolveCondition(_) | OracleNodeIr::StriveCost(_) - | OracleNodeIr::PreLoweredTrigger(_) - | OracleNodeIr::PreLoweredStatic(_) - | OracleNodeIr::PreLoweredReplacement(_) => None, + | OracleNodeIr::PreLoweredTrigger(_) => None, } } } @@ -1021,9 +1013,7 @@ impl OracleDocBuilder { self.trigger_index += 1 } OracleNodeIr::Static(_) - | OracleNodeIr::PreLoweredStatic(_) | OracleNodeIr::Replacement(_) - | OracleNodeIr::PreLoweredReplacement(_) | OracleNodeIr::Keyword(_) | OracleNodeIr::Modal(_) | OracleNodeIr::AdditionalCost(_) diff --git a/crates/engine/src/parser/oracle_ir/effect_chain.rs b/crates/engine/src/parser/oracle_ir/effect_chain.rs index 32c8940a40..592d752e82 100644 --- a/crates/engine/src/parser/oracle_ir/effect_chain.rs +++ b/crates/engine/src/parser/oracle_ir/effect_chain.rs @@ -441,6 +441,24 @@ pub(crate) struct AbilityIr { /// metadata whose order depends on the root that chain assembly selected. /// An empty list is a lowering no-op. pub(crate) root_transforms: Vec, + /// Modal metadata attached to this ability root and lowered with it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) modal: Option, +} + +/// Native modal payload. Its modes retain parser provenance until their +/// ordinary `AbilityIr` lowering runs at the owning root's lowering seam. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct ModalPayloadIr { + pub(crate) choice: crate::types::ability::ModalChoice, + pub(crate) modes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct ModalModeIr { + pub(crate) source_text: String, + pub(crate) source_line: Option, + pub(crate) ability: Box, } /// A root-level transform applied only after an [`AbilityIr`] has been fully diff --git a/crates/engine/src/parser/oracle_ir/feature.rs b/crates/engine/src/parser/oracle_ir/feature.rs index e60216f970..876397c3e6 100644 --- a/crates/engine/src/parser/oracle_ir/feature.rs +++ b/crates/engine/src/parser/oracle_ir/feature.rs @@ -475,9 +475,7 @@ pub(crate) fn scope_to_unit( | OracleNodeIr::Static(_) | OracleNodeIr::Replacement(_) | OracleNodeIr::PreLoweredSpell(_) - | OracleNodeIr::PreLoweredTrigger(_) - | OracleNodeIr::PreLoweredStatic(_) - | OracleNodeIr::PreLoweredReplacement(_) => {} + | OracleNodeIr::PreLoweredTrigger(_) => {} } } scoped diff --git a/crates/engine/src/parser/oracle_ir/trigger.rs b/crates/engine/src/parser/oracle_ir/trigger.rs index 981cdf0cf7..1dadec2617 100644 --- a/crates/engine/src/parser/oracle_ir/trigger.rs +++ b/crates/engine/src/parser/oracle_ir/trigger.rs @@ -8,7 +8,7 @@ use serde::Serialize; use super::ast::parsed_clause; use super::context::ParseContext; -use super::effect_chain::{DieResultBranchIr, EffectChainIr}; +use super::effect_chain::{DieResultBranchIr, EffectChainIr, ModalModeIr}; use crate::types::ability::{ AbilityCost, AbilityDefinition, AbilityKind, ChoiceType, ControllerRef, Effect, ModalChoice, TargetFilter, TargetSelectionMode, TriggerCondition, TriggerConstraint, TriggerDefinition, @@ -85,6 +85,11 @@ pub(crate) struct TriggerIr { /// CR 706.3b result-table rows for the terminal die roll in this trigger. /// They remain IR until trigger-body lowering attaches them before finalization. pub(crate) die_results: Vec, + /// Complete context established from the trigger condition before its body + /// is parsed. Nested modal modes must start from this same seed so event + /// anaphora (notably spell-cast "it") keep their trigger-body meaning. + #[serde(skip)] + pub(crate) body_context: ParseContext, } impl TriggerIr { @@ -129,6 +134,8 @@ pub(crate) enum TriggerBody { pub(crate) struct ReflexivePaymentIr { pub(crate) cost: AbilityCost, pub(crate) effect_chain: EffectChainIr, + pub(crate) payment_chain: Option, + pub(crate) modal: Option, } /// CR 700.2: Typed inline-modal trigger body. @@ -141,7 +148,7 @@ pub(crate) struct ReflexivePaymentIr { pub(crate) struct ModalIr { pub(crate) marker: EffectChainIr, pub(crate) choice: ModalChoice, - pub(crate) mode_abilities: Vec, + pub(crate) modes: Vec, } /// CR 701.38: Typed vote trigger body. diff --git a/crates/engine/src/parser/oracle_modal.rs b/crates/engine/src/parser/oracle_modal.rs index 3db1c78179..f5b67b493b 100644 --- a/crates/engine/src/parser/oracle_modal.rs +++ b/crates/engine/src/parser/oracle_modal.rs @@ -8,29 +8,43 @@ use nom::sequence::{delimited, preceded, terminated}; use nom::Parser; use crate::types::ability::{ - AbilityCondition, AbilityDefinition, AbilityKind, AdditionalCostOrigin, - AdditionalCostPaymentSource, ChoiceType, Effect, ModalChoice, ModalSelectionCondition, - ModalSelectionConstraint, PlayerFilter, QuantityExpr, QuantityRef, ReplacementDefinition, - StaticCondition, TargetFilter, TargetSelectionMode, TriggerCondition, + AbilityDefinition, AbilityKind, AdditionalCostOrigin, AdditionalCostPaymentSource, ChoiceType, + ControllerRef, Effect, ModalChoice, ModalSelectionCondition, ModalSelectionConstraint, + PlayerFilter, QuantityExpr, QuantityRef, ReplacementDefinition, StaticCondition, TargetFilter, + TargetSelectionMode, TriggerCondition, TypedFilter, }; use crate::types::replacements::ReplacementEvent; +use crate::types::triggers::TriggerMode; use super::oracle::{find_activated_colon, strip_activated_constraints}; use super::oracle_cost::parse_oracle_cost; -use super::oracle_effect::{parse_effect_chain_with_context, try_parse_named_choice}; +#[cfg(test)] +use super::oracle_effect::lower_ability_ir; +use super::oracle_effect::{parse_ability_ir_with_context, try_parse_named_choice}; use super::oracle_ir::context::ParseContext; -use super::oracle_ir::effect_chain::EffectChainIr; -use super::oracle_ir::trigger::ModalIr; +use super::oracle_ir::doc::PrintedTriggerIndex; +use super::oracle_ir::effect_chain::{ + AbilityIr, AbilityShellIr, EffectChainIr, ModalModeIr, ModalPayloadIr, +}; +use super::oracle_ir::replacement::ReplacementIr; +use super::oracle_ir::static_ir::StaticIr; +use super::oracle_ir::trigger::{ModalIr, ReflexivePaymentIr, TriggerBody, TriggerIr}; use super::oracle_nom::bridge::nom_on_lower; use super::oracle_nom::condition as nom_condition; use super::oracle_nom::primitives::{self as nom_primitives, scan_preceded}; use super::oracle_quantity::parse_cda_quantity; -use super::oracle_static::{parse_pt_mod, parse_static_line}; +#[cfg(test)] +use super::oracle_static::parse_static_line; +use super::oracle_static::{parse_pt_mod, parse_static_line_ir}; +#[cfg(test)] use super::oracle_trigger::parse_trigger_lines; +use super::oracle_trigger::parse_trigger_lines_at_index_ir; use super::oracle_util::{parse_mana_symbols, strip_reminder_text, TextPair}; use crate::parser::oracle_ir::ast::{ parsed_clause, ModalHeaderAst, ModalOptionality, ModeAst, OracleBlockAst, }; +#[cfg(test)] +use crate::types::ability::AbilityCondition; use crate::types::mana::ManaCost; pub(crate) fn parse_oracle_block(lines: &[&str], start: usize) -> Option<(OracleBlockAst, usize)> { @@ -195,10 +209,14 @@ fn parse_pawprint_run(input: &str) -> OracleResult<'_, u8> { pub(crate) fn collect_mode_asts(lines: &[&str], start: usize) -> Vec { let mut modes = Vec::new(); - for raw in lines.iter().skip(start) { + for (line_index, raw) in lines.iter().enumerate().skip(start) { let line = strip_reminder_text(raw.trim()); if let Some(stripped) = line.strip_prefix('•') { - modes.push(parse_mode_ast(stripped.trim())); + modes.push(parse_mode_ast( + stripped.trim(), + line.as_str(), + Some(line_index), + )); } else if let Some(stripped) = line.strip_prefix('+') { // CR 702.172: Spree mode lines use `+ {cost} — effect` format let stripped = stripped.trim(); @@ -207,6 +225,8 @@ pub(crate) fn collect_mode_asts(lines: &[&str], start: usize) -> Vec { let body = strip_mode_separator(rest); modes.push(ModeAst { raw: body.to_string(), + source_text: line.to_string(), + source_line: Some(line_index), label: None, body: body.to_string(), mode_cost: Some(cost), @@ -220,6 +240,8 @@ pub(crate) fn collect_mode_asts(lines: &[&str], start: usize) -> Vec { let body = strip_mode_separator(rest); modes.push(ModeAst { raw: body.to_string(), + source_text: line.to_string(), + source_line: Some(line_index), label: None, body: body.to_string(), mode_cost: None, @@ -233,12 +255,14 @@ pub(crate) fn collect_mode_asts(lines: &[&str], start: usize) -> Vec { modes } -fn parse_mode_ast(text: &str) -> ModeAst { +fn parse_mode_ast(text: &str, source_text: &str, source_line: Option) -> ModeAst { if let Some((label, body)) = split_short_label_prefix(text, 4) { if let Some((cost, rest)) = parse_mana_symbols(body) { let body = strip_mode_separator(rest); return ModeAst { raw: text.to_string(), + source_text: source_text.to_string(), + source_line, label: Some(label.to_string()), body: body.to_string(), mode_cost: Some(cost), @@ -248,6 +272,8 @@ fn parse_mode_ast(text: &str) -> ModeAst { return ModeAst { raw: text.to_string(), + source_text: source_text.to_string(), + source_line, label: Some(label.to_string()), body: body.to_string(), mode_cost: None, @@ -265,6 +291,8 @@ fn parse_mode_ast(text: &str) -> ModeAst { if let Some((label, body)) = split_mode_flavor_label(text) { return ModeAst { raw: text.to_string(), + source_text: source_text.to_string(), + source_line, label: Some(label.to_string()), body: body.to_string(), mode_cost: None, @@ -274,6 +302,8 @@ fn parse_mode_ast(text: &str) -> ModeAst { ModeAst { raw: text.to_string(), + source_text: source_text.to_string(), + source_line, label: None, body: text.to_string(), mode_cost: None, @@ -362,6 +392,8 @@ fn distribute_shared_mode_effect(header_full_text: &str, modes: Vec) -> let distributed = format!("{prefix}{target_phrase}{suffix}"); ModeAst { raw: mode.raw, + source_text: mode.source_text, + source_line: mode.source_line, label: mode.label, body: distributed, mode_cost: mode.mode_cost, @@ -546,13 +578,13 @@ fn anchor_modes_match_labels(modes: &[ModeAst], labels: &[String]) -> bool { if modes.len() != labels.len() { return false; } - let mode_labels: Vec = modes + let Some(mode_labels): Option> = modes .iter() - .filter_map(|m| m.label.as_ref().map(|s| s.to_lowercase())) - .collect(); - if mode_labels.len() != modes.len() { + .map(|mode| mode.label.as_ref().map(|label| label.to_lowercase())) + .collect() + else { return false; - } + }; let mut wanted: Vec = labels.iter().map(|s| s.to_lowercase()).collect(); for actual in &mode_labels { match wanted.iter().position(|w| w == actual) { @@ -996,6 +1028,312 @@ fn split_reflexive_optional_cost(trigger_line: &str) -> Option<(String, String)> Some((trigger_orig.to_string(), cost_cased)) } +/// Native document payload for a Priority-1 modal block. The document router +/// owns all source spans; this type deliberately carries only nested parser IR. +pub(crate) enum OracleBlockIr { + Activated(AbilityIr), + Modal { + choice: ModalChoice, + modes: Vec, + }, + Triggered(Vec), + AsEnters { + replacement: ReplacementIr, + children: Vec<(usize, Vec)>, + }, +} + +pub(crate) enum AnchorModeIr { + Trigger(Box), + Static(Box), + /// The bullet is still a first-class document item when no native trigger + /// or static grammar accepts it. This preserves its printed slot and exact + /// source line instead of silently dropping the label's semantics. + Unsupported(Box), +} + +/// CR 603.2c + CR 608.2c: A self-source damage trigger establishes the +/// damaged player as the referent for a nested modal mode's "that player". +/// The ordinary trigger classifier retains its historical `TargetPlayer` +/// treatment of the normalized `~` surface; modal modes instead require the +/// event-player context to parse their independent effect chains correctly. +/// +/// This consumes the trigger parser's typed output instead of recognizing a +/// second, narrower Oracle grammar here. In particular, the trigger parser +/// already accounts for combat/noncombat, plural damage verbs, and thresholds. +fn modal_relative_player_scope_for_trigger(trigger: &TriggerIr) -> Option { + match ( + &trigger.condition, + &trigger.partial_def.valid_source, + &trigger.partial_def.valid_target, + ) { + (TriggerMode::DamageDone, Some(TargetFilter::SelfRef), Some(TargetFilter::Player)) => { + Some(ControllerRef::TriggeringPlayer) + } + ( + TriggerMode::DamageDone, + Some(TargetFilter::SelfRef), + Some(TargetFilter::Typed(TypedFilter { + type_filters, + controller: Some(ControllerRef::Opponent), + .. + })), + ) if type_filters.is_empty() => Some(ControllerRef::TriggeringPlayer), + _ => None, + } +} + +pub(crate) fn lower_oracle_block_ir( + block: OracleBlockAst, + card_name: &str, + host_self_reference: Option, + ctx: &mut ParseContext, +) -> OracleBlockIr { + match block { + OracleBlockAst::ActivatedModal { + cost_text, + header, + modes, + constraints, + } => { + let payload = ModalPayloadIr { + choice: build_modal_choice(&header, &modes), + modes: parse_modal_mode_irs(&modes, AbilityKind::Activated, ctx), + }; + let mut ability = modal_marker_ir(&header, AbilityKind::Activated, payload, ctx); + ability.shell.cost = Some(parse_oracle_cost(&cost_text)); + ability.shell.activation_restrictions = constraints.restrictions; + OracleBlockIr::Activated(ability) + } + OracleBlockAst::Modal { header, modes } => OracleBlockIr::Modal { + choice: build_modal_choice(&header, &modes), + modes: parse_modal_mode_irs(&modes, AbilityKind::Spell, ctx), + }, + OracleBlockAst::TriggeredModal { + trigger_line, + header, + modes, + optional_cost, + } => { + let mut trigger_ctx = ctx.clone(); + trigger_ctx.host_self_reference = host_self_reference; + let mut triggers = parse_trigger_lines_at_index_ir( + &trigger_line, + card_name, + Some(PrintedTriggerIndex::placeholder()), + &mut trigger_ctx, + ); + ctx.diagnostics.extend(trigger_ctx.diagnostics); + for trigger in &mut triggers { + // `body_context` is captured before normal trigger-body parsing. + // Clone it per sibling: each mode receives all trigger-established + // facts, but no mode can leak chain-local state into another. + let mut mode_ctx = trigger.body_context.clone(); + mode_ctx.diagnostics.clear(); + if let Some(scope) = modal_relative_player_scope_for_trigger(trigger) { + mode_ctx.relative_player_scope = Some(scope); + } + let payload = ModalIr { + marker: EffectChainIr::single_clause( + &header.raw, + AbilityKind::Spell, + parsed_clause(modal_marker_effect(&header)), + None, + mode_ctx.actor.clone(), + true, + ), + choice: build_modal_choice(&header, &modes), + modes: parse_modal_mode_irs(&modes, AbilityKind::Spell, &mut mode_ctx), + }; + ctx.diagnostics.extend(mode_ctx.diagnostics); + trigger.body = Some(match &optional_cost { + Some(cost_text) => { + TriggerBody::ReflexivePayment(Box::new(ReflexivePaymentIr { + cost: parse_oracle_cost(cost_text), + effect_chain: EffectChainIr::single_clause( + cost_text, + AbilityKind::Spell, + parsed_clause(Effect::PayCost { + cost: parse_oracle_cost(cost_text), + scale: None, + payer: TargetFilter::Controller, + }), + None, + None, + true, + ), + payment_chain: Some( + parse_ability_ir_with_context( + cost_text, + AbilityKind::Spell, + &mut ParseContext { + actor: ctx.actor.clone(), + in_trigger: true, + ..Default::default() + }, + ) + .body, + ), + modal: Some(payload.clone()), + })) + } + None => TriggerBody::Modal(Box::new(payload.clone())), + }); + if matches!(header.optionality, ModalOptionality::MayDecline) { + trigger.modifiers.optional = true; + } + } + OracleBlockIr::Triggered(triggers) + } + OracleBlockAst::AsEntersAnchorWordModal { + header_text, + labels, + modes, + } => { + // `ReplacementIr` is the bounded wrapper permitted for the native + // as-enters header. The execute choice is a typed replacement payload, + // never a document-level pre-lowered node. + let replacement = ReplacementIr { + definition: ReplacementDefinition::new(ReplacementEvent::Moved) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::Choose { + choice_type: ChoiceType::Labeled { options: labels }, + persist: true, + selection: TargetSelectionMode::Chosen, + }, + )) + .valid_card(TargetFilter::SelfRef) + .destination_zone(crate::types::zones::Zone::Battlefield) + .description(header_text.trim().to_string()), + source_text: header_text, + execute_ir: None, + }; + let children = modes + .iter() + .map(|mode| anchor_mode_irs(mode, card_name, ctx)) + .collect(); + OracleBlockIr::AsEnters { + replacement, + children, + } + } + } +} + +fn modal_marker_ir( + header: &ModalHeaderAst, + kind: AbilityKind, + modal: ModalPayloadIr, + ctx: &ParseContext, +) -> AbilityIr { + AbilityIr { + source_text: header.raw.clone(), + body: EffectChainIr::single_clause( + &header.raw, + kind, + parsed_clause(modal_marker_effect(header)), + None, + ctx.actor.clone(), + ctx.in_trigger, + ), + shell: AbilityShellIr::default(), + die_results: vec![], + root_transforms: vec![], + modal: Some(modal), + } +} + +fn parse_modal_mode_irs( + modes: &[ModeAst], + kind: AbilityKind, + base_ctx: &mut ParseContext, +) -> Vec { + modes + .iter() + .map(|mode| { + let mut mode_ctx = base_ctx.clone(); + mode_ctx.diagnostics.clear(); + let mut ability = parse_ability_ir_with_context(&mode.body, kind, &mut mode_ctx); + guard_unsupported_mode_qualifiers_ir(&mut ability, kind, &mode_ctx); + base_ctx.diagnostics.extend(mode_ctx.diagnostics); + ModalModeIr { + source_text: mode.source_text.clone(), + source_line: mode.source_line, + ability: Box::new(ability), + } + }) + .collect() +} + +fn anchor_mode_irs( + mode: &ModeAst, + card_name: &str, + base_ctx: &mut ParseContext, +) -> (usize, Vec) { + let line = mode + .source_line + .expect("collected as-enters anchor modes have independent bullet lines"); + let Some(label) = mode.label.as_ref() else { + return (line, vec![unsupported_anchor_mode_ir(mode, base_ctx)]); + }; + let body = mode.body.trim(); + let lower = body.to_lowercase(); + if alt(( + tag::<_, _, OracleError<'_>>("when "), + tag("whenever "), + tag("at "), + )) + .parse(lower.as_str()) + .is_ok() + { + let mut mode_ctx = base_ctx.clone(); + mode_ctx.diagnostics.clear(); + let triggers = parse_trigger_lines_at_index_ir(body, card_name, None, &mut mode_ctx); + base_ctx.diagnostics.extend(mode_ctx.diagnostics); + let children = triggers + .into_iter() + .map(|mut trigger| { + if matches!(&trigger.condition, TriggerMode::Unknown(_)) || trigger.body.is_none() { + unsupported_anchor_mode_ir(mode, base_ctx) + } else { + attach_chosen_label_to_trigger_ir(&mut trigger, label); + AnchorModeIr::Trigger(Box::new(trigger)) + } + }) + .collect(); + return (line, children); + } + match parse_static_line_ir(body) { + Some(mut static_ir) => { + attach_chosen_label_to_static_ir(&mut static_ir, label); + (line, vec![AnchorModeIr::Static(Box::new(static_ir))]) + } + None => (line, vec![unsupported_anchor_mode_ir(mode, base_ctx)]), + } +} + +fn unsupported_anchor_mode_ir(mode: &ModeAst, ctx: &ParseContext) -> AnchorModeIr { + let source = mode.source_text.clone(); + AnchorModeIr::Unsupported(Box::new(AbilityIr { + source_text: source.clone(), + body: EffectChainIr::single_clause( + &source, + AbilityKind::Spell, + parsed_clause(Effect::unimplemented("as_enters_anchor_mode", &source)), + None, + ctx.actor.clone(), + ctx.in_trigger, + ), + shell: AbilityShellIr::default(), + die_results: vec![], + root_transforms: vec![], + modal: None, + })) +} + +#[cfg(test)] +#[allow(dead_code)] pub(crate) fn lower_oracle_block( block: OracleBlockAst, card_name: &str, @@ -1123,6 +1461,7 @@ pub(crate) fn lower_oracle_block( /// Falls back to a no-op placeholder static with an `Unrecognized` condition /// when a mode body parses to neither a trigger nor a static — preserves the /// choice shape for the coverage report instead of silently dropping a mode. +#[cfg(test)] fn lower_as_enters_anchor_word_modal( header_text: String, labels: Vec, @@ -1241,10 +1580,38 @@ fn lower_as_enters_anchor_word_modal( } } -/// Attach a `ChosenLabelIs` intervening-if to a parsed trigger. Composes with -/// any pre-existing condition via `TriggerCondition::And` so the linked -/// ability remains rule-correct even if the body itself carries an "if" -/// clause (none in current corpus, future-safe). +/// Attach the anchor gate before trigger lowering. Composes through the same +/// `intervening_if` field ordinary trigger parsing uses, so lowering remains +/// the sole definition-assembly seam. +fn attach_chosen_label_to_trigger_ir(trigger: &mut TriggerIr, label: &str) { + let gate = TriggerCondition::ChosenLabelIs { + label: label.to_string(), + }; + trigger.modifiers.intervening_if = Some(match trigger.modifiers.intervening_if.take() { + None => gate, + Some(existing) => TriggerCondition::And { + conditions: vec![gate, existing], + }, + }); +} + +/// Attach the anchor gate before static lowering. `StaticIr` is the permitted +/// bounded static wrapper for this whole-line grammar. +fn attach_chosen_label_to_static_ir(static_ir: &mut StaticIr, label: &str) { + let gate = StaticCondition::ChosenLabelIs { + label: label.to_string(), + }; + static_ir.definition.condition = Some(match static_ir.definition.condition.take() { + None => gate, + Some(existing) => StaticCondition::And { + conditions: vec![gate, existing], + }, + }); +} + +/// Attach a `ChosenLabelIs` intervening-if to a parsed trigger. Kept only for +/// the legacy lowering compatibility tests below. +#[cfg(test)] fn attach_chosen_label_to_trigger( trigger: &mut crate::types::ability::TriggerDefinition, label: &str, @@ -1264,8 +1631,8 @@ fn attach_chosen_label_to_trigger( // battlefield-only behavior is correct. } -/// Attach a `ChosenLabelIs` gate to a parsed static. Composes with any -/// pre-existing condition via `StaticCondition::And`. +/// Attach a `ChosenLabelIs` gate to a parsed static for legacy tests. +#[cfg(test)] fn attach_chosen_label_to_static( static_def: &mut crate::types::ability::StaticDefinition, label: &str, @@ -1281,6 +1648,7 @@ fn attach_chosen_label_to_static( }); } +#[cfg(test)] pub(crate) fn build_modal_ability( kind: AbilityKind, header: &ModalHeaderAst, @@ -1307,6 +1675,7 @@ pub(crate) fn build_modal_ability( /// caster (`ControllerRef::You`). This mirrors the inline `"; or"` modal path /// (`try_parse_inline_modal_ir`); both must thread the same scope so bullet-line /// and inline modal forms of the same trigger agree (issue #2346). +#[cfg(test)] fn build_modal_ability_with_subject( kind: AbilityKind, header: &ModalHeaderAst, @@ -1334,6 +1703,7 @@ fn build_modal_ability_with_subject( /// `resolve_it_pronoun`'s gating: only non-self, non-Any subjects route mode- /// body "that creature" to `TriggeringSource`; self-triggers (like Saga /// chapters that name themselves) keep the legacy `ParentTarget` semantics. +#[cfg(test)] fn derive_modal_subject( triggers: &[crate::types::ability::TriggerDefinition], ) -> Option { @@ -1465,6 +1835,7 @@ fn cap_modal_constraints( .collect() } +#[cfg(test)] fn lower_mode_abilities( modes: &[ModeAst], kind: AbilityKind, @@ -1482,6 +1853,7 @@ fn lower_mode_abilities( /// typed attachment-host self-reference so a `"that creature"` copy-token /// anaphor inside a modal mode body of an Aura/bestow card remaps to the /// enchanted host. `None` for non-Aura cards. +#[cfg(test)] fn lower_mode_abilities_with_subject( modes: &[ModeAst], kind: AbilityKind, @@ -1499,6 +1871,7 @@ fn lower_mode_abilities_with_subject( /// For DamageDone triggers the damaged player is the triggering /// player; "that player" in each modal branch must resolve to them, not the /// caster or `ParentTargetController`. +#[cfg(test)] pub(crate) fn lower_mode_abilities_with_scope( modes: &[ModeAst], kind: AbilityKind, @@ -1512,12 +1885,9 @@ pub(crate) fn lower_mode_abilities_with_scope( relative_player_scope, ..Default::default() }; - modes - .iter() - .map(|mode| { - let parsed = parse_effect_chain_with_context(&mode.body, kind, &mut ctx); - guard_unsupported_mode_qualifiers(&mode.body, parsed, kind) - }) + parse_modal_mode_irs(modes, kind, &mut ctx) + .into_iter() + .map(|mode| lower_ability_ir(&mode.ability)) .collect() } @@ -1555,6 +1925,8 @@ pub(crate) fn try_parse_inline_modal_ir(effect_body: &str, ctx: &ParseContext) - let body = body.trim_end_matches('.'); ModeAst { raw: body.to_string(), + source_text: body.to_string(), + source_line: None, label: None, body: body.to_string(), mode_cost: None, @@ -1563,13 +1935,7 @@ pub(crate) fn try_parse_inline_modal_ir(effect_body: &str, ctx: &ParseContext) - }) .collect(); - let mode_abilities = lower_mode_abilities_with_scope( - &modes, - AbilityKind::Spell, - None, - ctx.relative_player_scope.clone(), - None, - ); + let mut mode_ctx = ctx.clone(); Some(ModalIr { marker: EffectChainIr::single_clause( effect_body, @@ -1580,7 +1946,7 @@ pub(crate) fn try_parse_inline_modal_ir(effect_body: &str, ctx: &ParseContext) - ctx.in_trigger, ), choice: build_modal_choice(&header, &modes), - mode_abilities, + modes: parse_modal_mode_irs(&modes, AbilityKind::Spell, &mut mode_ctx), }) } @@ -1603,6 +1969,8 @@ pub(crate) fn try_parse_inline_modal_ir(effect_body: &str, ctx: &ParseContext) - /// (e.g. `TotalManaValueAtMost`) or filter extensions (core-type + subtype /// disjunction in `that's a X or Y`) are introduced, this guard will be /// tightened to only fire on the residual unsupported forms. +#[cfg(test)] +#[allow(dead_code)] fn guard_unsupported_mode_qualifiers( body: &str, parsed: AbilityDefinition, @@ -1629,10 +1997,7 @@ fn guard_unsupported_mode_qualifiers( if dig_with_total_mv || put_counter_with_thats_a { return AbilityDefinition::new( kind, - Effect::Unimplemented { - name: "modal_mode_unsupported_qualifier".into(), - description: Some(body.to_string()), - }, + Effect::unimplemented("modal_mode_unsupported_qualifier", body), ) .description(body.to_string()); } @@ -1640,6 +2005,43 @@ fn guard_unsupported_mode_qualifiers( parsed } +/// IR-native qualifier guard for the modal migration. It inspects every parsed +/// clause against its enclosing mode source; it never lowers an `AbilityIr` +/// merely to decide whether a restrictive qualifier was swallowed. +fn guard_unsupported_mode_qualifiers_ir( + ability: &mut AbilityIr, + kind: AbilityKind, + ctx: &ParseContext, +) { + let lower = ability.source_text.to_lowercase(); + let has_unsupported_qualifier = ability.body.clauses.iter().any(|clause| { + let dig_with_total_mv = matches!(&clause.parsed.effect, Effect::Dig { .. }) + && nom_primitives::scan_contains(&lower, "with total mana value"); + let put_counter_with_thats_a = matches!( + &clause.parsed.effect, + Effect::PutCounterAll { .. } | Effect::PutCounter { .. } + ) && nom_primitives::scan_contains(&lower, "that's a "); + dig_with_total_mv || put_counter_with_thats_a + }); + + if !has_unsupported_qualifier { + return; + } + + let source = ability.source_text.clone(); + ability.body = EffectChainIr::single_clause( + &source, + kind, + parsed_clause(Effect::unimplemented( + "modal_mode_unsupported_qualifier", + &source, + )), + None, + ctx.actor.clone(), + ctx.in_trigger, + ); +} + /// Parse the "choose N" count from the modal header line. /// /// Returns (min_choices, max_choices). Examples: @@ -2030,6 +2432,8 @@ fn scan_modal_count_override(text: &str) -> Option { #[cfg(test)] mod tests { + use crate::parser::oracle_util::normalize_card_name_refs; + use super::*; #[test] @@ -2046,11 +2450,13 @@ mod tests { Effect::GenericEffect { .. } )); assert_eq!(modal.choice.mode_count, 2); - assert_eq!(modal.mode_abilities.len(), 2); - assert!(modal - .mode_abilities - .iter() - .all(|mode| { !matches!(mode.effect.as_ref(), Effect::Unimplemented { .. }) })); + assert_eq!(modal.modes.len(), 2); + assert!(modal.modes.iter().all(|mode| { + !matches!( + mode.ability.body.clauses[0].parsed.effect, + Effect::Unimplemented { .. } + ) + })); } #[test] @@ -2131,6 +2537,8 @@ mod tests { fn bare_target_mode(body: &str) -> ModeAst { ModeAst { raw: format!("{body}."), + source_text: format!("{body}."), + source_line: None, label: None, body: body.to_string(), mode_cost: None, @@ -2596,11 +3004,267 @@ mod tests { assert!(modes.iter().all(|m| m.mode_cost.is_none())); } + #[test] + fn block_mode_ir_preserves_independent_bullet_provenance() { + let lines = ["Choose one —", "• Draw a card.", "• Gain 3 life."]; + let (block, _) = parse_oracle_block(&lines, 0).expect("modal block"); + let ir = lower_oracle_block_ir( + block, + "Provenance Witness", + None, + &mut ParseContext::default(), + ); + let OracleBlockIr::Modal { choice, modes } = ir else { + panic!("plain modal must remain independent document items"); + }; + + assert_eq!(choice.mode_count, 2); + assert_eq!(modes[0].source_line, Some(1)); + assert_eq!(modes[1].source_line, Some(2)); + assert!(matches!( + modes[0].ability.body.clauses[0].parsed.effect, + Effect::Draw { .. } + )); + assert!(matches!( + modes[1].ability.body.clauses[0].parsed.effect, + Effect::GainLife { .. } + )); + } + + #[test] + fn plain_modal_document_ir_keeps_each_bullet_in_its_exact_printed_slot() { + const ORACLE: &str = "Choose one —\n• Draw a card.\n• Gain 3 life."; + let mut ir = parse_oracle_ir(ORACLE, "Slot Witness", &[], &[], &[]); + + assert_eq!( + ir.items.len(), + 3, + "header plus two independently emitted modes" + ); + assert!(matches!(&ir.items[0].node, OracleNodeIr::Modal(_))); + assert!(matches!(&ir.items[1].node, OracleNodeIr::Spell(_))); + assert!(matches!(&ir.items[2].node, OracleNodeIr::Spell(_))); + for (line, item) in ir.items.iter().enumerate() { + let span = item.source.span(); + assert!(span.is_exact()); + assert_eq!((span.first_line, span.last_line), (line, line)); + assert_eq!( + item.source.fragment(), + Some(ORACLE.lines().nth(line).unwrap()) + ); + } + + let lowered = lower_oracle_ir(&mut ir); + assert_eq!( + lowered.modal.as_ref().map(|choice| choice.mode_count), + Some(2) + ); + assert_eq!(lowered.abilities.len(), 2); + assert!(matches!( + lowered.abilities[0].effect.as_ref(), + Effect::Draw { .. } + )); + assert!(matches!( + lowered.abilities[1].effect.as_ref(), + Effect::GainLife { .. } + )); + } + + #[test] + fn modal_document_items_keep_exact_slots_for_every_non_nested_modal_surface() { + let cases = [ + ( + "plain", + "Choose one —\n• Draw a card.\n• Gain 3 life.", + &[0, 1, 2][..], + 2, + ), + ( + "Spree", + "Spree\n+ {1} — Draw a card.\n+ {2} — Gain 3 life.", + &[0, 1, 2][..], + 2, + ), + ( + "pawprint", + "Choose up to five {P} worth of modes. You may choose the same mode more than once.\n{P} — Draw a card.\n{P}{P} — Gain 3 life.", + &[0, 1, 2][..], + 2, + ), + ( + "ordinary Tiered", + "Tiered (Choose one additional cost.)\n• Cure — {0} — Draw a card.\n• Cura — {1} — Gain 3 life.", + &[0, 1, 2][..], + 2, + ), + ("shared-effect Tiered", VINCENTS_ORACLE, &[0, 2, 3, 4][..], 3), + ]; + + for (name, oracle, expected_lines, expected_mode_count) in cases { + let types = vec!["Instant".to_string()]; + let card_name = if name == "Spree" { + "Modal Slot Witness" + } else { + name + }; + let mut ir = parse_oracle_ir(oracle, card_name, &[], &types, &[]); + let span_source = normalize_card_name_refs(oracle, card_name); + + assert_eq!( + ir.items.len(), + expected_lines.len(), + "{name}: header and every printed bullet must retain its own document slot" + ); + for (item, expected_line) in ir.items.iter().zip(expected_lines) { + let span = item.source.span(); + assert!(span.is_exact(), "{name}: slot must have an exact span"); + assert_eq!( + (span.first_line, span.last_line), + (*expected_line, *expected_line), + "{name}: source-order slot drift" + ); + let expected_fragment = oracle.lines().nth(*expected_line).unwrap(); + assert_eq!( + item.source.fragment(), + Some(expected_fragment), + "{name}: slot must retain its verbatim printed source" + ); + let expected_start_byte = span_source + .lines() + .take(*expected_line) + .map(|line| line.len() + 1) + .sum::(); + assert_eq!( + (span.start_byte, span.end_byte), + ( + expected_start_byte, + expected_start_byte + expected_fragment.len() + ), + "{name}: exact source span must bound this header or bullet only" + ); + assert!( + matches!(&item.node, OracleNodeIr::Modal(_) | OracleNodeIr::Spell(_)), + "{name}: migrated modal route may emit only native modal or spell IR" + ); + } + assert!( + matches!(&ir.items[0].node, OracleNodeIr::Modal(_)), + "{name}: root must retain standalone modal metadata" + ); + assert!( + ir.items.iter().skip(1).all(|item| matches!( + &item.node, + OracleNodeIr::Spell(ability) + if ability.modal.is_none() + && !matches!( + &ability.body.clauses[0].parsed.effect, + Effect::Unimplemented { .. } + ) + )), + "{name}: every bullet must remain a native, independently lowerable ability" + ); + + let lowered = lower_oracle_ir(&mut ir); + assert_eq!( + lowered.modal.as_ref().map(|choice| choice.mode_count), + Some(expected_mode_count), + "{name}: all source modes must reach modal metadata" + ); + assert_eq!( + lowered.abilities.len(), + expected_mode_count, + "{name}: every source mode must lower exactly once" + ); + assert!( + lowered.abilities.iter().all(|ability| !matches!( + ability.effect.as_ref(), + Effect::Unimplemented { .. } + )), + "{name}: a source-preserving route must still lower each mode" + ); + } + } + + #[test] + fn activated_and_triggered_modal_modes_remain_nested_with_native_provenance() { + const ACTIVATED: &str = "{1}: Choose one —\n• Draw a card.\n• Gain 3 life."; + let mut activated_ir = parse_oracle_ir(ACTIVATED, "Nested Activated", &[], &[], &[]); + assert_eq!(activated_ir.items.len(), 1); + let OracleNodeIr::Spell(activated_item) = &activated_ir.items[0].node else { + panic!("activated modal must stay native spell IR, not pre-lowered"); + }; + let activated_modes = &activated_item + .modal + .as_ref() + .expect("activated header owns native modal payload") + .modes; + assert_eq!( + activated_modes + .iter() + .map(|mode| (mode.source_line, mode.source_text.as_str())) + .collect::>(), + vec![(Some(1), "• Draw a card."), (Some(2), "• Gain 3 life."),], + "nested activated modes retain bullet provenance without independent slots" + ); + assert_eq!(activated_ir.items[0].source.span().first_line, 0); + assert_eq!( + activated_ir.items[0].source.fragment(), + Some("{1}: Choose one —") + ); + let activated = lower_oracle_ir(&mut activated_ir); + assert_eq!(activated.abilities.len(), 1); + assert!(matches!( + activated.abilities[0].mode_abilities.as_slice(), + [first, second] + if matches!(first.effect.as_ref(), Effect::Draw { .. }) + && matches!(second.effect.as_ref(), Effect::GainLife { .. }) + )); + + const TRIGGERED: &str = + "Whenever Nested Trigger enters, choose one —\n• Draw a card.\n• Gain 3 life."; + let mut triggered_ir = parse_oracle_ir(TRIGGERED, "Nested Trigger", &[], &[], &[]); + assert_eq!(triggered_ir.items.len(), 1); + let OracleNodeIr::Trigger(TriggerNodeIr::Parsed(trigger)) = &triggered_ir.items[0].node + else { + panic!("triggered modal must stay parsed trigger IR, not Assembled"); + }; + let Some(TriggerBody::Modal(modal)) = trigger.body.as_ref() else { + panic!("plain triggered modal must retain its native modal body"); + }; + assert_eq!( + modal + .modes + .iter() + .map(|mode| (mode.source_line, mode.source_text.as_str())) + .collect::>(), + vec![(Some(1), "• Draw a card."), (Some(2), "• Gain 3 life."),], + "nested trigger modes retain provenance without independent document slots" + ); + assert_eq!(triggered_ir.items[0].source.span().first_line, 0); + assert_eq!( + triggered_ir.items[0].source.fragment(), + Some("Whenever ~ enters, choose one —") + ); + let triggered = lower_oracle_ir(&mut triggered_ir); + assert_eq!(triggered.triggers.len(), 1); + assert!(matches!( + triggered.triggers[0] + .execute + .as_deref() + .map(|execute| execute.mode_abilities.as_slice()), + Some([first, second]) + if matches!(first.effect.as_ref(), Effect::Draw { .. }) + && matches!(second.effect.as_ref(), Effect::GainLife { .. }) + )); + } + #[test] fn build_modal_choice_preserves_positional_mode_costs_with_zero_for_costless_modes() { let modes = vec![ ModeAst { raw: "Draw a card.".to_string(), + source_text: "Draw a card.".to_string(), + source_line: None, label: None, body: "Draw a card.".to_string(), mode_cost: None, @@ -2608,6 +3272,8 @@ mod tests { }, ModeAst { raw: "Gain 3 life.".to_string(), + source_text: "Gain 3 life.".to_string(), + source_line: None, label: None, body: "Gain 3 life.".to_string(), mode_cost: Some(ManaCost::generic(1)), @@ -2615,6 +3281,8 @@ mod tests { }, ModeAst { raw: "Deal 3 damage.".to_string(), + source_text: "Deal 3 damage.".to_string(), + source_line: None, label: None, body: "Deal 3 damage.".to_string(), mode_cost: Some(ManaCost::generic(2)), @@ -2748,9 +3416,12 @@ mod tests { // ---- Ao, the Dawn Sky (SOC) — modal dies trigger integration ---- - use crate::parser::oracle::parse_oracle_text; + use crate::parser::oracle::{lower_oracle_ir, parse_oracle_ir, parse_oracle_text}; + use crate::parser::oracle_ir::doc::OracleNodeIr; + use crate::parser::oracle_ir::trigger::TriggerNodeIr; use crate::types::ability::{ - ChoiceType, Effect, StaticCondition, TargetFilter, TriggerCondition, + ChoiceType, ControllerRef, Effect, QuantityExpr, QuantityRef, StaticCondition, + TargetFilter, TriggerCondition, }; use crate::types::replacements::ReplacementEvent; use crate::types::triggers::TriggerMode; @@ -2918,6 +3589,353 @@ When The Ruinous Wrecking Crew enters, choose up to X —\n\ assert_eq!(parsed.statics[0].modifications.len(), 4); } + #[test] + fn frostcliff_siege_document_ir_is_native_and_preserves_all_bullet_lines() { + let mut ir = parse_oracle_ir(FROSTCLIFF_SIEGE_ORACLE, "Frostcliff Siege", &[], &[], &[]); + assert_eq!( + ir.items.len(), + 3, + "header replacement plus both anchor modes" + ); + assert!(matches!(&ir.items[0].node, OracleNodeIr::Replacement(_))); + assert!(matches!( + &ir.items[1].node, + OracleNodeIr::Trigger(TriggerNodeIr::Parsed(_)) + )); + assert!(matches!(&ir.items[2].node, OracleNodeIr::Static(_))); + for (line, item) in ir.items.iter().enumerate() { + assert!(item.source.span().is_exact()); + assert_eq!( + (item.source.span().first_line, item.source.span().last_line), + (line, line) + ); + assert_eq!( + item.source.fragment(), + Some(match line { + 0 => "As ~ enters, choose Jeskai or Temur.", + _ => FROSTCLIFF_SIEGE_ORACLE.lines().nth(line).unwrap(), + }) + ); + } + + let lowered = lower_oracle_ir(&mut ir); + assert_eq!(lowered.replacements.len(), 1); + assert_eq!(lowered.triggers.len(), 1); + assert_eq!(lowered.statics.len(), 1); + assert!(matches!( + lowered.triggers[0].condition.as_ref(), + Some(TriggerCondition::ChosenLabelIs { label }) if label == "Jeskai" + )); + assert!(matches!( + lowered.statics[0].condition.as_ref(), + Some(StaticCondition::ChosenLabelIs { label }) if label == "Temur" + )); + } + + #[test] + fn as_enters_anchor_mode_that_has_no_native_grammar_is_an_honest_residual() { + const ORACLE: &str = "As this enchantment enters, choose A or B.\n\ +• A — Whenever Anchor Residual enters, draw a card.\n\ +• B — This deliberately unsupported anchor body."; + let mut ir = parse_oracle_ir(ORACLE, "Anchor Residual", &[], &[], &[]); + assert_eq!( + ir.items.len(), + 3, + "unsupported labeled bullet still owns a slot" + ); + assert!(matches!(&ir.items[0].node, OracleNodeIr::Replacement(_))); + assert!(matches!( + &ir.items[1].node, + OracleNodeIr::Trigger(TriggerNodeIr::Parsed(_)) + )); + let OracleNodeIr::Spell(residual) = &ir.items[2].node else { + panic!("unsupported anchor bullet must not disappear or pre-lower"); + }; + assert!(matches!( + &residual.body.clauses[0].parsed.effect, + Effect::Unimplemented { .. } + )); + assert_eq!( + ir.items[2].source.fragment(), + Some("• B — This deliberately unsupported anchor body.") + ); + + let lowered = lower_oracle_ir(&mut ir); + assert_eq!(lowered.abilities.len(), 1); + assert!(matches!( + lowered.abilities[0].effect.as_ref(), + Effect::Unimplemented { .. } + )); + } + + #[test] + fn triggered_modal_spell_pronoun_uses_complete_trigger_body_context() { + const ORACLE: &str = "Whenever you cast a spell, choose one —\n\ +• Return it to its owner's hand.\n\ +• Draw a card."; + let mut ir = parse_oracle_ir(ORACLE, "Spell Context Witness", &[], &[], &[]); + assert!(matches!( + ir.items.as_slice(), + [item] if matches!(&item.node, OracleNodeIr::Trigger(TriggerNodeIr::Parsed(_))) + )); + + let lowered = lower_oracle_ir(&mut ir); + let first_mode = &lowered.triggers[0] + .execute + .as_ref() + .expect("triggered modal execute") + .mode_abilities[0]; + match first_mode.effect.as_ref() { + Effect::Bounce { target, .. } => assert_eq!( + target, + &TargetFilter::TriggeringSource, + "spell-cast 'it' must remain the triggering spell, not a parent target" + ), + other => panic!("expected spell-return mode, got {other:?}"), + } + } + + #[test] + fn modal_siblings_start_from_a_complete_fresh_context() { + const SCRY_ORACLE: &str = "When you choose to put two or more cards on the bottom of your library while scrying, choose one —\n\ +• Exile that many cards from the bottom of your library.\n\ +• Draw a card."; + let mut scry_ir = parse_oracle_ir(SCRY_ORACLE, "Scry Context Witness", &[], &[], &[]); + let OracleNodeIr::Trigger(TriggerNodeIr::Parsed(trigger)) = &scry_ir.items[0].node else { + panic!("completed-scry modal must retain parsed trigger IR"); + }; + let Some(TriggerBody::Modal(modal)) = trigger.body.as_ref() else { + panic!("completed-scry modal must retain its nested mode payload"); + }; + assert!(matches!( + &modal.modes[0].ability.body.clauses[0].parsed.effect, + Effect::ExileTop { + count: QuantityExpr::Ref { + qty: QuantityRef::TriggeringScryBottomCount, + }, + .. + } + )); + assert!(matches!( + &modal.modes[1].ability.body.clauses[0].parsed.effect, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + } + )); + let scry_lowered = lower_oracle_ir(&mut scry_ir); + assert!(matches!( + scry_lowered.triggers[0] + .execute + .as_ref() + .expect("completed-scry modal execute") + .mode_abilities[0] + .effect + .as_ref(), + Effect::ExileTop { + count: QuantityExpr::Ref { + qty: QuantityRef::TriggeringScryBottomCount, + }, + .. + } + )); + + const DAMAGE_ORACLE: &str = + "Whenever Context Witness deals combat damage to a player, choose one —\n\ +• Draw a card.\n\ +• Destroy target creature that player controls."; + let mut damage_ir = parse_oracle_ir(DAMAGE_ORACLE, "Context Witness", &[], &[], &[]); + let OracleNodeIr::Trigger(TriggerNodeIr::Parsed(trigger)) = &damage_ir.items[0].node else { + panic!("damage modal must retain parsed trigger IR"); + }; + let Some(TriggerBody::Modal(modal)) = trigger.body.as_ref() else { + panic!("damage modal must retain nested mode payload"); + }; + let mut opponent_controlled_object_trigger = trigger.clone(); + opponent_controlled_object_trigger.partial_def.valid_target = Some(TargetFilter::Typed( + TypedFilter::creature().controller(ControllerRef::Opponent), + )); + assert_eq!( + modal_relative_player_scope_for_trigger(&opponent_controlled_object_trigger), + None, + "an opponent-controlled object is not the damaged player" + ); + assert!(matches!( + &modal.modes[0].ability.body.clauses[0].parsed.effect, + Effect::Draw { + target: TargetFilter::Controller, + .. + } + )); + assert!(matches!( + &modal.modes[1].ability.body.clauses[0].parsed.effect, + Effect::Destroy { + target: TargetFilter::Typed(filter), + .. + } if filter.controller == Some(ControllerRef::TriggeringPlayer) + )); + let damage_lowered = lower_oracle_ir(&mut damage_ir); + assert!(matches!( + damage_lowered.triggers[0] + .execute + .as_ref() + .expect("damage modal execute") + .mode_abilities[1] + .effect + .as_ref(), + Effect::Destroy { + target: TargetFilter::Typed(filter), + .. + } if filter.controller == Some(ControllerRef::TriggeringPlayer) + )); + + const NONCOMBAT_DAMAGE_ORACLE: &str = + "Whenever Context Witness deals noncombat damage to an opponent, choose one —\n\ +• Draw a card.\n\ +• Destroy target creature that player controls."; + const THRESHOLD_DAMAGE_ORACLE: &str = + "Whenever Context Witness deals combat 3 or more damage to a player, choose one —\n\ +• Draw a card.\n\ +• Destroy target creature that player controls."; + for oracle in [NONCOMBAT_DAMAGE_ORACLE, THRESHOLD_DAMAGE_ORACLE] { + let mut ir = parse_oracle_ir(oracle, "Context Witness", &[], &[], &[]); + let OracleNodeIr::Trigger(TriggerNodeIr::Parsed(trigger)) = &ir.items[0].node else { + panic!("damage modal sibling must retain parsed trigger IR"); + }; + let Some(TriggerBody::Modal(modal)) = trigger.body.as_ref() else { + panic!("damage modal sibling must retain nested mode payload"); + }; + assert!(matches!( + &modal.modes[1].ability.body.clauses[0].parsed.effect, + Effect::Destroy { + target: TargetFilter::Typed(filter), + .. + } if filter.controller == Some(ControllerRef::TriggeringPlayer) + )); + let lowered = lower_oracle_ir(&mut ir); + assert!(matches!( + lowered.triggers[0] + .execute + .as_ref() + .expect("damage modal sibling execute") + .mode_abilities[1] + .effect + .as_ref(), + Effect::Destroy { + target: TargetFilter::Typed(filter), + .. + } if filter.controller == Some(ControllerRef::TriggeringPlayer) + )); + } + + const NON_SELF_DAMAGE_ORACLE: &str = + "Whenever a creature deals combat damage to a player, choose one —\n\ +• Draw a card.\n\ +• Destroy target creature that player controls."; + let mut non_self_damage_ir = + parse_oracle_ir(NON_SELF_DAMAGE_ORACLE, "Context Witness", &[], &[], &[]); + let OracleNodeIr::Trigger(TriggerNodeIr::Parsed(non_self_trigger)) = + &non_self_damage_ir.items[0].node + else { + panic!("non-self damage modal must retain parsed trigger IR"); + }; + assert!(matches!( + &non_self_trigger.partial_def.valid_source, + Some(TargetFilter::Typed(_)) + )); + assert_eq!( + modal_relative_player_scope_for_trigger(non_self_trigger), + None, + "non-self damage triggers keep their established context" + ); + let Some(TriggerBody::Modal(non_self_modal)) = non_self_trigger.body.as_ref() else { + panic!("non-self damage modal must retain nested mode payload"); + }; + assert!(matches!( + &non_self_modal.modes[1].ability.body.clauses[0].parsed.effect, + Effect::Destroy { + target: TargetFilter::Typed(filter), + .. + } if filter.controller == Some(ControllerRef::TriggeringPlayer) + )); + let non_self_lowered = lower_oracle_ir(&mut non_self_damage_ir); + assert!(matches!( + non_self_lowered.triggers[0] + .execute + .as_ref() + .expect("non-self damage modal execute") + .mode_abilities[1] + .effect + .as_ref(), + Effect::Destroy { + target: TargetFilter::Typed(filter), + .. + } if filter.controller == Some(ControllerRef::TriggeringPlayer) + )); + + const CAST_ORACLE: &str = "Whenever you cast a spell, choose one —\n\ +• Return it to its owner's hand.\n\ +• Exile it."; + let mut cast_ir = parse_oracle_ir(CAST_ORACLE, "Cast Context Witness", &[], &[], &[]); + let cast_lowered = lower_oracle_ir(&mut cast_ir); + let cast_modes = &cast_lowered.triggers[0] + .execute + .as_ref() + .expect("spell-cast modal execute") + .mode_abilities; + assert!(matches!( + cast_modes.as_slice(), + [first, second] + if matches!(first.effect.as_ref(), Effect::Bounce { + target: TargetFilter::TriggeringSource, + .. + }) && matches!(second.effect.as_ref(), Effect::ChangeZone { + target: TargetFilter::TriggeringSource, + .. + }) + )); + + const ACTOR_ORACLE: &str = "Choose one —\n\ +• You may sacrifice a creature.\n\ +• Any opponent may sacrifice a creature of their choice."; + let mut actor_ir = parse_oracle_ir(ACTOR_ORACLE, "Actor Context Witness", &[], &[], &[]); + assert!(matches!( + &actor_ir.items[1].node, + OracleNodeIr::Spell(ability) + if matches!( + &ability.body.clauses[0].parsed.effect, + Effect::Sacrifice { + target: TargetFilter::Typed(filter), + .. + } if filter.controller == Some(ControllerRef::You) + ) + )); + assert!(matches!( + &actor_ir.items[2].node, + OracleNodeIr::Spell(ability) + if matches!( + &ability.body.clauses[0].parsed.effect, + Effect::Sacrifice { + target: TargetFilter::Typed(filter), + .. + } if filter.controller == Some(ControllerRef::Opponent) + ) + )); + let actor_lowered = lower_oracle_ir(&mut actor_ir); + assert!(matches!( + actor_lowered.abilities.as_slice(), + [first, second] + if matches!(first.effect.as_ref(), Effect::Sacrifice { + target: TargetFilter::Typed(filter), + .. + } if filter.controller == Some(ControllerRef::You)) + && matches!(second.effect.as_ref(), Effect::Sacrifice { + target: TargetFilter::Typed(filter), + .. + } if filter.controller == Some(ControllerRef::Opponent)) + )); + } + // ---- Final Act (SOC / M3C) — "Choose one or more —" modal spell ---- const FINAL_ACT_ORACLE: &str = "Choose one or more —\n\ @@ -3222,6 +4240,88 @@ When The Ruinous Wrecking Crew enters, choose up to X —\n\ ); } + #[test] + fn caesar_reflexive_modal_is_native_before_lowering() { + let mut ir = parse_oracle_ir( + CAESAR_ORACLE, + "Caesar, Legion's Emperor", + &[], + &["Legendary".to_string(), "Creature".to_string()], + &["Human".to_string(), "Soldier".to_string()], + ); + let [item] = ir.items.as_slice() else { + panic!("Caesar's block must remain one nested trigger document item"); + }; + let OracleNodeIr::Trigger(TriggerNodeIr::Parsed(trigger)) = &item.node else { + panic!("Caesar must not escape through a pre-lowered trigger node"); + }; + assert_eq!(item.source.span().first_line, 0); + assert_eq!( + item.source.fragment(), + Some("Whenever you attack, you may sacrifice another creature. When you do, choose two —") + ); + let Some(TriggerBody::ReflexivePayment(reflexive)) = trigger.body.as_ref() else { + panic!("Caesar must retain its native reflexive-payment trigger body"); + }; + let modal = reflexive + .modal + .as_ref() + .expect("Caesar's reflexive payment owns the native modal payload"); + assert_eq!( + ( + modal.choice.min_choices, + modal.choice.max_choices, + modal.choice.mode_count + ), + (2, 2, 3), + "Caesar preserves its choose-two-of-three gate before lowering" + ); + assert_eq!( + modal + .modes + .iter() + .map(|mode| (mode.source_line, mode.source_text.as_str())) + .collect::>(), + vec![ + (Some(1), "• Create two 1/1 red and white Soldier creature tokens with haste that are tapped and attacking."), + (Some(2), "• You draw a card and you lose 1 life."), + (Some(3), "• ~ deals damage equal to the number of creature tokens you control to target opponent."), + ], + "Caesar's bullet provenance stays nested under the sole trigger item" + ); + assert!( + modal.modes.iter().all(|mode| !matches!( + &mode.ability.body.clauses[0].parsed.effect, + Effect::Unimplemented { .. } + )), + "every native Caesar mode must remain lowerable" + ); + + let lowered = lower_oracle_ir(&mut ir); + assert!( + lowered.abilities.is_empty(), + "nested Caesar bullets must not become standalone spell abilities" + ); + let payment = lowered.triggers[0] + .execute + .as_deref() + .expect("Caesar trigger has its optional payment"); + assert!(payment.optional, "Caesar's sacrifice payment is optional"); + assert!(matches!(payment.effect.as_ref(), Effect::Sacrifice { .. })); + let gated_modal = payment + .sub_ability + .as_deref() + .expect("payment must gate the modal under its result"); + assert_eq!(gated_modal.condition, Some(AbilityCondition::WhenYouDo)); + assert!(matches!( + gated_modal.mode_abilities.as_slice(), + [first, second, third] + if matches!(first.effect.as_ref(), Effect::Token { .. }) + && matches!(second.effect.as_ref(), Effect::Draw { .. }) + && matches!(third.effect.as_ref(), Effect::DealDamage { .. }) + )); + } + // ---- Vincent's Limit Break (FIN) — Tiered shared-effect chosen-P/T ---- const VINCENTS_ORACLE: &str = "Tiered (Choose one additional cost.)\n\ diff --git a/crates/engine/src/parser/oracle_special.rs b/crates/engine/src/parser/oracle_special.rs index 895377a3ee..ec9f033166 100644 --- a/crates/engine/src/parser/oracle_special.rs +++ b/crates/engine/src/parser/oracle_special.rs @@ -301,6 +301,7 @@ pub(super) fn try_parse_die_roll_table( ..AbilityShellIr::default() }, die_results: branches, + modal: None, root_transforms: vec![], }; Some((lower_ability_ir(&ir), next_line)) diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index 826db7e2fa..5eaec306c7 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -1314,6 +1314,7 @@ pub(crate) fn parse_trigger_line_with_index_ir( // source name; the gate carried the partner name). pending_meld_partner: meld_partner, pending_mana_symbol_count_color, + actor: ctx.actor.clone(), object_pronoun_ref: trigger_object_pronoun_ref_for_condition(condition_text) .or_else(|| trigger_object_pronoun_ref_for_intervening_if(&if_condition)), in_trigger: true, @@ -1335,6 +1336,10 @@ pub(crate) fn parse_trigger_line_with_index_ir( if parse_completed_scry_bottom_condition(&cond_lower).is_some() { effect_ctx.quantity_ref = Some(QuantityRef::TriggeringScryBottomCount); } + // Keep the condition-established context intact for nested parser payloads. + // The body parser mutates its working context while it walks clauses, whereas + // each nested modal mode must begin from the same trigger-level facts. + let body_context = effect_ctx.clone(); // Snapshot the condition-established scope before body parsing (which may // temporarily rebind it via `with_player_scope`) so lowering sees the scope // the condition introduced, not a transient nested-clause value. @@ -1355,7 +1360,12 @@ pub(crate) fn parse_trigger_line_with_index_ir( let effect_chain = parse_effect_chain_ir(&reflexive_effect_text, AbilityKind::Spell, &mut effect_ctx); Some(TriggerBody::ReflexivePayment(Box::new( - ReflexivePaymentIr { cost, effect_chain }, + ReflexivePaymentIr { + cost, + effect_chain, + payment_chain: None, + modal: None, + }, ))) } else if is_unsupported_disjunctive_reflexive_optional_payment(&effect_for_parse) { Some(TriggerBody::EffectChain(EffectChainIr::single_clause( @@ -1478,6 +1488,7 @@ pub(crate) fn parse_trigger_line_with_index_ir( }, source_text: text.to_string(), die_results: Vec::new(), + body_context, } } @@ -1610,21 +1621,41 @@ pub(crate) fn lower_trigger_ir(ir: &TriggerIr) -> TriggerDefinition { lower_trigger_effect_chain(&reflexive.effect_chain, modifiers, &ir.die_results); reflexive_ability.condition = Some(AbilityCondition::WhenYouDo); - let mut pay_ability = AbilityDefinition::new( - AbilityKind::Spell, - Effect::PayCost { - cost: reflexive.cost.clone(), - scale: None, - payer: TargetFilter::Controller, - }, - ); + if let Some(modal) = &reflexive.modal { + reflexive_ability = reflexive_ability.with_modal( + modal.choice.clone(), + modal + .modes + .iter() + .map(|mode| crate::parser::oracle_effect::lower_ability_ir(&mode.ability)) + .collect(), + ); + } + + let mut pay_ability = match &reflexive.payment_chain { + Some(chain) => lower_trigger_effect_chain(chain, modifiers, &[]), + None => AbilityDefinition::new( + AbilityKind::Spell, + Effect::PayCost { + cost: reflexive.cost.clone(), + scale: None, + payer: TargetFilter::Controller, + }, + ), + }; pay_ability.optional = true; pay_ability.sub_ability = Some(Box::new(reflexive_ability)); Some(Box::new(pay_ability)) } Some(TriggerBody::Modal(modal)) => Some(Box::new( - lower_trigger_effect_chain(&modal.marker, modifiers, &[]) - .with_modal(modal.choice.clone(), modal.mode_abilities.clone()), + lower_trigger_effect_chain(&modal.marker, modifiers, &[]).with_modal( + modal.choice.clone(), + modal + .modes + .iter() + .map(|mode| crate::parser::oracle_effect::lower_ability_ir(&mode.ability)) + .collect(), + ), )), Some(TriggerBody::Vote(vote)) => Some(Box::new(lower_trigger_effect_chain( &vote.effect_chain(AbilityKind::Spell), diff --git a/scripts/prelowered-ratchet.txt b/scripts/prelowered-ratchet.txt index dbc47c3c2c..6f92f16d2c 100644 --- a/scripts/prelowered-ratchet.txt +++ b/scripts/prelowered-ratchet.txt @@ -12,16 +12,16 @@ # real IR nodes. Every tranche that converts a producer must lower its number # in the same commit, which is what makes the burn-down visible in `git log` # on this one file. -crates/engine/src/parser/oracle.rs 12 +crates/engine/src/parser/oracle.rs 5 crates/engine/src/parser/oracle_class.rs 3 # --- infrastructure: NOT burn-down targets ---------------------------------- # The variant declarations and their consumers. These legitimately survive # until the last producer is gone and the variants themselves are deleted, so # they are ceilings only — they are not expected to reach 0 before then. -# 20 includes spell_payload's exhaustive consumer arms, not producers. -crates/engine/src/parser/oracle_ir/doc.rs 20 -crates/engine/src/parser/oracle_ir/feature.rs 9 +# 12 includes spell_payload's exhaustive consumer arms, not producers. +crates/engine/src/parser/oracle_ir/doc.rs 12 +crates/engine/src/parser/oracle_ir/feature.rs 7 # Prose references in the T1 witness-corpus comment. crates/engine/src/parser/oracle_ir/snapshot_tests.rs 2 From e3fc63be129690c0ff0fb0d12fc3ebb5e66b8438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D1=96=CC=81=D0=B9=20=D0=9F=D0=BE?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=D1=81=D0=BA=D1=96?= <54000164+andriypolanski@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:14:04 -0700 Subject: [PATCH 46/63] fix(engine): auto-reattach Gift of Immortality at next end step (#6808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(engine): auto-reattach Gift of Immortality at next end step (#4956) * fix: issue_4956 tests + attach-host unit tests pass * fix: 12 issue_4956 tests + attach-host unit tests pass * fix: Cass/Storm remainder checks in sequence.rs are exact assert_eq! strings — combinator gate passes * fix(engine): clear #6808 blockers for Cass/Necrotic reattach pipelines Propagate the chosen Attach host when a forward_result ChangeZone returns zero cards so Cass Equipment ParentTarget still resolves, refresh SelfRef at stack resolve after Aura SBAs, keep AttachedTo dies triggers on the battlefield, and drive Cass/Necrotic through real target selection without stamp_host. Also clear the Clippy field_reassign_with_default lint. Co-authored-by: Cursor * fix(engine): restore LKI dies-trigger currency without ExactLive refresh Reverting the blanket stack SelfRef refresh — rewriting trigger_source to the post-SBA GY object flipped source_read to ExactLive and broke Persist, Modular, attachment-count LKI, and related dies triggers. Necrotic Plague instead gains a narrow self_ref_is_current path for battlefield-latched SelfRef returns from the graveyard. Completed empty zone moves also publish a fresh empty tracked set (without mid-pause Storm Herald binding). Co-authored-by: Cursor * fix(engine): restore empty tracked-set publish and Iron Man attach host Remove the empty early-return from publish_tracked_set so completed no-op producers still allocate a fresh empty chain set, while mid-chain empty extends leave prior members intact (Beseech). ParentTarget Attach under forward_result falls back to the ability source when no explicit host was chosen (Iron Man / Armored Skyhunter) without overwriting a bound choice. Co-authored-by: Cursor * fix(engine): harden SelfRef post-SBA provenance and terminal empty tracked sets Require a matching BF→GY zone-change identity with no later same-id move for post-SBA SelfRef graveyard returns, and publish a fresh empty chain set on terminal EffectZoneChoice completion while keeping mid-pause empty skips. Co-authored-by: Cursor * fix(engine): publish empty tracked set on EffectZoneChoice zero-choice path The empty SelectCards early-return skipped terminal tracked-set publish, so up_to zero-choice could leave a prior non-empty chain set for TrackedSet consumers. Rebind before resume and cover it via handle_resolution_choice. Co-authored-by: Cursor * fix(PR-6808): cite Aura leaves-battlefield rule --------- Co-authored-by: Cursor Co-authored-by: matthewevans --- crates/engine/src/game/effects/change_zone.rs | 18 + .../src/game/effects/delayed_trigger.rs | 32 +- crates/engine/src/game/effects/mod.rs | 203 ++- .../src/game/engine_resolution_choices.rs | 281 +++- crates/engine/src/game/zone_pipeline.rs | 3 + .../src/parser/oracle_effect/assembly.rs | 72 +- .../src/parser/oracle_effect/imperative.rs | 136 +- .../engine/src/parser/oracle_effect/lower.rs | 3 + crates/engine/src/parser/oracle_effect/mod.rs | 96 +- .../src/parser/oracle_effect/sequence.rs | 59 +- crates/engine/src/parser/oracle_ir/ast.rs | 6 + crates/engine/src/parser/oracle_trigger.rs | 21 +- crates/engine/src/parser/oracle_util.rs | 4 + crates/engine/src/types/ability.rs | 195 +++ ...issue_4956_gift_of_immortality_reattach.rs | 1356 +++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 16 files changed, 2409 insertions(+), 77 deletions(-) create mode 100644 crates/engine/tests/integration/issue_4956_gift_of_immortality_reattach.rs diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index 1390e2e31f..bc362ef627 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -63,12 +63,30 @@ fn resolve_forward_result_search_attach_host( let targets = forward_result_attach_host_targets(ability); match target { TargetFilter::SelfRef => Some(AttachTarget::Object(ability.source_id)), + // CR 303.4b + CR 109.4: "attached to you" (Lynde, Cheerful Tormentor) + // binds the Aura host to the resolving ability's controller. + TargetFilter::Controller => Some(AttachTarget::Player(ability.controller)), TargetFilter::ParentTarget => targets .first() .map(|target| match target { TargetRef::Object(id) => AttachTarget::Object(*id), TargetRef::Player(id) => AttachTarget::Player(*id), }) + .or_else(|| { + // CR 603.2 + CR 608.2c + CR 701.3a: Event-subject return Auras + // ("return this … attached to that creature" — Dragon Breath, + // Smoke Shroud) bind ParentTarget to the trigger-event referent + // (entering creature), not the Aura's prior host. + crate::game::targeting::resolve_event_context_target( + state, + &TargetFilter::ParentTarget, + ability.source_id, + ) + .map(|target| match target { + TargetRef::Object(id) => AttachTarget::Object(id), + TargetRef::Player(id) => AttachTarget::Player(id), + }) + }) .or_else(|| { // CR 303.4b + CR 608.2c: Aura search-put "attached to that/enchanted // player" binds ParentTarget to the source's enchanted host when no diff --git a/crates/engine/src/game/effects/delayed_trigger.rs b/crates/engine/src/game/effects/delayed_trigger.rs index 7b70e2ae6a..aae9b21cc5 100644 --- a/crates/engine/src/game/effects/delayed_trigger.rs +++ b/crates/engine/src/game/effects/delayed_trigger.rs @@ -44,8 +44,19 @@ pub fn resolve( // chosen object. This ordering is mandatory: running the contextual bind // first would pre-empt the tracked-set rewrite and break the "those cards" // cards. + // CR 603.7: Prefer the active nonempty resolution-chain set, then the latest + // nonempty published set. An empty chain id (stale pre-choice publish) must + // not shadow a later nonempty set (Storm Herald delayed exile). let tracked_set_id = if uses_tracked_set { - crate::game::targeting::latest_tracked_set_id(state) + state + .chain_tracked_set_id + .filter(|id| { + state + .tracked_object_sets + .get(id) + .is_some_and(|objects| !objects.is_empty()) + }) + .or_else(|| crate::game::targeting::latest_tracked_set_id(state)) } else { None }; @@ -127,7 +138,7 @@ pub fn resolve( ) .map(|t| vec![t]) .unwrap_or_default() - } else if super::effect_refs_parent_target(&delayed_ability.effect) { + } else if super::ability_refs_parent_target(&delayed_ability) { parent_target_snapshot(state, ability) } else if effect_references_last_created(&delayed_ability.effect) && !state.last_created_token_ids.is_empty() @@ -170,13 +181,28 @@ pub fn resolve( // and activated-ability sources may not already carry trigger provenance; // capture their current incarnation at creation rather than later rebinding // the stored ObjectId. CR 400.7. + // + // CR 400.7 + CR 603.7c: when the delayed ability's source is still the same + // ObjectId, refresh zone + incarnation to that object's creation-time + // location. The parent trigger often latched while the source was on the + // battlefield (Gift of Immortality's dies trigger), but SBAs may already + // have moved that Aura to the graveyard (bumping incarnation) before the + // delayed return is created. SelfRef resolution requires a zone+incarnation + // match (`source_is_current_via_zone_match`); keeping the BF/pre-move stamp + // would make the end-step SelfRef ChangeZone no-op. let source_context = ability.trigger_source.clone().or_else(|| { state .objects .get(&ability.source_id) .map(|source| super::super::triggers::trigger_source_context_for_latch(state, source)) }); - if let Some(source_context) = source_context { + if let Some(mut source_context) = source_context { + if source_context.identity.reference.object_id == delayed_ability.source_id { + if let Some(obj) = state.objects.get(&delayed_ability.source_id) { + source_context.identity.expected_zone = obj.zone; + source_context.identity.reference.incarnation = obj.incarnation; + } + } delayed_ability.set_trigger_source_recursive(source_context); } diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 9961801baf..6272dcbcfd 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -2479,7 +2479,12 @@ fn apply_parent_chain_context( fn should_propagate_parent_targets(ability: &ResolvedAbility, sub: &ResolvedAbility) -> bool { sub.targets.is_empty() && !ability.targets.is_empty() - && sub.target_choice_timing != TargetChoiceTiming::Resolution + && (sub.target_choice_timing != TargetChoiceTiming::Resolution + // CR 608.2c + CR 303.4f: TargetOnly → ChangeZone[+Attach ParentTarget] + // (Necrotic Plague) stamps Resolution on the return clause, but the + // chosen host is still the parent's bound target — propagate it so + // nested Attach does not fall through to the trigger-event referent. + || change_zone_forwards_chosen_attach_host(sub)) && !(sub .effect .target_filter() @@ -2487,6 +2492,26 @@ fn should_propagate_parent_targets(ability: &ResolvedAbility, sub: &ResolvedAbil && !effect_refs_parent_target(&sub.effect)) } +/// CR 701.3a + CR 303.4f: `forward_result` ChangeZone nesting Attach→ParentTarget +/// carries the parent's chosen host (not a fresh Resolution-time object pick). +fn change_zone_forwards_chosen_attach_host(sub: &ResolvedAbility) -> bool { + sub.forward_result + && matches!( + &sub.effect, + Effect::ChangeZone { + destination: Zone::Battlefield, + .. + } + ) + && matches!( + sub.sub_ability.as_deref().map(|s| &s.effect), + Some(Effect::Attach { + target: TargetFilter::ParentTarget, + .. + }) + ) +} + fn waits_for_resolution_choice(waiting_for: &WaitingFor) -> bool { matches!( waiting_for, @@ -5279,6 +5304,14 @@ pub(crate) fn publish_tracked_set(state: &mut GameState, affected_ids: Vec bool { .is_some_and(ability_refs_triggering_source) } +/// True when any effect in the ability chain references `ParentTarget` +/// (including nested sub/else abilities). Used by delayed-trigger snapshotting +/// so an Attach host on a ChangeZone sub-chain (Gift of Immortality #4956) still +/// freezes the parent referent at creation time. +fn ability_refs_parent_target(ability: &ResolvedAbility) -> bool { + effect_refs_parent_target(&ability.effect) + || ability + .sub_ability + .as_deref() + .is_some_and(ability_refs_parent_target) + || ability + .else_ability + .as_deref() + .is_some_and(ability_refs_parent_target) +} + /// CR 603.7 + CR 109.5: Replace the first `TargetRef::Object` in a target /// slice with the supplied object id. Used by the `repeat_for: TrackedSetSize` /// per-iteration rebind so the i-th iteration's parent reference (e.g., @@ -6472,26 +6521,86 @@ fn filter_refs_post_replacement_event_target(filter: &TargetFilter) -> bool { /// effect carries an event-context recipient (`TriggeringPlayer`, etc.), bind /// that referent into `targets` before payer/effect resolution. Shared by the /// unless-pay interceptor and the main effect path (issue #2361). +/// +/// CR 603.2 + CR 701.3a + CR 303.4f: Event-subject return Auras nest +/// `Attach { target: ParentTarget }` under a `forward_result` ChangeZone whose +/// own target is `SelfRef` (Dragon Breath, Smoke Shroud). Hydrate the nested +/// host onto the Attach sub so zone entry binds the trigger-event creature +/// instead of falling through to CR 303.4f Aura choice. +/// +/// Do **not** inject an event referent when the ability chain already carries a +/// bound parent target (Necrotic Plague: controller's chosen creature wins over +/// the creature that died). `forward_result_attach_host_targets` prefers a +/// filled Attach sub over `ability.targets`, so an eager hydrate would overwrite +/// the explicit choice. fn hydrate_event_context_targets<'a>( state: &GameState, ability: &'a ResolvedAbility, ) -> Cow<'a, ResolvedAbility> { - if !ability.targets.is_empty() { - return Cow::Borrowed(ability); - } - let Some(filter) = extract_event_context_filter(&ability.effect) else { - return Cow::Borrowed(ability); + let root_ref = if ability.targets.is_empty() { + extract_event_context_filter(&ability.effect).and_then(|filter| { + crate::game::targeting::resolve_event_context_target(state, filter, ability.source_id) + }) + } else { + None }; - let Some(target_ref) = - crate::game::targeting::resolve_event_context_target(state, filter, ability.source_id) - else { - return Cow::Borrowed(ability); + // Only hydrate an event-context Attach host when the chain has no bound + // parent target yet — an explicit/snapshotted choice must stand. + let nested_host_ref = if ability.targets.is_empty() { + nested_forward_result_attach_parent_target_ref(state, ability) + } else { + None }; + + if root_ref.is_none() && nested_host_ref.is_none() { + return Cow::Borrowed(ability); + } + let mut resolved = ability.clone(); - resolved.targets = vec![target_ref]; + if let Some(target_ref) = root_ref { + resolved.targets = vec![target_ref]; + } + if let Some(host_ref) = nested_host_ref { + if let Some(sub) = resolved.sub_ability.as_mut() { + sub.targets = vec![host_ref]; + } + } Cow::Owned(resolved) } +/// CR 603.2 + CR 701.3a: Resolve `ParentTarget` for a nested forward-result +/// Attach host when the chain still has no bound parent target. +fn nested_forward_result_attach_parent_target_ref( + state: &GameState, + ability: &ResolvedAbility, +) -> Option { + if !ability.forward_result { + return None; + } + // CR 608.2c + CR 303.4f: A chosen/snapshotted parent target on the ability + // (Necrotic Plague's "target creature one of their opponents controls") must + // not be overwritten by the trigger-event referent (the creature that died). + if !ability.targets.is_empty() { + return None; + } + let sub = ability.sub_ability.as_ref()?; + if !sub.targets.is_empty() { + return None; + } + let Effect::Attach { + target: TargetFilter::ParentTarget, + .. + } = &sub.effect + else { + return None; + }; + crate::game::targeting::resolve_event_context_target( + state, + &TargetFilter::ParentTarget, + ability.source_id, + ) +} + /// CR 603.2: Filters that auto-resolve from `state.current_trigger_event` during /// hydration / unless-pay payer resolution (issue #2361, Kain #1335). fn hydratable_event_context_filter(filter: &TargetFilter) -> bool { @@ -10292,15 +10401,58 @@ fn resolve_chain_body( // isn't an implicit tracked-set consumer), prepend the moved // card as a target so `ParentTarget` consumers downstream // resolve to it. - if !forwarded_objects.is_empty() { + // CR 303.4g + CR 303.4i (#4956): `forward_result` Attach is realized via + // `enter_attached_to` on the zone move. If that move Remained, there is + // no ZoneChanged event — running Attach would stamp `attached_to` onto + // an Aura that never left its origin zone. + if ability.forward_result + && forwarded_objects.is_empty() + && matches!( + ability.effect, + Effect::ChangeZone { + destination: Zone::Battlefield, + .. + } + ) + && matches!(sub.effect, Effect::Attach { .. }) + { + // CR 303.4g + CR 608.2c: Zero cards returned — skip the Attach that + // would have bound SelfRef attachments on entry, but still run + // trailing instructions (Cass: "then attach any number of Equipment + // … to that creature"). The skipped Attach node holds the chosen + // host target; ParentTarget on the trailing Attach must inherit it. + if let Some(trailing) = sub.sub_ability.as_ref() { + let mut trailing_resolved = trailing.as_ref().clone(); + if should_propagate_parent_targets(sub, &trailing_resolved) { + trailing_resolved.targets = sub.targets.clone(); + } else if should_propagate_parent_targets(ability, &trailing_resolved) { + trailing_resolved.targets = ability.targets.clone(); + } + apply_parent_chain_context( + &mut trailing_resolved, + ability, + effect_context_object.as_ref(), + state, + ); + resolve_ability_chain(state, &trailing_resolved, events, depth + 1)?; + } + } else if !forwarded_objects.is_empty() { let mut sub_with_context = sub.as_ref().clone(); // CR 707.10: `CopySpell { SelfRef }` copies the resolving spell // itself (Sevinne's Reclamation, Chain cycle). `forward_result` // rebinding `source_id` to the just-moved permanent would make // `copy_spell::resolve` look up the wrong stack entry after // `resolve_top` has popped the spell (issue #2860). + // + // CR 603.7c + CR 201.5: `CreateDelayedTrigger` must keep the + // creating ability's source. SelfRef inside the delayed body means + // "this card" (Gift of Immortality #4956), not the just-returned + // host. ParentTarget anaphora still receive the moved object via + // `targets` below (snapshot at delayed-trigger creation). if !copy_spell_self_ref_keeps_resolving_spell_source(sub) { - sub_with_context.source_id = forwarded_objects[0]; + if !matches!(sub.effect, Effect::CreateDelayedTrigger { .. }) { + sub_with_context.source_id = forwarded_objects[0]; + } if matches!( ability.effect, Effect::Conjure { @@ -10325,7 +10477,30 @@ fn resolve_chain_body( .. } ); - if !attach_target_is_last_created + // CR 608.2c: ParentTarget hosts inherit an explicit parent + // choice when present (Necrotic Plague). When none was chosen, + // fall back to the ability source as host — CR 301.5b + + // CR 701.3a put→attach-to-~ (Iron Man, Armored Skyhunter). + // Do not append the source on top of an already-bound host: + // that would let ParentTarget resolve to the returned Aura. + let attach_target_is_parent = matches!( + &sub.effect, + Effect::Attach { + target: TargetFilter::ParentTarget, + .. + } + ); + if attach_target_is_parent { + if sub_with_context.targets.is_empty() { + if !ability.targets.is_empty() { + sub_with_context.targets = ability.targets.clone(); + } else { + sub_with_context + .targets + .push(TargetRef::Object(ability.source_id)); + } + } + } else if !attach_target_is_last_created && !sub_with_context .targets .iter() diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 11613e3db6..053c6931eb 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -4669,6 +4669,31 @@ pub(super) fn handle_resolution_choice( // Issue #423 audit: no cards chosen — this branch moves no // objects and emits no battlefield-exit events, so no // dies-trigger collection is needed. + // + // CR 603.7: Terminal empty `up_to` must still rebind a fresh + // empty chain tracked set before the continuation drains, or a + // following `TargetFilter::TrackedSet` can observe a prior + // non-empty set. Mid-pause empty publishes stay skipped at the + // NeedsAura / NeedsChoice call sites (`mid_pause: true`). + if matches!( + effect_kind, + EffectKind::Sacrifice + | EffectKind::ChangeZone + | EffectKind::BounceAll + | EffectKind::Tap + | EffectKind::Untap + | EffectKind::PutAtLibraryPosition + | EffectKind::CastFromZone + ) && state.active_ability_continuation().is_some() + { + publish_effect_zone_choice_tracked_set( + state, + effect_kind, + &[], + library_position, + false, + ); + } state.last_effect_count = Some(0); events.push(GameEvent::EffectResolved { kind: effect_kind, @@ -4829,6 +4854,17 @@ pub(super) fn handle_resolution_choice( } } crate::game::zone_pipeline::ZoneMoveTerminalResult::NeedsAuraAttachmentChoice => { + // CR 608.2c + CR 603.7 + CR 303.4f: Publish the + // selection before pausing for Aura host choice — + // this early return skips the terminal publish + // below (Storm Herald "Exile those Auras"). + publish_effect_zone_choice_tracked_set( + state, + effect_kind, + &chosen_ids, + library_position, + true, + ); crate::game::triggers::append_and_collect_logical_zone_trigger_segment( state, &mut logical_zone_change_group, @@ -4885,6 +4921,16 @@ pub(super) fn handle_resolution_choice( // `effects/mod.rs::drain_pending_change_zone_iteration` // resumes the loop after this replacement // choice resolves (issue #535). + // CR 608.2c + CR 603.7: Publish selection before + // the replacement pause — same early-return gap + // as NeedsAuraAttachmentChoice above. + publish_effect_zone_choice_tracked_set( + state, + effect_kind, + &chosen_ids, + library_position, + true, + ); crate::game::triggers::append_and_collect_logical_zone_trigger_segment( state, &mut logical_zone_change_group, @@ -5437,34 +5483,17 @@ pub(super) fn handle_resolution_choice( GameEvent::PermanentSacrificed { object_id, .. } => Some(*object_id), _ => None, }) - .collect() - } else if matches!(effect_kind, EffectKind::PutAtLibraryPosition) - && matches!(library_position, Some(LibraryPosition::Bottom)) - && state.active_ability_continuation().is_some() - { - // CR 608.2c: Expressive Iteration's bottom pick narrows the - // tracked set to the remaining looked-at library cards so the - // chained exile step cannot re-select the bottomed card. - state - .chain_tracked_set_id - .and_then(|id| state.tracked_object_sets.get(&id).cloned()) - .unwrap_or_default() - .into_iter() - .filter(|id| !chosen.contains(id)) - .filter(|id| { - state - .objects - .get(id) - .is_some_and(|obj| obj.zone == Zone::Library) - }) - .collect() + .collect::>() } else { chosen.clone() }; - let tracked_id = TrackedSetId(state.next_tracked_set_id); - state.next_tracked_set_id += 1; - state.tracked_object_sets.insert(tracked_id, tracked); - state.chain_tracked_set_id = Some(tracked_id); + publish_effect_zone_choice_tracked_set( + state, + effect_kind, + &tracked, + library_position, + false, + ); } state.last_effect_count = Some(chosen.len() as i32); events.push(GameEvent::EffectResolved { @@ -6603,6 +6632,82 @@ fn action_result_outcome( }) } +/// CR 608.2c + CR 603.7: Publish the EffectZoneChoice selection as the chain +/// tracked set when a continuation will consume it ("those Auras", plotted +/// cards, etc.). +/// +/// Must also run on mid-delivery pauses (`NeedsAuraAttachmentChoice` / +/// replacement `NeedsChoice`): those early-return before the terminal publish +/// at the end of the EffectZoneChoice arm, and Storm Herald's delayed exile +/// would otherwise bind `TrackedSet` against an unbound sentinel. +/// +/// `mid_pause`: when true, an empty selection is not published yet (Aura host +/// choice / replacement ordering still open). When false (terminal completion), +/// an empty `up_to` selection must rebind a fresh empty chain set so a following +/// `TargetFilter::TrackedSet` cannot reuse a prior non-empty set. +fn publish_effect_zone_choice_tracked_set( + state: &mut GameState, + effect_kind: EffectKind, + chosen: &[ObjectId], + library_position: Option, + mid_pause: bool, +) { + if !matches!( + effect_kind, + EffectKind::Sacrifice + | EffectKind::ChangeZone + | EffectKind::BounceAll + | EffectKind::Tap + | EffectKind::Untap + | EffectKind::PutAtLibraryPosition + | EffectKind::CastFromZone + ) || state.active_ability_continuation().is_none() + { + return; + } + // Distinguish mid-pause "nothing to publish yet" from a genuine empty + // narrowed set (PutAtLibraryPosition Bottom). The latter must still rebind + // `chain_tracked_set_id` so a chained TrackedSet exile cannot re-select + // cards that just left the library (CR 608.2c). + let mut narrowed = false; + let tracked = if matches!(effect_kind, EffectKind::Sacrifice) { + // Sacrifice publishes from PermanentSacrificed events at the completion + // seam; callers pass the sacrificed ids already. + chosen.to_vec() + } else if matches!(effect_kind, EffectKind::PutAtLibraryPosition) + && matches!(library_position, Some(LibraryPosition::Bottom)) + { + narrowed = true; + // CR 608.2c: Expressive Iteration's bottom pick narrows the tracked set + // to the remaining looked-at library cards so the chained exile step + // cannot re-select the bottomed card. + state + .chain_tracked_set_id + .and_then(|id| state.tracked_object_sets.get(&id).cloned()) + .unwrap_or_default() + .into_iter() + .filter(|id| !chosen.contains(id)) + .filter(|id| { + state + .objects + .get(id) + .is_some_and(|obj| obj.zone == Zone::Library) + }) + .collect() + } else { + chosen.to_vec() + }; + // Pause-only: skip empty until the selection is terminal. Terminal empty + // (including narrowed-to-empty Bottom) must still rebind. + if tracked.is_empty() && mid_pause && !narrowed { + return; + } + let tracked_id = TrackedSetId(state.next_tracked_set_id); + state.next_tracked_set_id += 1; + state.tracked_object_sets.insert(tracked_id, tracked); + state.chain_tracked_set_id = Some(tracked_id); +} + fn set_priority(state: &mut GameState, player: crate::types::player::PlayerId) { state.waiting_for = WaitingFor::Priority { player }; state.priority_player = player; @@ -9512,4 +9617,130 @@ mod tests { assert_eq!(active_plane(&state), Some(deck_second)); assert!(state.planar_deck.contains(&deck_top)); } + + /// CR 603.7: Terminal `up_to` EffectZoneChoice with zero cards selected must + /// rebind a fresh empty chain tracked set through the production + /// `handle_resolution_choice` path so a following TrackedSet consumer cannot + /// reuse a prior non-empty set. Mid-pause empty publishes stay skipped. + #[test] + fn terminal_empty_up_to_effect_zone_choice_rebinds_empty_tracked_set() { + use crate::types::ability::{ + CastingPermission, Effect, PermissionGrantee, ResolvedAbility, + }; + use crate::types::game_state::PendingContinuation; + use crate::types::identifiers::TrackedSetId; + use crate::types::zones::EtbTapState; + + let mut state = GameState::new_two_player(42); + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Source".to_string(), + Zone::Battlefield, + ); + let eligible = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Eligible Aura".to_string(), + Zone::Graveyard, + ); + let stale = create_object( + &mut state, + CardId(5), + PlayerId(0), + "Stale".to_string(), + Zone::Exile, + ); + state + .tracked_object_sets + .insert(TrackedSetId(1), vec![stale]); + state.next_tracked_set_id = 2; + state.chain_tracked_set_id = Some(TrackedSetId(1)); + + // Mid-pause empty must not rebind (Storm Herald Aura-host pause). + publish_effect_zone_choice_tracked_set(&mut state, EffectKind::ChangeZone, &[], None, true); + assert_eq!(state.chain_tracked_set_id, Some(TrackedSetId(1))); + assert_eq!( + state.tracked_object_sets.get(&TrackedSetId(1)), + Some(&vec![stale]) + ); + + // Continuation consumes the chain tracked set — must observe the fresh + // empty set from terminal zero-choice, not the stale prior members. + state.park_ability_continuation(PendingContinuation::new( + Box::new(ResolvedAbility::new( + Effect::GrantCastingPermission { + permission: CastingPermission::Plotted { turn_plotted: 0 }, + target: TargetFilter::TrackedSet { + id: TrackedSetId(0), + }, + grantee: PermissionGrantee::ObjectOwner, + }, + vec![], + source, + PlayerId(0), + )), + &state, + )); + + let waiting = WaitingFor::EffectZoneChoice { + player: PlayerId(0), + cards: vec![eligible], + count: 1, + min_count: 0, + up_to: true, + source_id: source, + effect_kind: EffectKind::ChangeZone, + zone: Zone::Graveyard, + destination: Some(Zone::Battlefield), + enter_tapped: EtbTapState::Unspecified, + enter_transformed: false, + enters_under_player: None, + enters_attacking: false, + owner_library: false, + track_exiled_by_source: false, + face_down_profile: None, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + count_param: 0, + library_position: None, + is_cost_payment: false, + enters_modified_if: None, + duration: None, + }; + state.waiting_for = waiting.clone(); + + let mut events = Vec::new(); + handle_resolution_choice( + &mut state, + waiting, + GameAction::SelectCards { cards: vec![] }, + &mut events, + ) + .expect("terminal empty up_to EffectZoneChoice resolves"); + + assert_eq!( + state.chain_tracked_set_id, + Some(TrackedSetId(2)), + "production empty up_to path must rebind a fresh chain tracked set" + ); + assert!(state + .tracked_object_sets + .get(&TrackedSetId(2)) + .is_some_and(|objects| objects.is_empty())); + assert!( + state + .objects + .get(&stale) + .is_some_and(|obj| obj.casting_permissions.is_empty()), + "TrackedSet continuation must not grant against the prior non-empty set" + ); + assert_eq!( + state.objects.get(&eligible).map(|obj| obj.zone), + Some(Zone::Graveyard), + "zero-choice must leave eligible cards unmoved" + ); + } } diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index e97a7d852c..b440078162 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -2952,6 +2952,9 @@ fn execute_zone_move_with_applied_terminal( } } } + // CR 303.4i specified-host Remain is handled after delivery when + // `attach_to` fails / SBA (CR 704.5m). Pre-move filter checks while + // the Aura is still in GY falsely Remained legal Gift/Lynde hosts. } if let Some((controller, aura_id, legal_targets)) = pending_aura_choice { let delivery_start = events.len(); diff --git a/crates/engine/src/parser/oracle_effect/assembly.rs b/crates/engine/src/parser/oracle_effect/assembly.rs index 3f9a8d473c..bfb6f616e6 100644 --- a/crates/engine/src/parser/oracle_effect/assembly.rs +++ b/crates/engine/src/parser/oracle_effect/assembly.rs @@ -98,6 +98,33 @@ fn is_multi_target_player_subject_definition(def: &AbilityDefinition) -> bool { }) } +/// CR 701.3a + CR 303.4f: Stamp `forward_result` on a ChangeZone→Battlefield that +/// nests Attach, including when that return sits under TargetOnly (Necrotic Plague). +fn stamp_forward_result_on_battlefield_attach_return(def: &mut AbilityDefinition) { + let nests_attach = matches!( + def.sub_ability.as_deref().map(|s| &*s.effect), + Some(Effect::Attach { .. }) + ); + if nests_attach + && matches!( + &*def.effect, + Effect::ChangeZone { + destination: Zone::Battlefield, + .. + } + ) + { + def.forward_result = true; + return; + } + if let Some(sub) = def.sub_ability.as_mut() { + stamp_forward_result_on_battlefield_attach_return(sub); + } + if let Some(else_ability) = def.else_ability.as_mut() { + stamp_forward_result_on_battlefield_attach_return(else_ability); + } +} + /// CR 608.2c: A bare verb after a multi-target player subject continues that /// subject. A printed player subject (especially `you`) starts an independent /// actor-relative instruction instead. @@ -1893,7 +1920,36 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { // `parse_target_with_ctx` during chunk parse, so a targeted "of their // choice" routes target selection to the scoped (upkeep) player. def.target_chooser = clause_ir.target_chooser.clone(); + // CR 701.3a + CR 303.4f: ChangeZone→Battlefield with an Attach sub + // (Gift of Immortality delayed reattach) must nest Attach on the + // ChangeZone with `forward_result` BEFORE delayed wrapping. Sibling- + // pushing then wrapping each unit would yield CDT{ChangeZone} + + // CDT{Attach} instead of CDT{ChangeZone[+Attach]}. TargetOnly already + // nests before wrap; mirror that for this attach-on-return shape. let clause_sub = if is_target_only { + def.sub_ability = clause_ir.parsed.sub_ability.clone(); + // CR 701.3a + CR 303.4f: TargetOnly → ChangeZone[+Attach] (Necrotic + // Plague) must stamp `forward_result` on the nested return so the + // chosen host propagates into Attach (see + // `change_zone_forwards_chosen_attach_host`). + if let Some(sub) = def.sub_ability.as_mut() { + stamp_forward_result_on_battlefield_attach_return(sub); + } + None + } else if matches!( + &*def.effect, + Effect::ChangeZone { + destination: Zone::Battlefield, + .. + } + ) && matches!( + clause_ir.parsed.sub_ability.as_deref().map(|s| &*s.effect), + Some(Effect::Attach { .. }) + ) { + // CR 303.4f + CR 608.2c: forward the moved Aura into Attach so + // `resolve_forward_result_search_attach_host` stamps `attach_to` + // and skips the CR 303.4f host-choice consult. + def.forward_result = true; def.sub_ability = clause_ir.parsed.sub_ability.clone(); None } else { @@ -2252,12 +2308,16 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { }, ), ); - // CR 608.2c: Lift condition/optional/repeat/player_scope to outer wrapper. - let lifted_condition = inner.condition.clone(); - let lifted_optional = inner.optional; - let lifted_optional_for = inner.optional_for; - let lifted_repeat_for = inner.repeat_for.clone(); - let lifted_player_scope = inner.player_scope.clone(); + // CR 608.2c: Lift condition/optional/repeat/player_scope to the + // outer wrapper — move, don't clone. Leaving + // `OptionalEffectPerformed` on the delayed payload (Next of Kin + // #4956) would re-check that creation-time "if you do" signal when + // the delayed trigger fires at end step and skip the return. + let lifted_condition = std::mem::take(&mut inner.condition); + let lifted_optional = std::mem::replace(&mut inner.optional, false); + let lifted_optional_for = std::mem::take(&mut inner.optional_for); + let lifted_repeat_for = std::mem::take(&mut inner.repeat_for); + let lifted_player_scope = std::mem::take(&mut inner.player_scope); // CR 608.2c: The `CreateDelayedTrigger` wrapper — not its payload — // is the node that occupies this clause's slot in the parent's // `sub_ability` chain, so it must carry the clause's `sub_link` diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index b35b1b84ca..c1c79add74 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -1890,12 +1890,13 @@ pub(super) fn parse_targeted_action_ast( rest }; let rest_lower = &lower[lower.len() - rest.len()..]; - let (trailing_target_text, trailing_dest) = super::strip_return_destination_ext(rest); + let (trailing_target_text, trailing_dest, trailing_dest_remainder) = + super::strip_return_destination_ext_with_remainder(rest); let (leading_target_text, leading_dest) = super::strip_leading_return_destination_ext(rest); - let (target_text, dest) = if leading_dest.is_some() { - (leading_target_text, leading_dest) + let (target_text, dest, dest_remainder) = if leading_dest.is_some() { + (leading_target_text, leading_dest, "") } else { - (trailing_target_text, trailing_dest) + (trailing_target_text, trailing_dest, trailing_dest_remainder) }; let (is_mass, target_text) = if let Some((_, rest)) = nom_on_lower(target_text, &target_text.to_ascii_lowercase(), |input| { @@ -2015,6 +2016,10 @@ pub(super) fn parse_targeted_action_ast( enter_with_counters: d.enter_with_counters, }) } else { + // CR 701.3a + CR 303.4f: "return … to the battlefield attached + // to " (Gift of Immortality, Next of Kin, Lynde). + let attach_host = + super::sequence::parse_search_attach_host(dest_remainder).map(|(h, _)| h); Some(TargetedImperativeAst::ReturnToBattlefield { target, origin, @@ -2024,6 +2029,7 @@ pub(super) fn parse_targeted_action_ast( enters_attacking: d.enters_attacking, enter_with_counters: d.enter_with_counters, face_down: d.face_down, + attach_host, }) } } @@ -2299,6 +2305,8 @@ pub(super) fn lower_targeted_action_ast(ast: TargetedImperativeAst) -> Effect { count, }, // CR 400.7: Return to battlefield is a zone change, not a bounce. + // CR 701.3a: `attach_host` is an ability-level sub_ability (Attach under + // ChangeZone with forward_result), recovered in `lower_imperative_family_ast`. TargetedImperativeAst::ReturnToBattlefield { target, origin, @@ -2308,6 +2316,7 @@ pub(super) fn lower_targeted_action_ast(ast: TargetedImperativeAst) -> Effect { enters_attacking, enter_with_counters, face_down, + attach_host: _, } => Effect::ChangeZone { origin, destination: Zone::Battlefield, @@ -5506,23 +5515,45 @@ pub(super) fn parse_utility_imperative_ast( return Some(UtilityImperativeAst::SwitchPT { target }); } } - // CR 400.7j + CR 608.2h: Zack Fair — "attach an Equipment that was attached - // to ~ to that creature". The attachment is battlefield Equipment whose - // host was the ability source (including LKI after self-sacrifice). + // CR 400.7j + CR 608.2h: Zack Fair / Cass — "attach [an / any number of] + // Equipment that was/were attached to ~/it to that creature". The attachment + // is battlefield Equipment whose host was the ability source or the + // triggering object (AttachedToSource + trigger_source LKI). "to that + // creature" after a chosen attach-host is ParentTarget (Cass), not the + // dies-trigger TriggeringSource demonstrative. if let Some((multi_target, recipient_text)) = nom_on_lower(text, lower, |input| { let (input, _) = tag("attach ").parse(input)?; - let (input, _) = opt(tag("an ")).parse(input)?; - let (input, multi_target) = opt(value( - MultiTargetSpec::up_to(QuantityExpr::Fixed { value: 1 }), - tag("up to one "), + let (input, multi_target) = alt(( + value(Some(MultiTargetSpec::unlimited(0)), tag("any number of ")), + value( + Some(MultiTargetSpec::up_to(QuantityExpr::Fixed { value: 1 })), + tag("up to one "), + ), + map(opt(tag("an ")), |_| None), )) .parse(input)?; - let (input, _) = tag("equipment that was attached to ").parse(input)?; - let (input, _) = alt((tag("~"), tag("this equipment"))).parse(input)?; + let (input, _) = tag("equipment that ").parse(input)?; + let (input, _) = alt((tag("was "), tag("were "))).parse(input)?; + let (input, _) = tag("attached to ").parse(input)?; + let (input, _) = alt((tag("~"), tag("it"), tag("this equipment"))).parse(input)?; let (input, _) = tag(" to ").parse(input)?; Ok((input, multi_target)) }) { - let (target, _target_rem) = parse_attach_recipient(recipient_text, ctx); + // CR 608.2c: "to that creature" names the chosen Aura host from the prior + // return/attach clause (Cass), not the dying creature. Force ParentTarget + // for that demonstrative; other recipients keep attach-recipient dispatch. + let (target, _target_rem) = { + let recipient_lower = recipient_text.trim().to_ascii_lowercase(); + if tag::<_, _, OracleError<'_>>("that creature") + .parse(recipient_lower.as_str()) + .ok() + .is_some_and(|(rest, _)| rest.trim().is_empty()) + { + (TargetFilter::ParentTarget, "") + } else { + parse_attach_recipient(recipient_text, ctx) + } + }; #[cfg(debug_assertions)] assert_no_compound_remainder(_target_rem, text); if _target_rem.trim().is_empty() { @@ -12094,6 +12125,47 @@ pub(super) fn lower_imperative_family_ast(ast: ImperativeFamilyAst) -> ParsedEff clause.multi_target = multi_target; clause } + // CR 701.3a + CR 303.4f: "return … to the battlefield attached to " + // (Gift of Immortality, Next of Kin, Lynde). Bare Effect lowering cannot + // carry the Attach sub-chain — nest it here (Cloak / SearchLibrary pattern) + // so assembly can wrap a single ChangeZone[+Attach] unit in CreateDelayedTrigger. + ImperativeFamilyAst::Structured(ImperativeAst::Targeted( + TargetedImperativeAst::ReturnToBattlefield { + target, + origin, + enter_transformed, + enters_under, + enter_tapped, + enters_attacking, + enter_with_counters, + face_down, + attach_host: Some(host), + }, + )) => { + let mut clause = parsed_clause(lower_targeted_action_ast( + TargetedImperativeAst::ReturnToBattlefield { + target, + origin, + enter_transformed, + enters_under, + enter_tapped, + enters_attacking, + enter_with_counters, + face_down, + attach_host: None, + }, + )); + // CR 701.3a: the returning Aura/permanent is SelfRef; the host is the + // anaphor captured on the IR (`that creature` / `you`). + clause.sub_ability = Some(Box::new(AbilityDefinition::new( + AbilityKind::Spell, + Effect::Attach { + attachment: TargetFilter::SelfRef, + target: host, + }, + ))); + clause + } // All other arms produce a bare Effect with no sub_ability chain. other => parsed_clause(lower_imperative_family_effect(other)), } @@ -14651,6 +14723,42 @@ mod tests { assert_eq!(multi_target, None); } + #[test] + fn parse_attach_any_number_equipment_were_attached_to_it_to_parent_target() { + // Cass, Hand of Vengeance — dies-trigger "that creature" is the chosen + // Aura host (ParentTarget), not TriggeringSource. + let input = "attach any number of Equipment that were attached to it to that creature"; + let lower = input.to_lowercase(); + let mut ctx = ParseContext { + subject: Some(TargetFilter::SelfRef), + ..Default::default() + }; + let result = parse_utility_imperative_ast(input, &lower, &mut ctx); + let Some(UtilityImperativeAst::Attach { + attachment, + target, + multi_target, + }) = result + else { + panic!("{input}: expected Attach, got {result:?}"); + }; + match attachment { + TargetFilter::Typed(tf) => { + assert!(tf + .type_filters + .iter() + .any(|t| matches!(t, TypeFilter::Subtype(s) if s == "Equipment"))); + assert!(tf.properties.contains(&FilterProp::AttachedToSource)); + } + other => panic!("expected typed Equipment filter, got {other:?}"), + } + assert!( + matches!(target, TargetFilter::ParentTarget), + "Cass Equipment host must be ParentTarget, got {target:?}" + ); + assert_eq!(multi_target, Some(MultiTargetSpec::unlimited(0))); + } + #[test] fn parse_attach_target_equipment_to_target_creature() { let input = "attach target Equipment you control to target creature you control"; diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index f3716dd26b..28b66f5769 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -6284,6 +6284,9 @@ fn strip_trailing_battlefield_riders(after_destination: &str) -> (&str, Battlefi } /// Detect "return ... to " destination phrase, including "transformed" flag. +/// Thin wrapper over [`strip_return_destination_ext_with_remainder`] for call sites +/// that discard the attach-host remainder (unit tests + legacy helpers). +#[allow(dead_code)] // exercised from `oracle_effect/tests.rs` (cfg(test) sibling) pub(super) fn strip_return_destination_ext(text: &str) -> (&str, Option) { let (target, dest, _) = strip_return_destination_ext_with_remainder(text); (target, dest) diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 1d56e95fcd..3bc27d9e2e 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -1854,6 +1854,39 @@ fn try_parse_die_exile_rider(lower: &str, kind: AbilityKind) -> Option bool { + matches!(self, Self::TrackedSetPlural) + } +} + +fn parse_leave_battlefield_rider_subject( + input: &str, +) -> OracleResult<'_, LeaveBattlefieldRiderSubject> { + alt(( + value( + LeaveBattlefieldRiderSubject::TrackedSetPlural, + alt(( + tag("those auras"), + tag("those enchantments"), + tag("those permanents"), + tag("those creatures"), + tag("them"), + )), + ), + map(parse_leave_battlefield_rider_ref, |_| { + LeaveBattlefieldRiderSubject::Singular + }), + )) + .parse(input) +} + fn parse_leave_battlefield_rider_ref(input: &str) -> OracleResult<'_, ()> { value( (), @@ -1865,6 +1898,7 @@ fn parse_leave_battlefield_rider_ref(input: &str) -> OracleResult<'_, ()> { // prefix-collides with the "it"/"the …"/"that …"/"this …" arms below. tag("~"), tag("it"), + tag("them"), tag("the card"), tag("the creature"), tag("the permanent"), @@ -1876,6 +1910,12 @@ fn parse_leave_battlefield_rider_ref(input: &str) -> OracleResult<'_, ()> { tag("this creature"), tag("this land"), tag("this permanent"), + // Storm Herald: "If those Auras would leave the battlefield, exile them + // instead of putting them anywhere else." + tag("those auras"), + tag("those enchantments"), + tag("those permanents"), + tag("those creatures"), )), ) .parse(input) @@ -1955,7 +1995,7 @@ pub(crate) fn try_parse_leave_battlefield_exile_replacement(lower: &str) -> Opti let (rest, _) = nom::combinator::opt(tag::<_, _, OracleError<'_>>("if ")) .parse(lower) .ok()?; - let (rest, _) = parse_leave_battlefield_rider_ref(rest).ok()?; + let (rest, subject) = parse_leave_battlefield_rider_subject(rest).ok()?; let (rest, _) = tag::<_, _, OracleError<'_>>(" would leave the battlefield, ") .parse(rest) .ok()?; @@ -1970,6 +2010,16 @@ pub(crate) fn try_parse_leave_battlefield_exile_replacement(lower: &str) -> Opti .ok()?; parse_optional_period_and_end(rest)?; + // CR 603.7 + CR 614.1a: Plural "those Auras/… them" riders install on the + // just-returned tracked set (Storm Herald), not a singular ParentTarget. + let target = if subject.is_tracked_set_plural() { + TargetFilter::TrackedSet { + id: TrackedSetId(0), + } + } else { + TargetFilter::Any + }; + Some(Effect::AddTargetReplacement { // CR 400.7: The standalone rider is bound to the lifetime of the object it // is installed on; stamp the expiry here so the lifetime is self-contained @@ -1981,7 +2031,7 @@ pub(crate) fn try_parse_leave_battlefield_exile_replacement(lower: &str) -> Opti replacement: Box::new( leave_battlefield_exile_replacement().expiry(RestrictionExpiry::UntilHostLeavesPlay), ), - target: TargetFilter::Any, + target, }) } @@ -15779,6 +15829,28 @@ fn try_parse_verb_and_target<'a>( rem, )) } else { + // CR 701.3a + CR 303.4f: consume "attached to " from the + // destination remainder so it does not fall through as an + // Unimplemented follow-up clause (Gift of Immortality #4956). + // Prefer `dest_remainder` over target-parse leftovers such as + // "from your graveyard" (Smoke Shroud / Dragon Breath) so the + // host rider is not skipped when `parse_target` leaves a zone + // phrase in `rem`. Preserve the unconsumed suffix for + // continuation parsing (Cass ", then attach …"; Storm Herald + // ". Exile those …"). + let attach_source = if nom_primitives::scan_contains( + &dest_remainder.to_ascii_lowercase(), + "attached to", + ) { + dest_remainder + } else { + rem + }; + let (attach_host, rem) = match sequence::parse_search_attach_host(attach_source) + { + Some((host, rest)) => (Some(host), rest), + None => (None, rem), + }; Some(( TargetedImperativeAst::ReturnToBattlefield { target, @@ -15789,6 +15861,7 @@ fn try_parse_verb_and_target<'a>( enters_attacking: d.enters_attacking, enter_with_counters: d.enter_with_counters, face_down: d.face_down, + attach_host, }, rem, )) @@ -24522,6 +24595,7 @@ fn hand_reveal_target_to_controller_ref(target: &TargetFilter) -> Option bool { is_exile_effect(effect) + || is_battlefield_return_effect(effect) || is_token_creating_effect(effect) || is_mass_coerce_static(effect) || matches!( @@ -24533,6 +24607,21 @@ fn publishes_tracked_set_from_resolution(effect: &Effect) -> bool { ) } +/// CR 603.7 + CR 400.7: A return/put onto the battlefield publishes the moved +/// objects as the chain tracked set (Storm Herald "those Auras", Returned cause). +fn is_battlefield_return_effect(effect: &Effect) -> bool { + matches!( + effect, + Effect::ChangeZone { + destination: Zone::Battlefield, + .. + } | Effect::ChangeZoneAll { + destination: Zone::Battlefield, + .. + } + ) +} + /// CR 608.2c + CR 701.21a: Does this clause publish, at resolution, a chain /// tracked set that a LATER set-AGGREGATE anaphor ("their total power", "the /// greatest power among them") would reduce? @@ -24767,6 +24856,9 @@ fn contains_explicit_tracked_set_pronoun(lower: &str) -> bool { || scan_contains_phrase(lower, "those permanents") || scan_contains_phrase(lower, "those creatures") || scan_contains_phrase(lower, "those tokens") + // Storm Herald: "Exile those Auras at the beginning of your next end step." + || scan_contains_phrase(lower, "those auras") + || scan_contains_phrase(lower, "those enchantments") || scan_contains_phrase(lower, "the exiled card") || scan_contains_phrase(lower, "the exiled permanent") || scan_contains_phrase(lower, "the exiled creature") diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 973b5df69f..6d541951db 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -117,7 +117,13 @@ fn parse_search_attach_host_phrase(input: &str) -> OracleResult<'_, TargetFilter .parse(input) } -fn parse_search_attach_host(text: &str) -> Option { +/// CR 701.3a + CR 303.4f: Parse an "attached to " rider from return / +/// search destination remainders (Gift of Immortality, Next of Kin, Lynde). +/// +/// Returns `(host, unconsumed_remainder)` so rules-bearing suffixes after the +/// host phrase (`, then attach …`, `. Exile those Auras …`) stay available for +/// normal continuation parsing (Cass, Hand of Vengeance; Storm Herald). +pub(crate) fn parse_search_attach_host(text: &str) -> Option<(TargetFilter, &str)> { let lower = text.to_ascii_lowercase(); nom_on_lower(text, &lower, |input| { let (input, _) = take_until("attached to").parse(input)?; @@ -125,7 +131,6 @@ fn parse_search_attach_host(text: &str) -> Option { let (input, filter) = parse_search_attach_host_phrase(input)?; Ok((input, filter)) }) - .map(|(filter, _)| filter) } /// CR 608.2c + CR 701.23i: Strip a leading player-subject from a search-result @@ -5242,7 +5247,9 @@ pub(super) fn parse_intrinsic_continuation_ast( let attach_host = if nom_primitives::scan_contains(&full_lower, "attached to") || nom_primitives::scan_contains(&lower, "attached to") { - parse_search_attach_host(&full_lower).or(Some(TargetFilter::Any)) + parse_search_attach_host(&full_lower) + .map(|(host, _)| host) + .or(Some(TargetFilter::Any)) } else { None }; @@ -12929,29 +12936,67 @@ mod tests { assert_eq!( super::parse_search_attach_host( "put it onto the battlefield attached to that creature, then shuffle" - ), + ) + .map(|(host, _)| host), Some(TargetFilter::ParentTarget) ); assert_eq!( super::parse_search_attach_host( "put it onto the battlefield attached to target creature. if you search your library this way, shuffle" - ), + ) + .map(|(host, _)| host), Some(TargetFilter::Typed(TypedFilter::creature())) ); assert_eq!( super::parse_search_attach_host( "put it onto the battlefield attached to target player, then shuffle" - ), + ) + .map(|(host, _)| host), Some(TargetFilter::Player) ); assert_eq!( super::parse_search_attach_host( "put that card onto the battlefield attached to ~, then shuffle" - ), + ) + .map(|(host, _)| host), Some(TargetFilter::SelfRef) ); } + #[test] + fn search_attach_host_preserves_continuation_remainder() { + // Cass, Hand of Vengeance: host phrase ends at the comma; ", then attach …" + // must remain for continuation parsing. + let (host, rem) = super::parse_search_attach_host( + "attached to target creature, then attach any number of Equipment that were attached to it to that creature", + ) + .expect("attach host"); + assert_eq!( + host, + TargetFilter::Typed(crate::types::ability::TypedFilter::creature()) + ); + assert_eq!( + rem.trim(), + "then attach any number of Equipment that were attached to it to that creature", + "Cass continuation must survive attach-host parse" + ); + + // Storm Herald: host phrase ends at the period; exile delayed clause must remain. + let (host, rem) = super::parse_search_attach_host( + "attached to creatures you control. Exile those Auras at the beginning of your next end step.", + ) + .expect("attach host"); + assert!( + matches!(host, TargetFilter::Typed(_)), + "Storm Herald host must parse as typed filter, got {host:?}" + ); + assert_eq!( + rem.trim(), + "Exile those Auras at the beginning of your next end step.", + "Storm Herald exile clause must survive attach-host parse" + ); + } + #[test] fn attach_one_of_them_reflexive_gate_is_not_dig_from_among() { let dig = make_dig_effect(); diff --git a/crates/engine/src/parser/oracle_ir/ast.rs b/crates/engine/src/parser/oracle_ir/ast.rs index a2c6c6b0d0..7e63466640 100644 --- a/crates/engine/src/parser/oracle_ir/ast.rs +++ b/crates/engine/src/parser/oracle_ir/ast.rs @@ -1002,6 +1002,12 @@ pub(crate) enum TargetedImperativeAst { /// a Forest land."). Lowered to a default vanilla-2/2 `face_down_profile`, /// refined by a trailing "It's a " `FaceDownProfileSpec`. face_down: bool, + /// CR 701.3a + CR 303.4f/i: Optional "attached to " rider on the + /// return (Gift of Immortality, Next of Kin, Lynde). When set, lowering + /// nests `Effect::Attach { SelfRef → host }` under the ChangeZone with + /// `forward_result` so the Aura enters attached and skips the CR 303.4f + /// host-choice consult. + attach_host: Option, }, /// CR 400.6: Return to a specific non-hand, non-battlefield zone (zone change). ReturnToZone { diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index 5eaec306c7..8c0a256f3d 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -1909,12 +1909,21 @@ pub(crate) fn lower_trigger_ir(ir: &TriggerIr) -> TriggerDefinition { && def.destination == Some(Zone::Graveyard) { def.trigger_zones = vec![Zone::Graveyard]; - } else if let Some(zone) = def - .execute - .as_deref() - .and_then(|execute| self_recursion_trigger_zone(execute, modifiers.effect_lower.as_str())) - { - def.trigger_zones = vec![zone]; + } else if !matches!(def.valid_card, Some(TargetFilter::AttachedTo)) { + // CR 603.6c + CR 603.6e + CR 113.6 + CR 704.5m: A SelfRef return "from your + // graveyard" in the effect body implies the source is already in that + // zone when the trigger fires — EXCEPT for AttachedTo dies triggers + // (Necrotic Plague: "When enchanted creature dies, … Return this card + // from its owner's graveyard"). Those fire while the Aura is still on + // the battlefield; SBAs move it to the GY before the effect resolves, where + // CR 603.6e lets the Aura's trigger find that Aura card. + // Applying self-recursion here would park the trigger in the GY only, + // so the enchanted-creature death never matches. + if let Some(zone) = def.execute.as_deref().and_then(|execute| { + self_recursion_trigger_zone(execute, modifiers.effect_lower.as_str()) + }) { + def.trigger_zones = vec![zone]; + } } // CR 608.2c: Off-battlefield source-return triggers (Senu, Keen-Eyed diff --git a/crates/engine/src/parser/oracle_util.rs b/crates/engine/src/parser/oracle_util.rs index 9ebaf51c3b..ee30023825 100644 --- a/crates/engine/src/parser/oracle_util.rs +++ b/crates/engine/src/parser/oracle_util.rs @@ -2204,6 +2204,9 @@ pub fn normalize_card_name_refs(text: &str, card_name: &str) -> String { let short_name = &effective_name[..of_pos]; let lower_short = short_name.to_lowercase(); // structural: not dispatch — guarding single-word short names only + // CR 201.5 / CR 201.5c: "Next of Kin" short name "Next" must not + // rewrite temporal "the next end step" → "the ~ end step" (Gift of + // Immortality peer class; issue #4956). let is_common_english_word = !short_name.contains(' ') && matches!( lower_short.as_str(), @@ -2223,6 +2226,7 @@ pub fn normalize_card_name_refs(text: &str, card_name: &str) -> String { | "back" | "away" | "off" + | "next" ); // CR 201.3a: a card's "of"-derived short name normalizes to `~` // (interchangeable name reference). Suppress this ONLY when the diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 0785d07ea6..dcc1bedae3 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -23300,6 +23300,13 @@ impl ResolvedAbility { /// /// Off-battlefield zone-match currency alone (co-departure mis-latch) does /// not suffice — CR 400.7e own-departure successor proof is required. + /// + /// CR 400.7 + CR 603.6c + CR 704.5m: A further exception is an Aura that + /// latched its AttachedTo dies trigger while still on the battlefield + /// (Necrotic Plague), then moved to the graveyard by SBAs before + /// resolution. `SelfRef` return-from-GY must bind that graveyard object + /// without rewriting `trigger_source` (which would flip `source_read` to + /// ExactLive and drop LKI attachments/counters for other dies triggers). pub fn self_ref_is_current(&self, state: &crate::types::game_state::GameState) -> bool { if self.source_is_current(state) { // CR 400.7e: co-departure mis-latch — zone-match currency alone cannot @@ -23316,6 +23323,88 @@ impl ResolvedAbility { return true; }; self.self_ref_own_departure_successor(state) + || self.self_ref_post_sba_graveyard_return(state) + } + + /// CR 400.7 + CR 704.5m: True when this ability returns `SelfRef` from the + /// graveyard and the source is now there after an SBA move that followed a + /// battlefield latch (AttachedTo dies → Aura to GY before resolution). + /// + /// Provenance mirrors `self_ref_own_departure_successor`: the BF→GY record + /// must carry the captured source identity (incarnation), and no later + /// same-id zone change may have occurred — a same-id blink/re-entry must + /// not be rebound as the old source. + fn self_ref_post_sba_graveyard_return( + &self, + state: &crate::types::game_state::GameState, + ) -> bool { + let Some(source) = self.trigger_source.as_ref() else { + return false; + }; + if source.identity.expected_zone != crate::types::zones::Zone::Battlefield { + return false; + } + if source.identity.reference.object_id != self.source_id { + return false; + } + if !self.effect_returns_self_ref_from_graveyard() { + return false; + } + if !state + .objects + .get(&self.source_id) + .is_some_and(|object| object.zone == crate::types::zones::Zone::Graveyard) + { + return false; + } + + // CR 400.7e: Find the BF→GY departure whose captured identity matches + // the ability's latched stamp, then require it is still the latest + // same-id zone change this turn. + let Some(departure_index) = state + .zone_changes_this_turn + .iter() + .enumerate() + .rev() + .find_map(|(index, record)| { + (record.object_id == self.source_id + && record.from_zone == Some(crate::types::zones::Zone::Battlefield) + && record.to_zone == crate::types::zones::Zone::Graveyard + && record.trigger_source_context().is_some_and(|event_source| { + event_source.identity.reference == source.identity.reference + })) + .then_some(index) + }) + else { + return false; + }; + + state + .zone_changes_this_turn + .iter() + .skip(departure_index + 1) + .all(|later| later.object_id != self.source_id) + } + + /// True when this ability (or a nested sub) is a `ChangeZone` of `SelfRef` + /// whose origin is the graveyard. + fn effect_returns_self_ref_from_graveyard(&self) -> bool { + fn change_zone_self_from_gy(effect: &Effect) -> bool { + matches!( + effect, + Effect::ChangeZone { + origin: Some(crate::types::zones::Zone::Graveyard), + target: TargetFilter::SelfRef, + .. + } + ) + } + fn walk(ability: &ResolvedAbility) -> bool { + change_zone_self_from_gy(&ability.effect) + || ability.sub_ability.as_deref().is_some_and(walk) + || ability.else_ability.as_deref().is_some_and(walk) + } + walk(self) } /// CR 608.2c: Bind a tracked-set sentinel (`TrackedSetId(0)`) to a CONCRETE @@ -26559,6 +26648,112 @@ mod tests { "relatch must early-true without requiring own-departure successor proof" ); } + + fn gy_return_ability( + source_id: ObjectId, + source_context: TriggerSourceContext, + ) -> ResolvedAbility { + let mut ability = ResolvedAbility::new( + Effect::ChangeZone { + origin: Some(Zone::Graveyard), + destination: Zone::Battlefield, + target: TargetFilter::SelfRef, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + }, + vec![], + source_id, + P0, + ); + ability.trigger_source = Some(source_context); + ability + } + + /// CR 400.7 + CR 704.5m: post-SBA BF→GY departure with matching captured + /// identity permits SelfRef return-from-GY (Necrotic Plague). + #[test] + fn self_ref_post_sba_gy_return_binds_matching_bf_departure() { + let mut scenario = GameScenario::new(); + let source = scenario.add_vanilla(P0, 2, 2); + let mut runner = scenario.build(); + + let bf_context = { + let obj = runner.state().objects.get(&source).unwrap(); + crate::game::triggers::trigger_source_context_for_latch(runner.state(), obj) + }; + let mut events = Vec::new(); + move_to_zone(runner.state_mut(), source, Zone::Graveyard, &mut events); + + let record = { + let mut record = ZoneChangeRecord::test_minimal( + source, + Some(Zone::Battlefield), + Zone::Graveyard, + ); + record.trigger_source_context = Some(bf_context.clone()); + record.turn_zone_change_index = 0; + record + }; + runner.state_mut().zone_changes_this_turn = vec![record].into(); + + let ability = gy_return_ability(source, bf_context); + assert!( + ability.self_ref_is_current(runner.state()), + "matching BF→GY departure must bind SelfRef return-from-GY" + ); + } + + /// CR 400.7e: a same-id second zone change after the BF→GY departure + /// must not rebind SelfRef to the stale source. + #[test] + fn self_ref_post_sba_gy_return_rejects_stale_after_second_zone_change() { + let mut scenario = GameScenario::new(); + let source = scenario.add_vanilla(P0, 2, 2); + let mut runner = scenario.build(); + + let bf_context = { + let obj = runner.state().objects.get(&source).unwrap(); + crate::game::triggers::trigger_source_context_for_latch(runner.state(), obj) + }; + let mut events = Vec::new(); + move_to_zone(runner.state_mut(), source, Zone::Graveyard, &mut events); + move_to_zone(runner.state_mut(), source, Zone::Exile, &mut events); + + let gy_record = { + let mut record = ZoneChangeRecord::test_minimal( + source, + Some(Zone::Battlefield), + Zone::Graveyard, + ); + record.trigger_source_context = Some(bf_context.clone()); + record.turn_zone_change_index = 0; + record + }; + let exile_record = { + let mut record = + ZoneChangeRecord::test_minimal(source, Some(Zone::Graveyard), Zone::Exile); + record.turn_zone_change_index = 1; + record + }; + // Leave the object in GY for the zone gate so only the second-move + // provenance rejects — mirrors a same-id return that later left again. + move_to_zone(runner.state_mut(), source, Zone::Graveyard, &mut events); + runner.state_mut().zone_changes_this_turn = vec![gy_record, exile_record].into(); + + let ability = gy_return_ability(source, bf_context); + assert!( + !ability.self_ref_is_current(runner.state()), + "second same-id zone change must reject post-SBA SelfRef return" + ); + } } } diff --git a/crates/engine/tests/integration/issue_4956_gift_of_immortality_reattach.rs b/crates/engine/tests/integration/issue_4956_gift_of_immortality_reattach.rs new file mode 100644 index 0000000000..a0945b6869 --- /dev/null +++ b/crates/engine/tests/integration/issue_4956_gift_of_immortality_reattach.rs @@ -0,0 +1,1356 @@ +//! Issue #4956: Gift of Immortality delayed Aura reattach must specify the +//! host ("that creature" / "you") instead of opening CR 303.4f Aura choice. +//! +//! Peers: Next of Kin (attach to the put creature), Lynde (attach to you). + +use engine::game::effects::attach::{attach_to, attach_to_player}; +use engine::game::game_object::AttachTarget; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::game::triggers::process_triggers; +use engine::parser::oracle::parse_oracle_text; +use engine::types::ability::{ + AbilityCondition, DelayedTriggerCondition, Effect, TargetFilter, TargetRef, TypedFilter, +}; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::keywords::Keyword; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +const GIFT_ORACLE: &str = "Enchant creature\n\ +When enchanted creature dies, return that card to the battlefield under its \ +owner's control. Return this card to the battlefield attached to that creature \ +at the beginning of the next end step."; + +const NEXT_OF_KIN_ORACLE: &str = "Enchant creature\n\ +When enchanted creature dies, you may put a creature card you own with lesser \ +mana value from your hand or from the command zone onto the battlefield. If \ +you do, return this card to the battlefield attached to that creature at the \ +beginning of the next end step."; + +const LYNDE_ORACLE: &str = "Deathtouch\n\ +Whenever a Curse is put into your graveyard from the battlefield, return it to \ +the battlefield attached to you at the beginning of the next end step.\n\ +At the beginning of your upkeep, you may attach a Curse attached to you to one \ +of your opponents. If you do, draw two cards."; + +const SMOKE_SHROUD_ORACLE: &str = "Enchant creature\n\ +Enchanted creature gets +1/+1 and has flying.\n\ +When a Ninja you control enters, you may return this card from your graveyard \ +to the battlefield attached to that creature."; + +const DRAGON_BREATH_ORACLE: &str = "Enchant creature\n\ +Enchanted creature has haste.\n\ +{R}: Enchanted creature gets +1/+0 until end of turn.\n\ +When a creature with mana value 6 or greater enters, you may return this card \ +from your graveyard to the battlefield attached to that creature."; + +const CASS_ORACLE: &str = "Vigilance\n\ +Whenever Cass or another creature you control dies, if it was enchanted or \ +equipped, return any number of Aura cards that were attached to it from your \ +graveyard to the battlefield attached to target creature, then attach any \ +number of Equipment that were attached to it to that creature."; + +const STORM_HERALD_ORACLE: &str = "Haste\n\ +When this creature enters, return any number of Aura cards from your graveyard \ +to the battlefield attached to creatures you control. Exile those Auras at the \ +beginning of your next end step. If those Auras would leave the battlefield, \ +exile them instead of putting them anywhere else."; + +const NECROTIC_PLAGUE_ORACLE: &str = "Enchant creature\n\ +Enchanted creature has \"At the beginning of your upkeep, sacrifice this creature.\"\n\ +When enchanted creature dies, its controller chooses target creature one of \ +their opponents controls. Return this card from its owner's graveyard to the \ +battlefield attached to that creature."; + +/// Event-subject GY return with nested Attach→ParentTarget (Smoke Shroud / Dragon Breath). +fn event_subject_return_attach_host( + parsed: &engine::parser::oracle::ParsedAbilities, +) -> &TargetFilter { + let trigger = parsed + .triggers + .iter() + .find(|t| { + matches!( + t.execute.as_ref().map(|e| e.effect.as_ref()), + Some(Effect::ChangeZone { + destination: Zone::Battlefield, + .. + }) + ) + }) + .expect("GY return trigger"); + let execute = trigger.execute.as_ref().expect("execute"); + assert!( + execute.forward_result, + "event-subject return must stamp forward_result for Attach nest" + ); + let attach = execute.sub_ability.as_ref().expect("Attach nest"); + match attach.effect.as_ref() { + Effect::Attach { + attachment: TargetFilter::SelfRef, + target, + } => target, + other => panic!("expected Attach SelfRef→host, got {other:?}"), + } +} + +fn ability_chain_contains_equipment_attach( + def: &engine::types::ability::AbilityDefinition, +) -> bool { + let is_equipment_attach = matches!( + def.effect.as_ref(), + Effect::Attach { + attachment: TargetFilter::Typed(tf), + .. + } if tf.type_filters.iter().any(|f| { + matches!(f, engine::types::ability::TypeFilter::Subtype(s) if s == "Equipment") + }) + ); + is_equipment_attach + || def + .sub_ability + .as_deref() + .is_some_and(ability_chain_contains_equipment_attach) + || def + .else_ability + .as_deref() + .is_some_and(ability_chain_contains_equipment_attach) +} + +fn ability_chain_contains_delayed_exile(def: &engine::types::ability::AbilityDefinition) -> bool { + let is_exile = match def.effect.as_ref() { + Effect::CreateDelayedTrigger { effect, .. } => { + matches!( + effect.effect.as_ref(), + Effect::ChangeZone { + destination: Zone::Exile, + .. + } + ) || ability_chain_contains_delayed_exile(effect) + } + Effect::ChangeZone { + destination: Zone::Exile, + .. + } => true, + _ => false, + }; + is_exile + || def + .sub_ability + .as_deref() + .is_some_and(ability_chain_contains_delayed_exile) + || def + .else_ability + .as_deref() + .is_some_and(ability_chain_contains_delayed_exile) +} + +fn effect_is_unimplemented(effect: &Effect) -> bool { + matches!(effect, Effect::Unimplemented { .. }) +} + +fn count_cdts(def: &engine::types::ability::AbilityDefinition) -> usize { + let head = matches!(def.effect.as_ref(), Effect::CreateDelayedTrigger { .. }) as usize; + head + def.sub_ability.as_deref().map(count_cdts).unwrap_or(0) + + def.else_ability.as_deref().map(count_cdts).unwrap_or(0) +} + +fn gift_delayed_attach_host(parsed: &engine::parser::oracle::ParsedAbilities) -> &TargetFilter { + let trigger = parsed.triggers.first().expect("dies trigger"); + let execute = trigger.execute.as_ref().expect("execute"); + let cdt = execute + .sub_ability + .as_ref() + .expect("CreateDelayedTrigger sibling"); + let Effect::CreateDelayedTrigger { + condition, effect, .. + } = cdt.effect.as_ref() + else { + panic!("expected CreateDelayedTrigger, got {:?}", cdt.effect); + }; + assert_eq!( + condition, + &DelayedTriggerCondition::AtNextPhase { phase: Phase::End } + ); + let inner = effect.as_ref(); + assert!( + inner.forward_result, + "delayed ChangeZone must forward_result into Attach" + ); + let attach = inner.sub_ability.as_ref().expect("Attach nest"); + match (inner.effect.as_ref(), attach.effect.as_ref()) { + ( + Effect::ChangeZone { + destination: Zone::Battlefield, + target: TargetFilter::SelfRef, + .. + }, + Effect::Attach { + attachment: TargetFilter::SelfRef, + target, + }, + ) => target, + other => panic!("unexpected Gift delayed body shape: {other:?}"), + } +} + +fn drain_priority(runner: &mut GameRunner) { + drain_priority_preferring(runner, &[]); +} + +/// Drain priority/resolution prompts, preferring `preferred` object ids when +/// choosing from EffectZoneChoice / target slots (Cass host, Storm Aura, etc.). +fn drain_priority_preferring( + runner: &mut GameRunner, + preferred: &[engine::types::identifiers::ObjectId], +) { + for _ in 0..256 { + match &runner.state().waiting_for { + WaitingFor::Priority { .. } if runner.state().stack.is_empty() => return, + WaitingFor::ReturnAsAuraTarget { + legal_targets, + returned_id, + .. + } => { + if preferred.is_empty() { + panic!( + "CR 303.4f Aura host choice must not open when attach_to is specified; \ + waiting_for = {:?}", + runner.state().waiting_for + ); + } + // Storm Herald "attached to creatures you control" is a Typed + // multi-host filter — CR 303.4f choice is rules-correct when + // more than one creature is legal. Prefer an explicit host. + let pick = preferred + .iter() + .find_map(|id| { + legal_targets + .iter() + .find(|t| matches!(t, TargetRef::Object(oid) if oid == id)) + .cloned() + }) + .or_else(|| legal_targets.first().cloned()) + .unwrap_or(TargetRef::Object(*returned_id)); + runner + .act(GameAction::ChooseTarget { target: Some(pick) }) + .expect("choose Aura host"); + } + WaitingFor::OrderTriggers { .. } => { + engine::game::triggers::drain_order_triggers_with_identity(runner.state_mut()); + } + WaitingFor::OptionalEffectChoice { .. } => { + runner + .act(GameAction::DecideOptionalEffect { accept: true }) + .expect("accept optional"); + } + WaitingFor::EffectZoneChoice { cards, .. } => { + let pick = preferred + .iter() + .copied() + .find(|id| cards.contains(id)) + .or_else(|| cards.first().copied()) + .expect("zone choice candidate"); + runner + .act(GameAction::SelectCards { cards: vec![pick] }) + .expect("choose zone cards"); + } + WaitingFor::TriggerTargetSelection { .. } | WaitingFor::TargetSelection { .. } => { + if let Some(pref) = preferred.first() { + // Prefer an explicit host when present among legal targets. + let legal = match &runner.state().waiting_for { + WaitingFor::TriggerTargetSelection { + target_slots, + selection, + .. + } + | WaitingFor::TargetSelection { + target_slots, + selection, + .. + } => target_slots + .get(selection.current_slot) + .map(|s| s.legal_targets.clone()) + .unwrap_or_default(), + _ => vec![], + }; + let target = legal + .iter() + .find(|t| matches!(t, TargetRef::Object(id) if id == pref)) + .cloned() + .or_else(|| legal.first().cloned()); + runner + .act(GameAction::ChooseTarget { target }) + .expect("choose target"); + } else { + runner + .choose_first_legal_target() + .expect("choose first legal target"); + } + } + WaitingFor::MultiTargetSelection { + legal_targets, + min_targets, + max_targets, + .. + } => { + let mut chosen: Vec<_> = preferred + .iter() + .copied() + .filter(|id| legal_targets.contains(id)) + .take(*max_targets) + .collect(); + if chosen.len() < *min_targets { + for id in legal_targets { + if chosen.len() >= *min_targets { + break; + } + if !chosen.contains(id) { + chosen.push(*id); + } + } + } + runner + .act(GameAction::SelectCards { cards: chosen }) + .expect("multi-target select"); + } + _ => { + if runner.act(GameAction::PassPriority).is_err() { + return; + } + } + } + } + panic!( + "drain_priority exceeded bound; waiting_for = {:?}", + runner.state().waiting_for + ); +} + +fn advance_through_delayed_end(runner: &mut GameRunner) { + for _ in 0..256 { + // Stop once the delayed trigger has fired and End/Cleanup priority is + // idle — do not keep walking into later turns. + if runner.state().delayed_triggers.is_empty() + && runner.state().stack.is_empty() + && matches!(runner.state().phase, Phase::End | Phase::Cleanup) + && matches!(runner.state().waiting_for, WaitingFor::Priority { .. }) + { + return; + } + match &runner.state().waiting_for { + WaitingFor::ReturnAsAuraTarget { .. } => { + panic!( + "delayed reattach must not prompt for Aura host; waiting_for = {:?}", + runner.state().waiting_for + ); + } + WaitingFor::OrderTriggers { .. } => { + engine::game::triggers::drain_order_triggers_with_identity(runner.state_mut()); + } + WaitingFor::DeclareAttackers { .. } => { + // Lynde (and similar) can be a legal attacker; empty declaration + // lets auto-advance reach the End step where the delayed fires. + runner + .act(GameAction::DeclareAttackers { + attacks: vec![], + bands: vec![], + }) + .expect("declare no attackers"); + } + WaitingFor::DeclareBlockers { .. } => { + runner + .act(GameAction::DeclareBlockers { + assignments: vec![], + }) + .expect("declare no blockers"); + } + WaitingFor::OptionalEffectChoice { .. } => { + runner + .act(GameAction::DecideOptionalEffect { accept: true }) + .expect("accept optional"); + } + _ => { + if runner.act(GameAction::PassPriority).is_err() { + panic!( + "priority pass stalled; phase={:?} dt={} stack={} wf={:?}", + runner.state().phase, + runner.state().delayed_triggers.len(), + runner.state().stack.len(), + runner.state().waiting_for, + ); + } + } + } + } + panic!( + "advance_through_delayed_end exceeded bound; phase = {:?}, dt = {}, stack = {}, wf = {:?}", + runner.state().phase, + runner.state().delayed_triggers.len(), + runner.state().stack.len(), + runner.state().waiting_for + ); +} + +#[test] +fn gift_of_immortality_delayed_reattach_shape() { + let parsed = parse_oracle_text( + GIFT_ORACLE, + "Gift of Immortality", + &[], + &["Enchantment".to_string()], + &["Aura".to_string()], + ); + assert_eq!(parsed.triggers.len(), 1); + assert!( + !effect_is_unimplemented(&parsed.triggers[0].execute.as_ref().unwrap().effect), + "Gift dies trigger must be supported" + ); + assert_eq!( + gift_delayed_attach_host(&parsed), + &TargetFilter::ParentTarget, + "Gift delayed Attach host must be ParentTarget (that creature)" + ); +} + +#[test] +fn next_of_kin_delayed_reattach_shape() { + let parsed = parse_oracle_text( + NEXT_OF_KIN_ORACLE, + "Next of Kin", + &[], + &["Enchantment".to_string()], + &["Aura".to_string()], + ); + let trigger = parsed.triggers.first().expect("dies trigger"); + let execute = trigger.execute.as_ref().expect("execute"); + assert!( + !effect_is_unimplemented(&execute.effect), + "Next of Kin must parse supported: {:?}", + execute.effect + ); + let delayed_link = execute + .sub_ability + .as_ref() + .expect("delayed / if-you-do sibling"); + let Effect::CreateDelayedTrigger { + condition, effect, .. + } = delayed_link.effect.as_ref() + else { + panic!( + "Next of Kin 'next end step' must wrap CreateDelayedTrigger (short-name \ + 'next' must not rewrite temporal text); got {:?}", + delayed_link.effect + ); + }; + assert_eq!( + condition, + &DelayedTriggerCondition::AtNextPhase { phase: Phase::End } + ); + let inner = effect.as_ref(); + assert!(inner.forward_result); + // "If you do" gates CreateDelayedTrigger installation, not the delayed body + // at end-step fire time (OptionalEffectPerformed is a creation-time signal). + assert_eq!( + delayed_link.condition.as_ref(), + Some(&AbilityCondition::effect_performed()), + "OptionalEffectPerformed must lift onto the CreateDelayedTrigger wrapper" + ); + assert!( + inner.condition.is_none(), + "delayed payload must not retain OptionalEffectPerformed; got {:?}", + inner.condition + ); + let attach = inner.sub_ability.as_ref().expect("Attach nest"); + match attach.effect.as_ref() { + Effect::Attach { + attachment, + target: TargetFilter::ParentTarget, + } => { + assert_eq!( + attachment, + &TargetFilter::SelfRef, + "Next of Kin Attach attachment must be SelfRef, got {attachment:?}" + ); + } + other => panic!("Next of Kin Attach host must be ParentTarget, got {other:?}"), + } + assert_eq!( + count_cdts(execute), + 1, + "nest-before-wrap must yield a single CreateDelayedTrigger, not two" + ); +} + +#[test] +fn lynde_delayed_reattach_shape() { + let parsed = parse_oracle_text( + LYNDE_ORACLE, + "Lynde, Cheerful Tormentor", + &[], + &["Creature".to_string()], + &["Human".to_string(), "Warlock".to_string()], + ); + let curse_ltb = parsed + .triggers + .iter() + .find(|t| { + matches!( + t.execute.as_ref().map(|e| e.effect.as_ref()), + Some(Effect::CreateDelayedTrigger { .. }) + ) + }) + .expect("Curse LTB delayed trigger"); + let execute = curse_ltb.execute.as_ref().unwrap(); + let Effect::CreateDelayedTrigger { + effect, condition, .. + } = execute.effect.as_ref() + else { + unreachable!() + }; + assert_eq!( + condition, + &DelayedTriggerCondition::AtNextPhase { phase: Phase::End } + ); + let inner = effect.as_ref(); + assert!(inner.forward_result); + let attach = inner.sub_ability.as_ref().expect("Attach nest"); + match (inner.effect.as_ref(), attach.effect.as_ref()) { + ( + Effect::ChangeZone { + destination: Zone::Battlefield, + target: TargetFilter::TriggeringSource, + .. + }, + Effect::Attach { + attachment: TargetFilter::SelfRef, + target: TargetFilter::Controller, + }, + ) => {} + other => panic!("Lynde delayed body shape wrong: {other:?}"), + } +} + +#[test] +fn gift_of_immortality_reattaches_without_aura_choice() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let gift = scenario + .add_creature(P0, "Gift of Immortality", 0, 0) + .as_enchantment() + .with_subtypes(vec!["Aura"]) + .from_oracle_text(GIFT_ORACLE) + .with_keyword(Keyword::Enchant(TargetFilter::Typed( + TypedFilter::creature(), + ))) + .id(); + + let mut runner = scenario.build(); + // `attach_to` returns the prior host (`None` on first attach), not success. + attach_to(runner.state_mut(), gift, host); + assert_eq!( + runner.state().objects[&gift].attached_to, + Some(AttachTarget::Object(host)), + "Gift must start attached to the host" + ); + + let mut events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), host, Zone::Graveyard, &mut events); + process_triggers(runner.state_mut(), &events); + drain_priority(&mut runner); + + assert_eq!( + runner.state().objects[&host].zone, + Zone::Battlefield, + "dies trigger returns the enchanted creature" + ); + assert_eq!( + runner.state().objects[&gift].zone, + Zone::Graveyard, + "Gift is in the graveyard awaiting end-step return" + ); + assert_eq!( + runner.state().delayed_triggers.len(), + 1, + "exactly one AtNextEnd delayed reattach must be installed" + ); + assert_eq!( + runner.state().delayed_triggers[0].ability.targets, + vec![engine::types::ability::TargetRef::Object(host)], + "delayed trigger must snapshot the returned creature for ParentTarget Attach; \ + targets={:?} source={:?} effect={:?}", + runner.state().delayed_triggers[0].ability.targets, + runner.state().delayed_triggers[0].ability.source_id, + runner.state().delayed_triggers[0].ability.effect, + ); + assert_eq!( + runner.state().delayed_triggers[0].ability.source_id, + gift, + "delayed SelfRef source must remain Gift, not the returned creature" + ); + assert!( + matches!( + &runner.state().delayed_triggers[0].ability.effect, + Effect::ChangeZone { + destination: Zone::Battlefield, + target: TargetFilter::SelfRef, + origin: None, + .. + } + ), + "delayed ChangeZone must be SelfRef→BF with no origin guard; got {:?}", + runner.state().delayed_triggers[0].ability.effect + ); + assert!( + matches!( + runner.state().delayed_triggers[0] + .ability + .sub_ability + .as_ref() + .map(|s| &s.effect), + Some(Effect::Attach { + target: TargetFilter::ParentTarget, + .. + }) + ), + "delayed body must nest Attach→ParentTarget; sub={:?}", + runner.state().delayed_triggers[0].ability.sub_ability + ); + runner.advance_to_end_step(); + advance_through_delayed_end(&mut runner); + + assert!( + runner.state().delayed_triggers.is_empty(), + "delayed reattach must have fired; phase={:?} dt={:?} stack={} wf={:?}", + runner.state().phase, + runner.state().delayed_triggers.len(), + runner.state().stack.len(), + runner.state().waiting_for, + ); + let gift_obj = &runner.state().objects[&gift]; + assert_eq!( + gift_obj.zone, + Zone::Battlefield, + "Gift returns at the next end step; attached={:?} core={:?} subtypes={:?} kw={:?} host_zone={:?} wf={:?}", + gift_obj.attached_to, + gift_obj.card_types.core_types, + gift_obj.card_types.subtypes, + gift_obj.keywords, + runner.state().objects[&host].zone, + runner.state().waiting_for, + ); + assert_eq!( + runner.state().objects[&gift].attached_to, + Some(AttachTarget::Object(host)), + "Gift auto-attaches to that creature — no CR 303.4f prompt" + ); +} + +#[test] +fn gift_of_immortality_stays_in_graveyard_when_host_gone() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let gift = scenario + .add_creature(P0, "Gift of Immortality", 0, 0) + .as_enchantment() + .with_subtypes(vec!["Aura"]) + .from_oracle_text(GIFT_ORACLE) + .with_keyword(Keyword::Enchant(TargetFilter::Typed( + TypedFilter::creature(), + ))) + .id(); + + let mut runner = scenario.build(); + attach_to(runner.state_mut(), gift, host); + assert_eq!( + runner.state().objects[&gift].attached_to, + Some(AttachTarget::Object(host)) + ); + + let mut events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), host, Zone::Graveyard, &mut events); + process_triggers(runner.state_mut(), &events); + drain_priority(&mut runner); + + assert_eq!( + runner.state().delayed_triggers.len(), + 1, + "hostile path must install the delayed reattach before the host is exiled; \ + otherwise the negative assertion can pass without exercising CR 303.4i" + ); + assert_eq!( + runner.state().objects[&host].zone, + Zone::Battlefield, + "dies trigger must return the host before the hostile exile" + ); + + // CR 303.4i + Gatherer: exile the returned host before end step → Gift remains in GY. + let mut exile_events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), host, Zone::Exile, &mut exile_events); + + runner.advance_to_end_step(); + advance_through_delayed_end(&mut runner); + + assert_eq!( + runner.state().objects[&gift].zone, + Zone::Graveyard, + "Gift remains in the graveyard when the specified host is undefined/illegal" + ); + assert!( + !matches!( + runner.state().waiting_for, + WaitingFor::ReturnAsAuraTarget { .. } + ), + "must not open Aura host choice when the specified host is gone" + ); +} + +#[test] +fn next_of_kin_attaches_to_put_creature_not_dying_host() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // Dying host A (MV 3); put creature B from hand (MV 2, lesser). + let host_a = scenario + .add_creature(P0, "Hill Giant", 3, 3) + .with_mana_cost(ManaCost::Cost { + shards: vec![], + generic: 3, + }) + .id(); + let put_b = scenario + .add_creature_to_hand(P0, "Grizzly Bears", 2, 2) + .with_mana_cost(ManaCost::Cost { + shards: vec![], + generic: 2, + }) + .id(); + let aura = scenario + .add_creature(P0, "Next of Kin", 0, 0) + .as_enchantment() + .with_subtypes(vec!["Aura"]) + .from_oracle_text(NEXT_OF_KIN_ORACLE) + .with_keyword(Keyword::Enchant(TargetFilter::Typed( + TypedFilter::creature(), + ))) + .id(); + + let mut runner = scenario.build(); + attach_to(runner.state_mut(), aura, host_a); + assert_eq!( + runner.state().objects[&aura].attached_to, + Some(AttachTarget::Object(host_a)) + ); + + let mut events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), host_a, Zone::Graveyard, &mut events); + process_triggers(runner.state_mut(), &events); + drain_priority(&mut runner); + + assert_eq!( + runner.state().objects[&put_b].zone, + Zone::Battlefield, + "Next of Kin puts the lesser-MV creature" + ); + assert_eq!( + runner.state().delayed_triggers.len(), + 1, + "if-you-do delayed reattach installed" + ); + assert!( + runner.state().delayed_triggers[0] + .ability + .condition + .is_none(), + "installed delayed body must not carry OptionalEffectPerformed; got {:?}", + runner.state().delayed_triggers[0].ability.condition + ); + + runner.advance_to_end_step(); + advance_through_delayed_end(&mut runner); + + assert_eq!( + runner.state().objects[&aura].zone, + Zone::Battlefield, + "Next of Kin returns at end step" + ); + assert_eq!( + runner.state().objects[&aura].attached_to, + Some(AttachTarget::Object(put_b)), + "hostile: attach to put creature B, not dying host A" + ); +} + +#[test] +fn lynde_returns_curse_attached_to_controller() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let _lynde = scenario + .add_creature(P0, "Lynde, Cheerful Tormentor", 2, 4) + .from_oracle_text(LYNDE_ORACLE) + .id(); + let curse = scenario + .add_creature(P0, "Curse of Thirst", 0, 0) + .as_enchantment() + .with_subtypes(vec!["Aura", "Curse"]) + .with_keyword(Keyword::Enchant(TargetFilter::Player)) + .id(); + + let mut runner = scenario.build(); + attach_to_player(runner.state_mut(), curse, P0); + assert_eq!( + runner.state().objects[&curse].attached_to, + Some(AttachTarget::Player(P0)) + ); + + let mut events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), curse, Zone::Graveyard, &mut events); + process_triggers(runner.state_mut(), &events); + drain_priority(&mut runner); + + assert_eq!(runner.state().delayed_triggers.len(), 1); + + runner.advance_to_end_step(); + advance_through_delayed_end(&mut runner); + + assert_eq!(runner.state().objects[&curse].zone, Zone::Battlefield); + assert_eq!( + runner.state().objects[&curse].attached_to, + Some(AttachTarget::Player(P0)), + "Lynde returns the Curse attached to you" + ); +} + +#[test] +fn smoke_shroud_and_dragon_breath_attach_host_is_parent_target() { + for (name, oracle) in [ + ("Smoke Shroud", SMOKE_SHROUD_ORACLE), + ("Dragon Breath", DRAGON_BREATH_ORACLE), + ] { + let parsed = parse_oracle_text( + oracle, + name, + &[], + &["Enchantment".to_string()], + &["Aura".to_string()], + ); + assert_eq!( + event_subject_return_attach_host(&parsed), + &TargetFilter::ParentTarget, + "{name}: GY return Attach host must be ParentTarget (that creature)" + ); + } +} + +#[test] +fn cass_preserves_equipment_reattach_continuation() { + let parsed = parse_oracle_text( + CASS_ORACLE, + "Cass, Hand of Vengeance", + &[], + &["Creature".to_string()], + &["Human".to_string(), "Assassin".to_string()], + ); + let execute = parsed + .triggers + .first() + .and_then(|t| t.execute.as_ref()) + .expect("Cass dies trigger"); + assert!( + ability_chain_contains_equipment_attach(execute), + "Cass must preserve Equipment reattach continuation; execute={:?}", + execute.effect + ); + fn find_equipment_attach( + def: &engine::types::ability::AbilityDefinition, + ) -> Option<(TargetFilter, TargetFilter)> { + match def.effect.as_ref() { + Effect::Attach { + attachment: TargetFilter::Typed(tf), + target, + } if tf.type_filters.iter().any( + |f| matches!(f, engine::types::ability::TypeFilter::Subtype(s) if s == "Equipment"), + ) => + { + Some((TargetFilter::Typed(tf.clone()), target.clone())) + } + _ => def + .sub_ability + .as_deref() + .and_then(find_equipment_attach) + .or_else(|| def.else_ability.as_deref().and_then(find_equipment_attach)), + } + } + let (attachment, target) = + find_equipment_attach(execute).expect("Equipment Attach in Cass chain"); + match &attachment { + TargetFilter::Typed(tf) => { + assert!( + tf.properties + .contains(&engine::types::ability::FilterProp::AttachedToSource), + "Equipment must look back via AttachedToSource LKI, got {tf:?}" + ); + } + other => panic!("expected Typed Equipment, got {other:?}"), + } + assert!( + matches!(target, TargetFilter::ParentTarget), + "Cass 'to that creature' must be ParentTarget (chosen Aura host), got {target:?}" + ); +} + +#[test] +fn storm_herald_preserves_delayed_exile_continuation() { + let parsed = parse_oracle_text( + STORM_HERALD_ORACLE, + "Storm Herald", + &[], + &["Creature".to_string()], + &["Human".to_string(), "Shaman".to_string()], + ); + let execute = parsed + .triggers + .first() + .and_then(|t| t.execute.as_ref()) + .expect("Storm Herald ETB"); + assert!( + ability_chain_contains_delayed_exile(execute), + "Storm Herald must preserve delayed exile continuation; execute={:?}", + execute.effect + ); + fn find_cdt( + def: &engine::types::ability::AbilityDefinition, + ) -> Option<&engine::types::ability::AbilityDefinition> { + if matches!(def.effect.as_ref(), Effect::CreateDelayedTrigger { .. }) { + return Some(def); + } + def.sub_ability + .as_deref() + .and_then(find_cdt) + .or_else(|| def.else_ability.as_deref().and_then(find_cdt)) + } + let cdt = find_cdt(execute).expect("CreateDelayedTrigger"); + let Effect::CreateDelayedTrigger { + uses_tracked_set, + effect, + .. + } = cdt.effect.as_ref() + else { + unreachable!() + }; + assert!( + *uses_tracked_set, + "Storm Herald 'those Auras' delayed exile must set uses_tracked_set" + ); + assert!( + matches!( + effect.effect.as_ref(), + Effect::ChangeZone { + destination: Zone::Exile, + target: TargetFilter::TrackedSet { .. }, + .. + } + ), + "delayed body must exile TrackedSet, got {:?}", + effect.effect + ); + // Leave-battlefield rider must be AddTargetReplacement (TrackedSet), not a + // fake immediate ChangeZone Exile ParentTarget claiming support. + fn find_leave_rider(def: &engine::types::ability::AbilityDefinition) -> Option<&Effect> { + match def.effect.as_ref() { + e @ Effect::AddTargetReplacement { .. } => Some(e), + e @ Effect::Unimplemented { .. } => Some(e), + e @ Effect::ChangeZone { + destination: Zone::Exile, + target: TargetFilter::ParentTarget, + .. + } => Some(e), + _ => def + .sub_ability + .as_deref() + .and_then(find_leave_rider) + .or_else(|| def.else_ability.as_deref().and_then(find_leave_rider)), + } + } + match find_leave_rider(execute) { + Some(Effect::AddTargetReplacement { + target: TargetFilter::TrackedSet { .. }, + .. + }) => {} + Some(Effect::Unimplemented { .. }) => {} + Some(other) => panic!( + "leave-battlefield rider must be AddTargetReplacement{{TrackedSet}} or \ + Unimplemented, got {other:?}" + ), + None => panic!("expected leave-battlefield rider in Storm Herald chain"), + } +} + +#[test] +fn cass_reattaches_equipment_to_chosen_host_via_pipeline() { + // CR 400.7j + CR 608.2c + CR 701.3a: drive the printed Cass dies trigger + // through process_triggers → TriggerTargetSelection → resolution. ParentTarget + // for the Equipment attach must bind the chosen host without a test-side + // stamp_host helper. + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let bearer = scenario.add_creature(P0, "Bearer", 2, 2).id(); + let cass = scenario + .add_creature(P0, "Cass, Hand of Vengeance", 2, 2) + .from_oracle_text(CASS_ORACLE) + .id(); + let equipment = scenario + .add_creature(P0, "Bonesplitter", 0, 0) + .as_artifact() + .with_subtypes(vec!["Equipment"]) + .id(); + + let mut runner = scenario.build(); + attach_to(runner.state_mut(), equipment, cass); + + let mut death_events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), cass, Zone::Graveyard, &mut death_events); + process_triggers(runner.state_mut(), &death_events); + assert!( + !runner.state().stack.is_empty() + || matches!( + runner.state().waiting_for, + WaitingFor::TriggerTargetSelection { .. } | WaitingFor::OrderTriggers { .. } + ), + "Cass dies trigger must be pending after equipped Cass dies; \ + stack={} waiting={:?}", + runner.state().stack.len(), + runner.state().waiting_for + ); + drain_priority_preferring(&mut runner, &[bearer, equipment]); + + assert_eq!( + runner.state().objects[&equipment].attached_to, + Some(AttachTarget::Object(bearer)), + "Equipment that was attached to dying Cass must reattach to chosen bearer; \ + attached_to={:?}", + runner.state().objects[&equipment].attached_to + ); + assert!( + !matches!( + runner.state().waiting_for, + WaitingFor::ReturnAsAuraTarget { .. } + ), + "must not open CR 303.4f Aura host choice for Equipment reattach" + ); +} + +#[test] +fn storm_herald_exiles_returned_auras_at_end_step_via_pipeline() { + // CR 603.7 + CR 303.4f: return Aura attached to a creature you control, then + // delayed TrackedSet exile at next end step. + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let aura = scenario + .add_creature_to_graveyard(P0, "Pacifism", 0, 0) + .as_enchantment() + .with_subtypes(vec!["Aura"]) + .with_keyword(Keyword::Enchant(TargetFilter::Typed( + TypedFilter::creature(), + ))) + .id(); + let herald = scenario + .add_creature_to_hand(P0, "Storm Herald", 3, 2) + .from_oracle_text(STORM_HERALD_ORACLE) + .id(); + + let mut runner = scenario.build(); + let mut etb_events = Vec::new(); + engine::game::zones::move_to_zone( + runner.state_mut(), + herald, + Zone::Battlefield, + &mut etb_events, + ); + process_triggers(runner.state_mut(), &etb_events); + drain_priority_preferring(&mut runner, &[aura, host]); + + assert_eq!( + runner.state().objects[&aura].zone, + Zone::Battlefield, + "Storm Herald must return the Aura" + ); + assert_eq!( + runner.state().objects[&aura].attached_to, + Some(AttachTarget::Object(host)), + "returned Aura must attach to a creature you control" + ); + assert_eq!( + runner.state().delayed_triggers.len(), + 1, + "delayed exile of those Auras must be installed; delayed={:?}", + runner.state().delayed_triggers + ); + match &runner.state().delayed_triggers[0].ability.effect { + Effect::ChangeZone { + destination: Zone::Exile, + target: TargetFilter::TrackedSet { id }, + .. + } + | Effect::ChangeZoneAll { + destination: Zone::Exile, + target: TargetFilter::TrackedSet { id }, + .. + } => { + assert_ne!( + id.0, 0, + "TrackedSet sentinel must be rebound at CDT creation; id={id:?}" + ); + assert!( + runner + .state() + .tracked_object_sets + .get(id) + .is_some_and(|set| set.contains(&aura)), + "rebound TrackedSet must contain the returned Aura; set={:?}", + runner.state().tracked_object_sets.get(id) + ); + } + other => panic!("delayed body must be exile TrackedSet, got {other:?}"), + } + + // CR 603.7: Resolve the installed delayed body through the production + // effect pipeline (same path end-step firing uses). Turn-gate timing for + // AtNextPhaseForPlayer is covered by the delayed-trigger suite; here we + // prove the TrackedSet bind + exile semantics for Storm Herald's rem. + let delayed = runner.state().delayed_triggers[0].ability.clone(); + runner.state_mut().delayed_triggers.clear(); + let mut events = Vec::new(); + engine::game::effects::resolve_ability_chain(runner.state_mut(), &delayed, &mut events, 0) + .expect("delayed exile resolves"); + drain_priority_preferring(&mut runner, &[host]); + + assert_eq!( + runner.state().objects[&aura].zone, + Zone::Exile, + "those Auras must be exiled via the delayed TrackedSet body" + ); +} + +#[test] +fn smoke_shroud_attaches_to_entering_ninja_among_multiple_hosts() { + // CR 303.4f + CR 608.2c: with another legal Aura host on the battlefield, + // the event-subject return must bind ParentTarget to the entering Ninja — + // no CR 303.4f prompt, and not the distractor creature. + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let distractor = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let ninja = scenario + .add_creature_to_hand(P0, "Ninja of the Deep Hours", 2, 2) + .with_subtypes(vec!["Human", "Ninja"]) + .id(); + let aura = scenario + .add_creature(P0, "Smoke Shroud", 0, 0) + .as_enchantment() + .with_subtypes(vec!["Aura"]) + .from_oracle_text(SMOKE_SHROUD_ORACLE) + .with_keyword(Keyword::Enchant(TargetFilter::Typed( + TypedFilter::creature(), + ))) + .id(); + + let mut runner = scenario.build(); + // Prior host so AttachedTo fallback would prefer the distractor if the + // event referent is not hydrated onto the nested Attach. + attach_to(runner.state_mut(), aura, distractor); + let mut gy_events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), aura, Zone::Graveyard, &mut gy_events); + process_triggers(runner.state_mut(), &gy_events); + drain_priority(&mut runner); + assert_eq!(runner.state().objects[&aura].zone, Zone::Graveyard); + + let mut etb_events = Vec::new(); + engine::game::zones::move_to_zone( + runner.state_mut(), + ninja, + Zone::Battlefield, + &mut etb_events, + ); + process_triggers(runner.state_mut(), &etb_events); + drain_priority(&mut runner); + + assert_eq!( + runner.state().objects[&aura].zone, + Zone::Battlefield, + "Smoke Shroud returns from GY on Ninja ETB" + ); + assert_eq!( + runner.state().objects[&aura].attached_to, + Some(AttachTarget::Object(ninja)), + "must attach to the entering Ninja, not the distractor ({distractor:?}); \ + attached_to={:?}", + runner.state().objects[&aura].attached_to + ); + assert!( + !matches!( + runner.state().waiting_for, + WaitingFor::ReturnAsAuraTarget { .. } + ), + "must not open CR 303.4f Aura host choice among multiple legal hosts" + ); +} + +#[test] +fn necrotic_plague_attaches_to_chosen_creature_not_dying_host() { + // CR 608.2c + CR 303.4f: Necrotic Plague nests Attach→ParentTarget under a + // TargetOnly→ChangeZone chain. Drive the printed dies trigger through + // process_triggers → TriggerTargetSelection → resolution with distinct dying + // and chosen creatures — no stamp_host / hand-built ResolvedAbility. + let parsed = parse_oracle_text( + NECROTIC_PLAGUE_ORACLE, + "Necrotic Plague", + &[], + &["Enchantment".to_string()], + &["Aura".to_string()], + ); + let dies = parsed + .triggers + .iter() + .find(|t| { + matches!( + t.execute.as_ref().map(|e| e.effect.as_ref()), + Some(Effect::TargetOnly { .. }) + ) + }) + .expect("dies trigger"); + let execute = dies.execute.as_ref().expect("execute"); + assert!( + execute.forward_result + || execute + .sub_ability + .as_ref() + .is_some_and(|s| s.forward_result), + "Necrotic Plague return must forward_result into Attach; execute={:?}", + execute.effect + ); + fn change_zone_has_forward_result(def: &engine::types::ability::AbilityDefinition) -> bool { + let here = matches!( + def.effect.as_ref(), + Effect::ChangeZone { + destination: Zone::Battlefield, + .. + } + ) && def.forward_result; + here || def + .sub_ability + .as_deref() + .is_some_and(change_zone_has_forward_result) + || def + .else_ability + .as_deref() + .is_some_and(change_zone_has_forward_result) + } + assert!( + change_zone_has_forward_result(execute), + "TargetOnly→ChangeZone[+Attach] must stamp forward_result on ChangeZone; execute={:?}", + execute.effect + ); + fn find_attach_parent(def: &engine::types::ability::AbilityDefinition) -> bool { + matches!( + def.effect.as_ref(), + Effect::Attach { + target: TargetFilter::ParentTarget, + .. + } + ) || def.sub_ability.as_deref().is_some_and(find_attach_parent) + || def.else_ability.as_deref().is_some_and(find_attach_parent) + } + assert!( + find_attach_parent(execute), + "Necrotic Plague must nest Attach→ParentTarget; execute={:?}", + execute.effect + ); + assert!( + dies.trigger_zones.contains(&Zone::Battlefield), + "AttachedTo dies trigger must fire from the battlefield (Gift-shaped), not only GY; \ + trigger_zones={:?}", + dies.trigger_zones + ); + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let dying = scenario.add_creature(P0, "Dying Host", 2, 2).id(); + let chosen = scenario.add_creature(P1, "Chosen Host", 2, 2).id(); + let other_opp = scenario.add_creature(P1, "Other Opp Creature", 2, 2).id(); + let plague = scenario + .add_creature(P0, "Necrotic Plague", 0, 0) + .as_enchantment() + .with_subtypes(vec!["Aura"]) + .from_oracle_text(NECROTIC_PLAGUE_ORACLE) + .with_keyword(Keyword::Enchant(TargetFilter::Typed( + TypedFilter::creature(), + ))) + .id(); + + let mut runner = scenario.build(); + attach_to(runner.state_mut(), plague, dying); + + // Mirror Gift: collect dies triggers while the Aura is still on the battlefield + // (CR 603.6d LKI); SBAs during drain move it to the GY before resolution. + let mut death_events = Vec::new(); + engine::game::zones::move_to_zone( + runner.state_mut(), + dying, + Zone::Graveyard, + &mut death_events, + ); + process_triggers(runner.state_mut(), &death_events); + assert!( + !runner.state().stack.is_empty() + || matches!( + runner.state().waiting_for, + WaitingFor::TriggerTargetSelection { .. } | WaitingFor::OrderTriggers { .. } + ), + "Necrotic dies trigger must be pending after enchanted creature dies; \ + stack={} waiting={:?}", + runner.state().stack.len(), + runner.state().waiting_for + ); + // CR 704.5m + CR 704.3: Aura with illegal/dead host goes to GY before the + // pending trigger resolves. The printed return is "from its owner's + // graveyard", so ChangeZone's origin guard needs the Aura in GY first. + engine::game::sba::check_state_based_actions(runner.state_mut(), &mut death_events); + assert_eq!( + runner.state().objects[&plague].zone, + Zone::Graveyard, + "SBA must put Necrotic Plague into GY before its return resolves" + ); + drain_priority_preferring(&mut runner, &[chosen]); + + assert_eq!( + runner.state().objects[&plague].zone, + Zone::Battlefield, + "Necrotic Plague returns from GY" + ); + assert_eq!( + runner.state().objects[&plague].attached_to, + Some(AttachTarget::Object(chosen)), + "must attach to the chosen opponent creature ({chosen:?}), not the dying host \ + ({dying:?}) or distractor ({other_opp:?}); attached_to={:?}", + runner.state().objects[&plague].attached_to + ); + assert!( + !matches!( + runner.state().waiting_for, + WaitingFor::ReturnAsAuraTarget { .. } + ), + "must not open CR 303.4f Aura host choice when ParentTarget is the chosen creature" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index f708b60fb3..1eee9562e3 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -540,6 +540,7 @@ mod issue_4835_intimidation_tactics; mod issue_4836_mindskinner; mod issue_4921_skullscorch_unless_deal_damage; mod issue_4955_greenbelt_rampager; +mod issue_4956_gift_of_immortality_reattach; mod issue_4960_nova_flame; mod issue_4962_volo_guide_to_monsters; mod issue_4966_waterbenders_ascension; From d05ee575d6549713e46eb91c2c915516c11f6ba0 Mon Sep 17 00:00:00 2001 From: Bradd McBrearty <35860549+mcbradd@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:44:15 -0700 Subject: [PATCH 47/63] perf(ai,engine): measurement-mode batch determinism, mimalloc, incremental layers flush, and pod-lab loop-3 speed/telemetry (#6777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ai-commander): run games in measurement mode for cross-game batch reproducibility --games-file batch mode diverged from single-game mode: a game that won cleanly run solo stalled at turn 60 when played 3rd in a batch (pod-lab equivalence gate, phase#6252). Root cause is NOT leaked state but the AI search's wall-clock deadline. ai_commander built each seat's AiConfig in ExecutionMode::Interactive with time_budget_ms = AI_SEARCH_TIME_BUDGET_MS (1500ms), so both search paths (search.rs, planner::with_deadline) used Deadline::after(1500ms). A warmer, slower Nth-in-batch process (measured: 74.1s vs 63.4s at an IDENTICAL board state) expired that deadline on a mid-game decision the fresh solo process completed in full, collapsing search to the degraded tactical-only floor and picking a different, sometimes unpayable move -> divergence -> stall. Two equally-fast solo runs never straddle the budget, so they byte-match; only the slower batched run diverges. Fix: build_seat_config applies .into_measurement(seed), forcing ExecutionMode::Measurement -> Deadline::none() (both search paths gate on is_measurement) -> search bounded solely by max_nodes/max_depth, a pure function of (seed, difficulty, feed). Mirrors the established duel_suite::run batch harness, whose config uses .into_measurement(seed) for the same documented "eliminate wall-clock flake" reason. Covers single-game mode too (both paths go through play_one_game), so batch game N is bit-identical to the same game solo. Tests: a deterministic unit test asserting every seat runs in measurement mode (fails against the unfixed Interactive code); plus an integration test encoding the repro shape (target game run 3rd in a batch == the same game run alone, byte-identical RESULT block). Co-Authored-By: Claude Fable 5 * feat(ai): add opt-in --watch-cards swap-liveness telemetry to ai-commander pod-lab loop-3 Q3(b): restores a per-game "was this card ever drawn or cast" signal for the harness's swap comparisons, after loop-2 found divergence rate alone is useless for this (a single-card deck swap reorders the entire shuffled library, so divergence is 100% regardless of whether the changed card was ever seen -- see the design spike at docs/q3-swap-liveness-spike.md in the pod-lab repo). Purpose-built over two rejected alternatives: slot-stable shuffling (would be a new engine pin, invalidating every cached pod-lab baseline, for a correctness-burden the payoff didn't justify) and turning on the existing --dump-log/PHASE_DUMP_LOG full structured-log collection (pays for every turn/phase/stack/zone/life/mana entry for the whole game to answer a one-bit-per-watched-card question). `--watch-cards "Name A,Name B"` populates a per-game HashSet, matched by name (not CardId -- CardId is assigned per physical-card-object at deck load, not a stable per-name identity, while every GameObject already carries its own resolved name). The match runs on SpellCast/CardDrawn events already produced every action by the driver loop -- record_watched_cards is O(1) per event and is skipped entirely, not just a no-op HashSet lookup, when no names are requested, so a run that doesn't pass the flag pays nothing. Emits one `PODLAB-TELEM {"cards_seen": [...]}` line inside the existing === RESULT === summary block, only when --watch-cards is non-empty. Verified against pod-lab's runner.py/mechanisms.py regexes directly (not just by inspection) that this collides with none of them: the _TURN/_PROGRESS patterns both require a literal "Turn " prefix this line never has, and the line contains none of "Winner: "/"Difficulty: "/ "ABORT: hit "/"did NOT reach GameOver". Confirmed end-to-end against a real built binary (seed 7, both a true-negative and, via the RESULT block placement, correct positioning relative to the existing Elapsed/Total actions/Turns played lines). record_watched_cards is unit-tested directly (SpellCast + CardDrawn matched by name, an unwatched card and an unrelated event variant both proven to be no-ops, and an empty-results no-op case) using a minimal GameState + GameObject::new fixture -- no card database or deck loading needed, mirroring this file's existing fixture_db()-free test style where the logic under test doesn't require it. Local branch only -- no PR without separate explicit authorization (the pod-lab plan's E6 constraint: this session has authority to build, pin, and measure phase.rs changes from a local branch, not to open or merge upstream). Co-Authored-By: Claude Sonnet 5 * perf(ai): mimalloc for all native phase-ai binaries; fix panic=abort doc bug pod-lab loop-3 Q5, part 1 of 2 (mimalloc + build-recipe fix; the layers.rs incremental-flush lever is a separate, more involved change gated on its own review round -- see the plan/review transcript for why it isn't landing in this commit). mimalloc is target-gated in Cargo.toml (`[target.'cfg(not(target_arch = "wasm32"))'.dependencies]`), not a plain [dependencies] entry: engine-wasm and draft-wasm both depend on this crate's lib for their wasm32 builds, which are explicitly size-tuned (opt-level='z', the #6313 25 MiB pages-deploy guard) -- an ungated entry would pull mimalloc's C sources into those builds too. Verified both ways: `cargo tree -i mimalloc --target wasm32-unknown- unknown` resolves empty for both wasm crates, and `cargo check -p engine-wasm --target wasm32-unknown-unknown` still builds clean. Every native bin in crates/phase-ai/src/bin/ gets a #[global_allocator] declaration -- all 12 files, not just the 9 with an explicit [[bin]] Cargo.toml entry. declare_attackers_bench.rs/pass_priority_bench.rs/ resolve_bench.rs are picked up by Cargo's default bin auto-discovery with no [[bin]] stanza of their own; a plan that enumerated only the explicit entries would have silently skipped them. Also fixes a real bug found while verifying the PGO half of this plan (not implemented in this commit -- that's a build-recipe change, not a source change, and depends on pod-lab's actual invocation, out of scope for this repo): ai_commander.rs's own doc comment documented `cargo run --release`, but [profile.release] sets panic='abort' (it exists to keep the WASM build small), which silently defeats run_batch_isolated's catch_unwind-based per-game panic isolation -- under abort, one game's panic takes the whole batch process down instead of being caught and reported via the "GAME
-### 15. Multi-target / 'up to N' optionality or count dropped (89 cards) +### 15. Multi-target / 'up to N' optionality or count dropped (83 cards) **Signature.** MultiTargetSpec / 'up to one/two target' optionality dropped to a mandatory single Typed target (or collapsed into DamageAll), losing the multi_target / up_to slot and per-target distinctness. @@ -4217,7 +4217,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top
Cards - A-Incriminate -- Batroc the Leaper - Blue Dragon - Bon... placeholder - Bonfire of the Damned @@ -4232,7 +4231,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Capricious Efreet - Cetavolver - Chandra, Flame's Catalyst -- Chandra, Hope's Beacon - Chandra, Roaring Flame - Chaotic Transformation - Clattering Augur @@ -4254,8 +4252,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Invent - Ioreth of the Healing House - Jace, Ingenious Mind-Mage -- Jagged Lightning -- Jaya's Immolating Inferno - Journey of Discovery - Magus of the Candelabra - March of Reckless Joy @@ -4269,7 +4265,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Nimbleclaw Adept - Nomad Decoy - Perpetual Timepiece -- Pinnacle of Rage - Primal Might - Pull from the Deep - Put Away @@ -4285,7 +4280,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Rimehorn Aurochs - Risky Move - Sex Appeal -- Shower of Coals - Simoon - Soratami Mirror-Mage - Soratami Seer From 02760f58841457847f535a6f54aa480ae8e22af5 Mon Sep 17 00:00:00 2001 From: Clayton <118192227+claytonlin1110@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:43:15 -0500 Subject: [PATCH 55/63] fix(parser): resolve bare 'they may' pronoun subject as optional TriggeringPlayer (#6823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(parser): resolve bare 'they may' pronoun subject as optional TriggeringPlayer Wandering Archaic's "Whenever an opponent casts an instant or sorcery spell, they may pay {2}. If they don't, you may copy that spell." never worked: the payer resolved to the ability's Controller (the Wandering Archaic player) instead of the casting opponent, and the PayCost step was mandatory instead of optional, so the "if they don't" copy branch could never fire correctly. parse_subject_application already recognized "that player may pay" (Smothering Tithe, Mind Whip) as an optional-modal subject phrase, but the equivalent bare-pronoun phrasing "they may pay" had no matching arm — only the exact string "they" (without a trailing "may") was accepted, so "they may" fell through with no match and the payer/ optionality defaulted to Controller/false. Add a "they may" branch that reuses the existing resolve_they_pronoun dispatch and threads through is_optional, mirroring the "that player may" handling. Fixes #6477. Co-Authored-By: Claude Sonnet 5 * fix(engine): correct CR citation and add production-path coverage for Wandering Archaic Review follow-up on the "they may pay" parser fix: - oracle_trigger_tests.rs cited CR 608.2k for the "they" pronoun resolution, but that rule governs an untargeted object reference persisting through characteristic changes, not player-pronoun resolution. Restrict the citation to the CopySpell "that spell" object reference it actually supports, and cite CR 608.2d (the optional-choice rule) for the "may pay" optionality. - The existing parser test only asserted the lowered AST shape (payer + optionality), not runtime behavior. Add issue_6477_wandering_archaic_optional_payment.rs: an opponent casts an instant through the real apply pipeline, and both branches are exercised — declining the {2} payment offers the controller the copy (accepting deals a second instance of damage), and paying deducts the mana and suppresses the copy entirely. Co-Authored-By: Claude Sonnet 5 * fix(engine): non-discriminating test fix + citation cleanup + class coverage Second review round on the Wandering Archaic "they may pay" fix: - drive_to_idle silently sent `accept: false` for any later OptionalEffectChoice, so a regression that incorrectly re-offered the copy after the opponent paid would still land on the same "2 damage" outcome as a correctly-suppressed copy — the paid-path test couldn't actually discriminate. Make it panic on any further OptionalEffectChoice instead: every decision each test cares about is already made explicitly before drive_to_idle runs, so a further prompt is by construction unexpected. - The integration test cited CR 608.2d for the claim that "they" identifies the casting opponent (P1). CR 608.2d governs how an effect's offered choices are announced during resolution, not who a pronoun refers to — that's a parser fact, already covered by wandering_archaic_they_pay_as_triggering_player. Reworded to attribute the identity claim to the parser, not the rule text. - A before/after parse diff surfaced four other printed cards whose parsing changed: Mishra's Command, Undercity Plunder, Tarnation, and Smart Ass. All four share the exact same "bare they + may" pattern Wandering Archaic uses, and were previously broken the same way — parse_subject_application had no match for "they may", so the caller's fallback silently substituted an unbound TargetFilter::Any target and a non-optional ability. The fix's wider blast radius is the intended class fix, not a regression; added four unit tests locking in the corrected (bound + optional) shape for each. Co-Authored-By: Claude Sonnet 5 * fix(parser): carry defending-player scope through trigger-relative context Third review round on the "they may pay" parser fix: Smart Ass's "If defending player has no cards ..., they may reveal their hand" set relative_player_scope from the trigger's own head condition only ("whenever this creature attacks", which names no player). The defending-player reference lives in a per-clause conditional buried later in the effect body, past an intervening imperative, so the existing single-authority relative_player_scope_for_condition never saw it. resolve_they_pronoun also had no ControllerRef::DefendingPlayer arm at all, so "they" fell through to the generic ParentTarget default — plausible-looking (not Any) but still wrong, since there's no prior target for "defending player" to inherit. Added effect_body_introduces_defending_player to detect a body-level "if [the] defending player" conditional and carry that scope through when the head condition didn't already establish one, plus the missing resolve_they_pronoun arm mapping DefendingPlayer to TargetFilter::DefendingPlayer (CR 506.2, CR 508.5). A before/after parse-diff audit of every printed card containing "if defending player" (19 cards) found exactly two real changes: Smart Ass's RevealHand target, and a second independent bug in Siege Dragon — "that player controls" resolved to the attacker (ControllerRef::You) instead of the defending player, damaging the attacker's own creatures instead of the opponent's. Every other card in the audit parsed identically, confirming the fix's scope. Added tests for both, plus a sibling case confirming a different relative-player scope (TargetPlayer, from "deals combat damage to a player") still resolves correctly and isn't captured by the new arm. Co-Authored-By: Claude Sonnet 5 * test(engine): add Elder Brain production regression, dedupe scope scanner Fourth review round on the Wandering Archaic parser fix: - Elder Brain ("Whenever this creature attacks a player, exile all cards from that player's hand, then they draw that many cards...") changed under the parse-diff (Draw.target: ParentTarget -> DefendingPlayer) but had no dedicated regression. Unlike Smart Ass and Siege Dragon, Elder Brain's trigger condition itself ("attacks a player") is already recognized by the pre-existing condition_introduces_defending_player check — what was missing was purely the resolve_they_pronoun arm, which now reads that pre-existing scope the same way it reads the new effect_body_introduces_defending_player-derived one. Added a test exercising this route specifically, distinct from the body- conditional route the other two tests cover. - effect_body_introduces_defending_player duplicated the word-boundary scan loop already shared by oracle_nom::primitives:: scan_at_word_boundaries. Replaced the hand-rolled loop with the shared combinator. Co-Authored-By: Claude Sonnet 5 * fix(PR-6823): annotate Elder Brain defending player test --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: matthewevans --- .../src/parser/oracle_effect/subject.rs | 29 +- crates/engine/src/parser/oracle_trigger.rs | 31 ++ .../engine/src/parser/oracle_trigger_tests.rs | 353 ++++++++++++++++++ ...6477_wandering_archaic_optional_payment.rs | 308 +++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 5 files changed, 720 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/integration/issue_6477_wandering_archaic_optional_payment.rs diff --git a/crates/engine/src/parser/oracle_effect/subject.rs b/crates/engine/src/parser/oracle_effect/subject.rs index 7dac435c22..d5d180f6fa 100644 --- a/crates/engine/src/parser/oracle_effect/subject.rs +++ b/crates/engine/src/parser/oracle_effect/subject.rs @@ -2836,13 +2836,24 @@ pub(super) fn parse_subject_application( // In trigger effects: "they" refers to the triggering player (for player-type // subjects like "an opponent") or the triggering source (for object subjects). // Outside trigger context: anaphoric reference to previously mentioned objects. - if lower == "they" { + // CR 608.2d: an optional "may" modal parallels the "that player may " / + // "the player may " forms above — "they may pay {2}" (Wandering Archaic, + // Umbilicus) is the pronoun-subject counterpart of "that player may pay + // {2}" (Smothering Tithe, Mind Whip); both must set `is_optional` so + // `lower_subject_predicate_ast` marks the lowered ability optional and + // `resolve_they_pronoun`'s existing player/object dispatch is unchanged. + if let Ok((_, is_optional)) = all_consuming(alt(( + value(true, tag::<_, _, OracleError<'_>>("they may")), + value(false, tag("they")), + ))) + .parse(lower.as_str()) + { return Some(SubjectApplication { affected: resolve_they_pronoun(ctx), target: None, multi_target: None, inherits_parent: false, - is_optional: false, + is_optional, }); } @@ -3143,6 +3154,20 @@ fn resolve_they_pronoun(ctx: &mut ParseContext) -> TargetFilter { ) { return TargetFilter::ParentTargetOwner; } + // CR 506.2 + CR 508.5: An attack-trigger intervening-if that names + // "defending player" (`condition_introduces_defending_player`) stamps + // `relative_player_scope = DefendingPlayer` — the nonactive player being + // attacked, not a chosen or previously-targeted player. "They" inside + // such an effect ("they may reveal their hand" — Smart Ass) refers to + // that combat-relative player. Without this arm, "they" fell through to + // the generic `ParentTarget` default, which has no defending-player + // referent to inherit and left the effect unbound. + if matches!( + ctx.relative_player_scope, + Some(ControllerRef::DefendingPlayer) + ) { + return TargetFilter::DefendingPlayer; + } // CR 603.7c + CR 120.3 + CR 506.2: A "deals [combat] damage to a player" or // "attacks a player" trigger introduces the damaged/attacked player as the // event referent (the parser stamps `relative_player_scope = TargetPlayer`). diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index 8c0a256f3d..6864d7dd3f 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -977,6 +977,29 @@ fn condition_introduces_defending_player(cond_lower: &str) -> bool { false } +fn parse_if_defending_player(input: &str) -> OracleResult<'_, ()> { + let (rest, ()) = value((), tag::<_, _, OracleError<'_>>("if ")).parse(input)?; + let (rest, _) = opt(tag("the ")).parse(rest)?; + value((), tag("defending player")).parse(rest) +} + +/// CR 506.2 + CR 508.5: An attack-trigger's effect body may name "defending +/// player" as the subject of a per-clause conditional AFTER an intervening +/// imperative ("Whenever ~ attacks, choose a card name. If defending player +/// has no cards ..., they may reveal their hand." — Smart Ass), rather than in +/// the trigger's own head condition. `condition_introduces_defending_player` +/// only sees the head (`cond_lower` is everything before the FIRST comma — +/// CR 603.4's `split_trigger` boundary), so it never observes a defending- +/// player conditional buried later in the effect text. Detecting that +/// separately lets a later "they"/"that player" anaphor in the SAME effect +/// body resolve to `ControllerRef::DefendingPlayer` +/// (`resolve_they_pronoun`'s dedicated arm) instead of falling through to the +/// generic `ParentTarget` default, which has no defending-player referent to +/// inherit. +fn effect_body_introduces_defending_player(effect_lower: &str) -> bool { + nom_primitives::scan_at_word_boundaries(effect_lower, parse_if_defending_player).is_some() +} + /// CR 508.1 + CR 603.2c: "Whenever a player attacks with [N or more] creatures, /// ... that player ..." introduces the ATTACKING player (TriggeringPlayer) as the /// relative-player anaphor for a trailing "that player"/"that player controls" @@ -1327,6 +1350,14 @@ pub(crate) fn parse_trigger_line_with_index_ir( // split path derives the identical scope from the same condition. if let Some(scope) = relative_player_scope_for_condition(&cond_lower) { effect_ctx.relative_player_scope = Some(scope); + } else if effect_body_introduces_defending_player(&effect_lower) { + // CR 506.2 + CR 508.5: the head condition names no relative player + // (`cond_lower` is just "whenever ~ attacks"), but the effect body's + // own per-clause conditional names "defending player" later on + // (Smart Ass). Carry that scope through so a "they"/"that player" + // anaphor in the same body resolves to the combat-relative defending + // player instead of the generic `ParentTarget` fallback. + effect_ctx.relative_player_scope = Some(ControllerRef::DefendingPlayer); } // CR 701.22a + CR 603.2: The completed-scry predicate establishes the // provenance for its "that many" effect body. Keep this as a pure match on diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index 16c5194245..9927388818 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -21888,6 +21888,63 @@ fn smothering_tithe_that_player_pays_as_triggering_player() { } } +/// CR 608.2d + CR 608.2k (issue #6477): the bare-pronoun counterpart of +/// `smothering_tithe_that_player_pays_as_triggering_player` — "they may pay" +/// anaphors back to "an opponent" from the trigger condition and must resolve +/// identically to the explicit "that player may pay" phrasing: the opponent +/// who cast the spell pays (not the Wandering Archaic controller), and the +/// payment is optional (CR 608.2d) so a decline can gate the copy. The copy's +/// "that spell" target is the untargeted spell object the trigger condition +/// already named, carried forward per CR 608.2k. +#[test] +fn wandering_archaic_they_pay_as_triggering_player() { + let def = parse_trigger_line( + "Whenever an opponent casts an instant or sorcery spell, they may pay {2}. If they don't, you may copy that spell. You may choose new targets for the copy.", + "Wandering Archaic", + ); + + assert_eq!(def.mode, TriggerMode::SpellCast); + let execute = def.execute.as_ref().expect("should have execute"); + match &*execute.effect { + Effect::PayCost { + payer, + cost: AbilityCost::Mana { cost }, + .. + } => { + assert_eq!( + payer, + &TargetFilter::TriggeringPlayer, + "the opponent who cast the spell pays, not the Wandering Archaic controller" + ); + assert_eq!(cost, &crate::types::mana::ManaCost::generic(2)); + } + other => panic!("expected PayCost, got: {other:?}"), + } + assert!(execute.optional, "they may pay should be optional"); + + let sub = execute + .sub_ability + .as_ref() + .expect("copy should remain chained"); + assert_eq!( + sub.condition, + Some(AbilityCondition::Not { + condition: Box::new(AbilityCondition::effect_performed()) + }), + "the copy is gated on the opponent having declined payment" + ); + assert!(sub.optional, "you may copy that spell"); + match &*sub.effect { + Effect::CopySpell { + target, retarget, .. + } => { + assert_eq!(target, &TargetFilter::TriggeringSource); + assert_eq!(retarget, &CopyRetargetPermission::MayChooseNewTargets); + } + other => panic!("expected CopySpell sub_ability, got: {other:?}"), + } +} + /// CR 603.4: Wedding Ring — "an opponent who controls F draws /// a card" parses the relative clause into an `ObjectCount >= 1` /// intervening-if scoped to the triggering player, ANDed with the @@ -25701,3 +25758,299 @@ fn thieving_skydiver_dependent_continuation_is_never_replicated_or_branch() { "reach guard: Thieving Skydiver must build a multi-node chain (GainControl + Attach continuation), got {node_count}", ); } + +// --------------------------------------------------------------------------- +// CR 608.2c + CR 608.2d (issue #6477 review follow-up): the "they may" subject +// arm in `subject.rs` is a class fix, not a Wandering-Archaic special case — +// every card whose Oracle text puts the "may" modal on the bare pronoun +// "they" (rather than the explicit "that player"/"the player" forms already +// handled) previously fell through `parse_subject_application` with NO match +// (only the exact string "they", with no trailing "may", was accepted). The +// caller's `unwrap_or` fallback then silently substituted +// `SubjectApplication { affected: TargetFilter::Any, is_optional: false, .. }` +// — an unbound target AND a mandatory (non-"may") ability, both wrong. These +// four tests lock in the corrected behavior for every other printed card +// found to share the pattern (via a before/after parse diff), so the fix's +// wider blast radius is intentional and covered, not an unexplained +// side effect. +// --------------------------------------------------------------------------- + +/// Mishra's Command mode 1: "Choose target player. They may discard up to X +/// cards." Before the fix: `Discard { target: Any, .. }`, non-optional — +/// unbound to the just-chosen player and mandatory despite "may". After: the +/// discard binds to `ParentTarget` (the chosen player) and is optional. +#[test] +fn mishras_command_they_may_discard_binds_to_chosen_player_and_is_optional() { + let parsed = parse_oracle_text( + "Choose two \u{2014}\n\u{2022} Choose target player. They may discard up to X cards. Then they draw a card for each card discarded this way.\n\u{2022} This spell deals X damage to target creature.\n\u{2022} This spell deals X damage to target planeswalker.\n\u{2022} Target creature gets +X/+0 and gains haste until end of turn.", + "Mishra's Command", + &[], + &["Sorcery".to_string()], + &[], + ); + let mode1 = parsed + .abilities + .first() + .expect("Mishra's Command must parse mode 1 as the first ability"); + assert!( + matches!(*mode1.effect, Effect::TargetOnly { .. }), + "mode 1's head is the target-player slot, got {:?}", + mode1.effect + ); + let discard = mode1 + .sub_ability + .as_ref() + .expect("the discard must remain chained to the chosen target"); + match &*discard.effect { + Effect::Discard { target, .. } => { + assert_eq!( + target, + &TargetFilter::ParentTarget, + "\"they\" discard must bind to the just-chosen target player, not float unbound" + ); + } + other => panic!("expected Discard, got {other:?}"), + } + assert!( + discard.optional, + "\"they may discard\" must be optional, not mandatory" + ); +} + +/// Undercity Plunder: "Target opponent discards a card. Then they may +/// discard an additional card. If they don't, conjure ..." Before the fix: +/// the second Discard's target was `Any` (unbound) and non-optional, so the +/// "if they don't" branch's condition was unreachable in practice. +#[test] +fn undercity_plunder_they_may_discard_additional_binds_to_parent_target() { + let parsed = parse_oracle_text( + "Target opponent discards a card. Then they may discard an additional card. If they don't, conjure a duplicate of a random card from their library into your hand. It perpetually gains \"You may spend mana as though it were mana of any color to cast this spell.\"", + "Undercity Plunder", + &[], + &["Sorcery".to_string()], + &[], + ); + let head = parsed + .abilities + .first() + .expect("Undercity Plunder must parse the initial discard"); + let second_discard = head + .sub_ability + .as_ref() + .expect("\"they may discard an additional card\" must remain chained"); + match &*second_discard.effect { + Effect::Discard { target, .. } => { + assert_eq!( + target, + &TargetFilter::ParentTarget, + "the additional discard must bind to the same targeted opponent" + ); + } + other => panic!("expected Discard, got {other:?}"), + } + assert!( + second_discard.optional, + "\"they may discard an additional card\" must be optional" + ); + let conjure_gate = second_discard + .sub_ability + .as_ref() + .expect("the \"if they don't\" conjure branch must remain chained"); + assert_eq!( + conjure_gate.condition, + Some(AbilityCondition::Not { + condition: Box::new(AbilityCondition::effect_performed()) + }), + "the conjure branch is gated on declining the additional discard" + ); +} + +/// Tarnation: "Whenever a player commits a crime, they may draw a card." +/// Before the fix: `Draw { target: Any, .. }`, non-optional — the draw had no +/// player bound to it at all. +#[test] +fn tarnation_they_may_draw_binds_to_triggering_player() { + let def = parse_trigger_line( + "Whenever a player commits a crime, they may draw a card. (Targeting opponents, anything they control, and/or cards in their graveyards is a crime.)", + "Tarnation", + ); + let execute = def.execute.as_ref().expect("should have execute"); + match &*execute.effect { + Effect::Draw { target, .. } => { + assert_eq!( + target, + &TargetFilter::TriggeringPlayer, + "\"they\" draws for the player who committed the crime" + ); + } + other => panic!("expected Draw, got {other:?}"), + } + assert!(execute.optional, "\"they may draw\" must be optional"); +} + +/// Smart Ass: "... If defending player has no cards with the chosen name in +/// their hand, they may reveal their hand. If they don't reveal their hand, +/// this creature can't be blocked this turn." CR 506.2: "defending player" is +/// the combat-relative nonactive player being attacked, not a chosen or +/// previously-targeted player — the intervening-if stamps +/// `relative_player_scope = ControllerRef::DefendingPlayer` +/// (`condition_introduces_defending_player`), so "they" must resolve to +/// `TargetFilter::DefendingPlayer` specifically. Before the fix: +/// `RevealHand { target: Any, .. }`, non-optional (the pronoun was unhandled +/// entirely). An earlier version of this fix left `resolve_they_pronoun` +/// without a `DefendingPlayer` arm, so "they" fell through to the generic +/// `ParentTarget` default instead — plausible-looking (not `Any`) but still +/// wrong, since there is no prior target for "defending player" to inherit. +#[test] +fn smart_ass_they_may_reveal_hand_binds_to_defending_player() { + let def = parse_trigger_line( + "Whenever this creature attacks, choose a card name. If defending player has no cards with the chosen name in their hand, they may reveal their hand. If they don't reveal their hand, this creature can't be blocked this turn.", + "Smart Ass", + ); + let execute = def.execute.as_ref().expect("should have execute"); + let reveal = execute + .sub_ability + .as_ref() + .expect("the reveal-hand clause must remain chained to the naming choice"); + match &*reveal.effect { + Effect::RevealHand { target, .. } => { + assert_eq!( + target, + &TargetFilter::DefendingPlayer, + "\"they\" reveal must bind to the combat-relative defending player" + ); + } + other => panic!("expected RevealHand, got {other:?}"), + } + assert!( + reveal.optional, + "\"they may reveal their hand\" must be optional" + ); +} + +/// Sibling case for `smart_ass_they_may_reveal_hand_binds_to_defending_player`: +/// a "they may" pronoun under a DIFFERENT relative-player scope +/// (`ControllerRef::TargetPlayer`, stamped by a "deals combat damage to a +/// player" condition — CR 120.3) must still resolve to `TriggeringPlayer` +/// (the damaged player), not fall into the new `DefendingPlayer` arm. Guards +/// the scope routing in `resolve_they_pronoun`: the two `if` checks read the +/// same `Option` field and are mutually exclusive by +/// construction, but this locks in that the "they may" modal threading +/// doesn't accidentally collapse distinct scopes onto one filter. Mirrors the +/// existing bare-"they" (non-"may") coverage in +/// `parse_unstoppable_slasher_combat_damage_half_life`. "Test Card" is a +/// synthetic grammar-class fixture (see `trigger_you_may_pay_remains_controller`), +/// not a printed card — no real card in the corpus pairs this exact +/// combat-damage-to-a-player condition with a "they may" effect body. +#[test] +fn they_may_after_combat_damage_to_player_binds_to_triggering_player() { + let def = parse_trigger_line( + "Whenever this creature deals combat damage to a player, they may draw a card.", + "Test Card", + ); + let execute = def.execute.as_ref().expect("should have execute"); + match &*execute.effect { + Effect::Draw { target, .. } => { + assert_eq!( + target, + &TargetFilter::TriggeringPlayer, + "\"they\" after \"deals combat damage to a player\" must bind to the \ + damaged player (TriggeringPlayer), not DefendingPlayer" + ); + } + other => panic!("expected Draw, got {other:?}"), + } + assert!(execute.optional, "\"they may draw\" must be optional"); +} + +/// Siege Dragon: "Whenever this creature attacks, if defending player +/// controls no Walls, it deals 2 damage to each creature without flying +/// that player controls." A before/after parse-diff audit of every printed +/// card containing "if defending player" (18 cards, run while developing the +/// `effect_body_introduces_defending_player` fix) surfaced this as a SECOND +/// real defect fixed by the same mechanism: "that player controls" is a +/// possessive-controller reference back to the if-condition's "defending +/// player", and before the fix it resolved to `ControllerRef::You` — Siege +/// Dragon was damaging creatures the ATTACKER controls instead of the +/// defending player's, exactly backwards for an attack-punisher effect. Every +/// other card in that audit (Fear of the Dark, Must Be Knights, Reaper of +/// Night, Robber of the Rich, Septic Rats, Spectral Bears, Spectral Force, +/// Aerial Surveyor, Blurry Beeble, and the static-ability "can't attack/block +/// if defending player ..." cards) parsed identically before and after, +/// confirming the fix's scope is exactly the cards that anaphor back to a +/// body-level "defending player" conditional. +#[test] +fn siege_dragon_that_player_controls_binds_to_defending_player() { + let def = parse_trigger_line( + "Whenever this creature attacks, if defending player controls no Walls, it deals 2 damage to each creature without flying that player controls.", + "Siege Dragon", + ); + let execute = def.execute.as_ref().expect("should have execute"); + match &*execute.effect { + Effect::DamageAll { target, .. } => match target { + TargetFilter::Typed(tf) => { + assert_eq!( + tf.controller, + Some(ControllerRef::DefendingPlayer), + "\"that player controls\" must bind to the defending player named by \ + the if-condition, not the attacker (ControllerRef::You)" + ); + } + other => panic!("expected a Typed target filter, got {other:?}"), + }, + other => panic!("expected DamageAll, got {other:?}"), + } +} + +/// CR 508.5: For an ability of an attacking creature that refers to a defending +/// player, that player is the player the creature attacks. +/// Elder Brain: "Whenever this creature attacks a player, exile all cards +/// from that player's hand, then they draw that many cards. ..." Unlike Smart +/// Ass and Siege Dragon (which name "defending player" via a per-clause +/// conditional buried in the effect body — the NEW +/// `effect_body_introduces_defending_player` path), this trigger's OWN head +/// condition is "whenever ~ attacks a player", which the PRE-EXISTING +/// `condition_introduces_defending_player` check +/// (`relative_player_scope_for_condition`) already recognized and stamped as +/// `ControllerRef::DefendingPlayer` before this fix. What was still broken: +/// `resolve_they_pronoun` had no arm reading that scope at all, so "they" in +/// "they draw that many cards" fell through to the generic `ParentTarget` +/// default regardless of which mechanism set the scope. This test locks in +/// the production Oracle route through both the pre-existing head-condition +/// detector and the new `resolve_they_pronoun` arm together, distinct from +/// the body-conditional route the other two tests cover. +#[test] +fn elder_brain_they_draw_binds_to_defending_player() { + let def = parse_trigger_line( + "Whenever this creature attacks a player, exile all cards from that player's hand, then they draw that many cards. You may play lands and cast spells from among the exiled cards for as long as they remain exiled. If you cast a spell this way, you may spend mana as though it were mana of any color to cast it.", + "Elder Brain", + ); + assert_eq!(def.mode, TriggerMode::Attacks); + let execute = def.execute.as_ref().expect("should have execute"); + assert!( + matches!(&*execute.effect, Effect::ChangeZoneAll { .. }), + "head effect must remain the exile-hand ChangeZoneAll, got {:?}", + execute.effect + ); + let draw = execute + .sub_ability + .as_ref() + .expect("the draw must remain chained to the exile"); + match &*draw.effect { + Effect::Draw { target, count } => { + assert_eq!( + target, + &TargetFilter::DefendingPlayer, + "\"they draw\" must bind to the attacked defending player, not ParentTarget" + ); + assert_eq!( + count, + &QuantityExpr::Ref { + qty: QuantityRef::EventContextAmount + }, + "\"that many cards\" must read the number of cards just exiled" + ); + } + other => panic!("expected Draw, got {other:?}"), + } +} diff --git a/crates/engine/tests/integration/issue_6477_wandering_archaic_optional_payment.rs b/crates/engine/tests/integration/issue_6477_wandering_archaic_optional_payment.rs new file mode 100644 index 0000000000..fa2d0f8a30 --- /dev/null +++ b/crates/engine/tests/integration/issue_6477_wandering_archaic_optional_payment.rs @@ -0,0 +1,308 @@ +//! Issue #6477: Wandering Archaic's "they may pay {2}. If they don't, you may +//! copy that spell" never copied the opponent's spell, whether or not they +//! paid. +//! +//! The parser fix (`oracle_effect/subject.rs`) is covered by +//! `wandering_archaic_they_pay_as_triggering_player` in `oracle_trigger_tests.rs`, +//! which only proves the lowered AST shape (payer + optionality). These tests +//! drive the real trigger-resolution pipeline: an opponent casts an instant, +//! is offered the {2} payment, and either path — decline-then-copy or +//! pay-and-suppress — is exercised through `apply`, never hand-constructed. +//! +//! Oracle text: +//! Whenever an opponent casts an instant or sorcery spell, they may pay +//! {2}. If they don't, you may copy that spell. You may choose new targets +//! for the copy. + +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::ability::TargetRef; +use engine::types::actions::GameAction; +use engine::types::game_state::{CastPaymentMode, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; + +const WANDERING_ARCHAIC_ORACLE: &str = "Whenever an opponent casts an instant or sorcery spell, \ + they may pay {2}. If they don't, you may copy that spell. You may choose new targets for the copy."; + +const SHOCK_ORACLE: &str = "Shock deals 2 damage to any target."; + +fn floating_mana(units: &[ManaType]) -> Vec { + units + .iter() + .map(|ty| ManaUnit::new(*ty, ObjectId(0), false, vec![])) + .collect() +} + +/// Build the shared scenario: P0 controls Wandering Archaic, P1 holds Shock +/// (with a red pip to cast plus two floating generic for the optional tax) +/// and has priority to cast it, targeting a bystander creature under P0's +/// control. The target's toughness (10) is well above any damage total these +/// tests deal (up to 4, from the original Shock plus an accepted copy) so it +/// never dies mid-resolution — a dead target would make the second spell's +/// resolution fizzle on an illegal target (CR 608.2b) and mask the copy +/// under test as a false negative. +fn build_scenario() -> (GameRunner, ObjectId, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.add_creature_from_oracle(P0, "Wandering Archaic", 4, 4, WANDERING_ARCHAIC_ORACLE); + let target = scenario.add_creature(P0, "Target Dummy", 2, 10).id(); + + let shock = scenario + .add_spell_to_hand_from_oracle(P1, "Shock", true, SHOCK_ORACLE) + .with_mana_cost(ManaCost::Cost { + generic: 0, + shards: vec![ManaCostShard::Red], + }) + .id(); + scenario.with_mana_pool( + P1, + floating_mana(&[ManaType::Red, ManaType::Colorless, ManaType::Colorless]), + ); + + let mut runner = scenario.build(); + runner.state_mut().active_player = P1; + runner.state_mut().priority_player = P1; + runner.state_mut().waiting_for = WaitingFor::Priority { player: P1 }; + + (runner, shock, target) +} + +/// Cast `shock` targeting `target` through the real `apply` pipeline, +/// submitting the target-selection prompt manually. Deliberately does NOT use +/// the `SpellCast`/`CastCommit` fluent builder's `.resolve()` — that driver +/// auto-answers every `OptionalEffectChoice` it encounters with a `Decline` +/// default (see `drive_resolution`'s `ResolutionPolicy`), which would silently +/// drive past both of Wandering Archaic's optional prompts before the test +/// ever got a chance to intercept them. +fn cast_shock(runner: &mut GameRunner, shock: ObjectId, target: ObjectId) { + let card_id = runner.state().objects[&shock].card_id; + runner + .act(GameAction::CastSpell { + object_id: shock, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("P1 casts Shock"); + + match runner.state().waiting_for.clone() { + WaitingFor::TargetSelection { .. } => { + runner + .act(GameAction::SelectTargets { + targets: vec![TargetRef::Object(target)], + }) + .expect("select the target creature for Shock"); + } + other => panic!("expected TargetSelection after casting Shock, got {other:?}"), + } +} + +/// Advance the engine until the {2} optional-payment prompt (or the stack +/// empties without one, which would itself be the bug this regresses). +fn drive_to_payment_prompt(runner: &mut GameRunner) { + for _ in 0..100 { + if matches!( + runner.state().waiting_for, + WaitingFor::OptionalEffectChoice { .. } + ) { + return; + } + if runner.state().stack.is_empty() { + return; + } + if runner.act(GameAction::PassPriority).is_err() { + return; + } + } +} + +/// Drain to an idle, empty stack after every decision this test cares about +/// has already been made explicitly by the caller (the {2} payment, and — +/// on decline — the follow-up copy choice). A further `OptionalEffectChoice` +/// here is UNEXPECTED and fails loudly rather than being silently declined: +/// a regression that offers the copy even after the opponent paid (or offers +/// it twice) must not be swallowed into the same "2 damage" outcome a +/// correctly-suppressed copy produces — that would make the paid-path test +/// pass whether or not the copy was actually suppressed, defeating its whole +/// point. `CopyRetarget` is the one legitimate additional prompt (issued only +/// once a copy has already been created), so it alone is handled here. +fn drive_to_idle(runner: &mut GameRunner) { + for _ in 0..100 { + match &runner.state().waiting_for { + WaitingFor::CopyRetarget { .. } => { + runner + .act(GameAction::KeepAllCopyTargets) + .expect("keep the copy's original target"); + } + WaitingFor::OptionalEffectChoice { .. } => { + panic!( + "unexpected optional-effect prompt during drive_to_idle: {:?} — \ + every decision this test exercises must already be settled by now", + runner.state().waiting_for + ); + } + WaitingFor::Priority { .. } if runner.state().stack.is_empty() => return, + WaitingFor::Priority { .. } => { + if runner.act(GameAction::PassPriority).is_err() { + return; + } + } + _ => { + if runner.act(GameAction::PassPriority).is_err() { + return; + } + } + } + } +} + +/// The opponent declines the {2} payment, so the "if they don't" branch +/// offers Wandering Archaic's controller the copy. Accepting must put a +/// second Shock on the stack under the controller's control, dealing a +/// second 2 damage to the target (4 total) once both the original and the +/// copy have resolved. +#[test] +fn wandering_archaic_declined_payment_lets_controller_copy_spell() { + let (mut runner, shock, target) = build_scenario(); + + cast_shock(&mut runner, shock, target); + drive_to_payment_prompt(&mut runner); + + // The payment choice must be offered to the casting opponent (P1), not + // Wandering Archaic's controller (P0) — the defect this regresses. "They" + // in "they may pay" anaphors to the opponent named by the trigger + // condition (the parser fact asserted directly by + // `wandering_archaic_they_pay_as_triggering_player`); CR 608.2d only + // governs that an effect's offered choice is announced by the player + // applying the effect, not who that player is. + match runner.state().waiting_for.clone() { + WaitingFor::OptionalEffectChoice { player, .. } => { + assert_eq!( + player, P1, + "the {{2}} payment choice must be offered to the casting opponent" + ); + } + other => panic!("expected the {{2}} optional payment prompt, got {other:?}"), + } + let p1_mana_before = runner + .state() + .players + .iter() + .find(|p| p.id == P1) + .unwrap() + .mana_pool + .total(); + + runner + .act(GameAction::DecideOptionalEffect { accept: false }) + .expect("P1 declines the {2} payment"); + + // Declining must not spend the opponent's mana. + assert_eq!( + runner + .state() + .players + .iter() + .find(|p| p.id == P1) + .unwrap() + .mana_pool + .total(), + p1_mana_before, + "declining the payment must not deduct the opponent's mana" + ); + + // The "if they don't" branch now offers the copy to Wandering Archaic's + // controller (P0), not the opponent. + for _ in 0..20 { + if matches!( + runner.state().waiting_for, + WaitingFor::OptionalEffectChoice { .. } + ) { + break; + } + if runner.act(GameAction::PassPriority).is_err() { + break; + } + } + match runner.state().waiting_for.clone() { + WaitingFor::OptionalEffectChoice { player, .. } => { + assert_eq!( + player, P0, + "the \"you may copy\" choice belongs to Wandering Archaic's controller" + ); + } + other => panic!("expected the \"you may copy\" prompt, got {other:?}"), + } + + runner + .act(GameAction::DecideOptionalEffect { accept: true }) + .expect("P0 accepts the copy"); + + drive_to_idle(&mut runner); + + assert!( + runner.state().stack.is_empty(), + "resolution must settle with an empty stack" + ); + assert_eq!( + runner.state().objects[&target].damage_marked, + 4, + "the original Shock plus the accepted copy must deal 2 + 2 = 4 damage" + ); +} + +/// The opponent paying the {2} tax must suppress the copy entirely — only +/// the original Shock resolves. `drive_to_idle` panics on any further +/// `OptionalEffectChoice`, so a regression that still offers the copy after +/// payment fails here instead of coincidentally landing on the same "2 +/// damage" outcome a correctly-suppressed copy produces. +#[test] +fn wandering_archaic_paid_payment_suppresses_copy() { + let (mut runner, shock, target) = build_scenario(); + + cast_shock(&mut runner, shock, target); + drive_to_payment_prompt(&mut runner); + + match runner.state().waiting_for.clone() { + WaitingFor::OptionalEffectChoice { player, .. } => assert_eq!(player, P1), + other => panic!("expected the {{2}} optional payment prompt, got {other:?}"), + } + let p1_mana_before = runner + .state() + .players + .iter() + .find(|p| p.id == P1) + .unwrap() + .mana_pool + .total(); + + runner + .act(GameAction::DecideOptionalEffect { accept: true }) + .expect("P1 pays the {2}"); + + assert_eq!( + runner + .state() + .players + .iter() + .find(|p| p.id == P1) + .unwrap() + .mana_pool + .total(), + p1_mana_before - 2, + "paying must deduct exactly {{2}} generic from the opponent's pool" + ); + + drive_to_idle(&mut runner); + + assert!( + runner.state().stack.is_empty(), + "resolution must settle with an empty stack" + ); + assert_eq!( + runner.state().objects[&target].damage_marked, + 2, + "paying the tax must suppress the copy — only the original Shock's 2 damage lands" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index b5fa0d54ad..3de438afaf 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -625,6 +625,7 @@ mod issue_6435_mosswort_bridge_hideaway_play; mod issue_6437_fight_rigging_exiled_card_target; mod issue_6440_mockingbird_uncast_copy_ceiling; mod issue_6459_scheming_symmetry; +mod issue_6477_wandering_archaic_optional_payment; mod issue_6498_portent_of_calamity; mod issue_6499_flickering_ward_protection_exemption; mod issue_6500_loreseekers_stone_hand_cost; From 5a3a42a79bf0f01c39095b027ee475ca02a78e9e Mon Sep 17 00:00:00 2001 From: galuis116 <116897328+galuis116@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:45:33 -0400 Subject: [PATCH 56/63] test(engine): Riptide per-opponent target slots are scoped to that opponent (#6565) (#6836) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(engine): Riptide per-opponent target slots are scoped to that opponent's permanents (#6565) The per-opponent fanout targeting fix (#6565, "that player controls" bound to the iterated opponent) had no runtime regression covering the controller scope. The existing riptide_gearhulk_5994.rs tests cover the optional-count behavior (#5994) but not that each opponent's slot targets THAT opponent's permanents rather than the caster's. Add two runtime tests driving Riptide's real ETB through the cast pipeline: - `riptide_per_opponent_slot_excludes_the_casters_own_permanents` offers only the caster's own permanent as target intent; it is not a legal target for the opponent's per-opponent slot, so the slot is declined and the permanent stays. Non-vacuous: a broken scope that let the opponent slot target the caster's permanent would move it to the library and flip the assertion. - `riptide_per_opponent_slot_targets_that_opponents_permanent` is the positive half — the opponent's own permanent IS legal and moves to its owner's library, while the caster's permanent is untouched. Refs #6565 * test(PR-6836): cover both per-opponent target slots * fix(engine): place every per-opponent fanout target at its library position (#6565) A per-opponent target fanout ("for each opponent, put up to one target nonland permanent that player controls into its owner's library third from the top") pre-selects one target PER opponent at stack time via multi_target.max = PlayerCount { Opponent }. The PutAtLibraryPosition effect's count field (Fixed(1)) is the PER-OPPONENT cap, not the total, so put_on_top::resolve wrongly treated the two pre-chosen permanents as "more candidates than count" and issued an interactive EffectZoneChoice { count: 1 } over the already-targeted permanents. That prompt looped forever (selecting one never advanced) and, at best, would have placed only one of the two. CR 601.2c: the number of targets is fixed at targeting; each chosen target is placed. CR 401.4: cards put at the same library position are arranged by their owner. For a per-opponent fanout, expected placement count is the number of pre-chosen targets, so each is routed to its own owner's library (CR 400.7). Fixes the runtime regression covered by riptide_per_opponent_slots_target_each_opponents_permanent (#6836). --------- Co-authored-by: matthewevans --- crates/engine/src/game/ability_utils.rs | 2 +- crates/engine/src/game/effects/put_on_top.rs | 15 ++++ .../integration/riptide_gearhulk_5994.rs | 69 +++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/game/ability_utils.rs b/crates/engine/src/game/ability_utils.rs index b1c85a6b8e..8e3a77df51 100644 --- a/crates/engine/src/game/ability_utils.rs +++ b/crates/engine/src/game/ability_utils.rs @@ -4822,7 +4822,7 @@ fn attach_host_enchant_filter( Some((filter, attachment_id, controller)) } -fn is_per_opponent_target_fanout(ability: &ResolvedAbility) -> bool { +pub(crate) fn is_per_opponent_target_fanout(ability: &ResolvedAbility) -> bool { if ability.target_choice_timing != TargetChoiceTiming::Stack { return false; } diff --git a/crates/engine/src/game/effects/put_on_top.rs b/crates/engine/src/game/effects/put_on_top.rs index 5d5581631e..a5c81dff23 100644 --- a/crates/engine/src/game/effects/put_on_top.rs +++ b/crates/engine/src/game/effects/put_on_top.rs @@ -178,6 +178,21 @@ pub fn resolve( expected }; + // CR 601.2c + CR 401.4 (issue #6565 / #6836): A per-opponent target fanout + // ("for each opponent, put up to one target ... that player controls ...") + // pre-selects one target PER opponent at stack time — `multi_target.max = + // PlayerCount { Opponent }`, so `collected_targets` already holds every + // chosen permanent (one per opponent). The effect's `count` (`Fixed(1)`) is + // the PER-OPPONENT cap, NOT the total, so it must never gate a further + // "choose `count` of them" prompt over the already-targeted permanents + // (which would loop forever and place at most one). Each pre-chosen target + // is placed into its own owner's library (CR 400.7, routed by the move). + let expected = if crate::game::ability_utils::is_per_opponent_target_fanout(ability) { + collected_targets.len() + } else { + expected + }; + if collected_targets.is_empty() { if expected == 0 { events.push(GameEvent::EffectResolved { diff --git a/crates/engine/tests/integration/riptide_gearhulk_5994.rs b/crates/engine/tests/integration/riptide_gearhulk_5994.rs index 6c77fa06a0..a96764d053 100644 --- a/crates/engine/tests/integration/riptide_gearhulk_5994.rs +++ b/crates/engine/tests/integration/riptide_gearhulk_5994.rs @@ -114,3 +114,72 @@ fn riptide_selects_one_opponent_target_and_declines_the_other() { // The declined opponent's permanent is untouched. outcome.assert_zone(&[p2_perm], Zone::Battlefield); } + +/// Issue #6565: "for each opponent, put up to one target nonland permanent THAT +/// PLAYER controls ..." scopes each per-opponent target to the iterated +/// opponent's permanents — never the caster's own. Offering the caster's own +/// permanent as the sole target intent must leave it untouched (it is not a legal +/// target for the opponent's slot), and the ability still resolves. +#[test] +fn riptide_per_opponent_slot_excludes_the_casters_own_permanents() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool(P0, riptide_mana()); + + let my_perm = scenario.add_creature(P0, "My Own Bear", 2, 2).id(); + let opp_perm = scenario.add_creature(P1, "Opp Bear", 2, 2).id(); + let riptide = scenario + .add_creature_to_hand_from_oracle(P0, "Riptide Gearhulk", 4, 4, RIPTIDE_ORACLE) + .id(); + + let mut runner = scenario.build(); + + // Offer ONLY the caster's own permanent as target intent. It is not a legal + // target for the opponent's per-opponent slot, so that slot is declined. + let outcome = runner + .cast(riptide) + .target_players(&[P1]) + .target_objects(&[my_perm]) + .resolve(); + + assert!( + matches!(outcome.final_waiting_for(), WaitingFor::Priority { .. }), + "the ETB must resolve to priority, got {:?}", + outcome.final_waiting_for() + ); + // The caster's own permanent is not controlled by the opponent, so it can + // never be chosen for the opponent's slot — it stays on the battlefield. + outcome.assert_zone(&[my_perm], Zone::Battlefield); + outcome.assert_zone(&[opp_perm], Zone::Battlefield); +} + +/// Issue #6565: each opponent's own permanent is legal only for that opponent's +/// per-opponent slot. Selecting both opponents independently moves both targets +/// to their owners' libraries while the caster's own permanent remains untouched. +#[test] +fn riptide_per_opponent_slots_target_each_opponents_permanent() { + let mut scenario = GameScenario::new_n_player(3, 42); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool(P0, riptide_mana()); + scenario.add_card_to_library_top(P1, "P1 Library Card"); + scenario.add_card_to_library_top(P2, "P2 Library Card"); + + let my_perm = scenario.add_creature(P0, "My Own Bear", 2, 2).id(); + let p1_perm = scenario.add_creature(P1, "Opp1 Bear", 2, 2).id(); + let p2_perm = scenario.add_creature(P2, "Opp2 Bear", 2, 2).id(); + let riptide = scenario + .add_creature_to_hand_from_oracle(P0, "Riptide Gearhulk", 4, 4, RIPTIDE_ORACLE) + .id(); + + let mut runner = scenario.build(); + + let outcome = runner + .cast(riptide) + .target_players(&[P1, P2]) + .target_objects(&[p1_perm, p2_perm]) + .resolve(); + + outcome.assert_zone(&[p1_perm, p2_perm], Zone::Library); + outcome.assert_zone(&[my_perm], Zone::Battlefield); + outcome.assert_zone(&[riptide], Zone::Battlefield); +} From e04514f3c0a16581bc163d324937f11ddba0cbe9 Mon Sep 17 00:00:00 2001 From: "@Lcola98" <75585494+keloide@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:02:56 +0200 Subject: [PATCH 57/63] fix(parser): retain target for counter instead overrides (#6843) * fix(parser): retain target for counter instead overrides * fix(parser): retain counter override event targets * fix(parser): bind instead to paid continuation --------- Co-authored-by: matthewevans --- .../src/parser/oracle_effect/assembly.rs | 79 +++++-- .../engine/src/parser/oracle_effect/lower.rs | 15 +- .../engine/src/parser/oracle_effect/tests.rs | 57 +++++ .../issue_6677_wakandan_royal_guard.rs | 206 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 5 files changed, 340 insertions(+), 18 deletions(-) create mode 100644 crates/engine/tests/integration/issue_6677_wakandan_royal_guard.rs diff --git a/crates/engine/src/parser/oracle_effect/assembly.rs b/crates/engine/src/parser/oracle_effect/assembly.rs index bfb6f616e6..1e62c06d19 100644 --- a/crates/engine/src/parser/oracle_effect/assembly.rs +++ b/crates/engine/src/parser/oracle_effect/assembly.rs @@ -1625,9 +1625,11 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { // 1 runs as printed, then the tail runs from // `else_ability`. Single-clause bases collapse to the // prior shape (empty tail → no `else_ability`). - // U6-C2: `Instead` is the ONLY handler that binds FirstEmitted - // (CR 608.2c — the override replaces the FIRST printed - // instruction). Do not unify it with the `Last*` selectors. + // U6-C2: `Instead` is the ONLY handler that begins from + // FirstEmitted. Its one structural refinement is an optional + // payment: there, the override replaces the immediate + // `IfYouDo` continuation, not the payment instruction. + // Do not unify it with the `Last*` selectors. let bound = env.resolve( &defs, AntecedentSelector::FirstEmitted, @@ -1649,21 +1651,51 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { append_to_deepest_sub_ability(&mut root, Some(Box::new(next))); } let mut instead = *instead_def.clone(); - // CR 702.33d + CR 707.10: Resolve "create N of those - // tokens" anaphor against the root (the antecedent - // for a multi-clause base is the first printed clause). - rewrite_those_tokens_from_antecedent(&mut instead.effect, &root.effect); - if rewrite_counter_instead_target_from_antecedent( - &mut instead.effect, - &root.effect, - ) { - instead.target_choice_timing = root.target_choice_timing; - } - if has_explicit_player_target(root.effect.as_ref()) { - rewrite_player_anaphor_targets_in_definition(&mut instead); + if instead_replaces_optional_payment_continuation(&root) { + // CR 608.2c: after "you may pay ... If you do, X", + // a later "Y instead" modifies X, not the preceding + // payment instruction. Keep the payment as the root + // and attach the override to its paid continuation. + let continuation = root + .sub_ability + .as_deref_mut() + .expect("the optional-payment continuation was checked"); + rewrite_those_tokens_from_antecedent( + &mut instead.effect, + &continuation.effect, + ); + if rewrite_counter_instead_target_from_antecedent( + &mut instead.effect, + &continuation.effect, + ) { + instead.target_choice_timing = + continuation.target_choice_timing; + } + if has_explicit_player_target(continuation.effect.as_ref()) { + rewrite_player_anaphor_targets_in_definition(&mut instead); + } + instead.else_ability = continuation.sub_ability.take(); + continuation.sub_ability = Some(Box::new(instead)); + } else { + // CR 702.33d + CR 707.10: Resolve "create N of those + // tokens" anaphor against the root (the antecedent + // for a multi-clause base is the first printed clause). + rewrite_those_tokens_from_antecedent( + &mut instead.effect, + &root.effect, + ); + if rewrite_counter_instead_target_from_antecedent( + &mut instead.effect, + &root.effect, + ) { + instead.target_choice_timing = root.target_choice_timing; + } + if has_explicit_player_target(root.effect.as_ref()) { + rewrite_player_anaphor_targets_in_definition(&mut instead); + } + instead.else_ability = root.sub_ability.take(); + root.sub_ability = Some(Box::new(instead)); } - instead.else_ability = root.sub_ability.take(); - root.sub_ability = Some(Box::new(instead)); defs.push(root); if let Some(id) = root_id { env.arena.reinstate(id); @@ -2897,6 +2929,19 @@ fn rebind_condition_instead_damage_anaphor( false } +/// CR 608.2c: identify the structural "you may pay ... If you do, X" form +/// whose immediate paid continuation, rather than the payment itself, can be +/// modified by a following "Y instead" clause. +fn instead_replaces_optional_payment_continuation(root: &AbilityDefinition) -> bool { + matches!(root.effect.as_ref(), Effect::PayCost { .. }) + && root.sub_ability.as_ref().is_some_and(|continuation| { + continuation + .condition + .as_ref() + .is_some_and(AbilityCondition::is_optional_effect_performed) + }) +} + #[cfg(test)] mod arena_tests { use super::*; diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index ed6bc7b445..25b8e5b1af 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -2399,6 +2399,8 @@ pub(super) fn rewrite_counter_instead_target_from_antecedent( if !matches!(current_target, TargetFilter::SelfRef) { return false; } + // CR 608.2c + CR 115.1: an instead clause later in the same instruction + // reuses the original chosen target rather than announcing a new target. // Existing attachment-host case — only when the antecedent is itself a `PutCounter`. // Preserved verbatim (clone the host filter) so attachment-host cards stay byte-identical. if let Effect::PutCounter { @@ -2410,7 +2412,18 @@ pub(super) fn rewrite_counter_instead_target_from_antecedent( *current_target = antecedent_target.clone(); return true; } - return false; + match antecedent_target { + // A printed target is selected once for the root instruction; the + // override must inherit that selection rather than open a new slot. + TargetFilter::Typed(_) => *current_target = TargetFilter::ParentTarget, + // Event and parent anaphors already identify the antecedent object + // at resolution. Reuse the same reference for a bare "it" override. + TargetFilter::ParentTarget + | TargetFilter::ParentTargetSlot { .. } + | TargetFilter::TriggeringSource => *current_target = antecedent_target.clone(), + _ => return false, + } + return true; } // FIX A′ — CR 608.2c: an instead-override "Put a +1/+1 counter on it" whose antecedent // is a typed-targeted non-counter clause (Throw from the Saddle's "Target creature you diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 2812dda149..e208998790 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -19144,6 +19144,63 @@ fn instead_condition_recognizes_that_permanent_is_color() { ); } +/// CR 608.2c + CR 115.1: a conditional counter override whose base clause +/// targeted a creature reuses that chosen object through `ParentTarget`. The +/// override must not turn its bare "it" into the resolving source or request a +/// second target. Wakandan Royal Guard and Elder Cathar cover the unrestricted +/// and controller-qualified target forms of this grammar. +#[test] +fn counter_instead_override_reuses_typed_antecedent_target() { + for effect_text in [ + "Put a +1/+1 counter on target creature. If that creature is another Hero, put two +1/+1 counters on it instead.", + "Put a +1/+1 counter on target creature you control. If that creature is a Human, put two +1/+1 counters on it instead.", + ] { + let ability = parse_effect_chain(effect_text, AbilityKind::Spell); + assert!( + matches!( + ability.effect.as_ref(), + Effect::PutCounter { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Typed(_), + .. + } + ), + "the base counter instruction must retain its typed target for {effect_text:?}; got {:?}", + ability.effect + ); + let override_branch = ability + .sub_ability + .as_deref() + .unwrap_or_else(|| panic!("expected counter override for {effect_text:?}")); + assert!( + matches!( + override_branch.condition.as_ref(), + Some(AbilityCondition::ConditionInstead { inner }) + if matches!(inner.as_ref(), AbilityCondition::TargetMatchesFilter { .. }) + ), + "the override must retain a typed target-match condition for {effect_text:?}; got {:?}", + override_branch.condition + ); + assert!( + matches!( + override_branch.effect.as_ref(), + Effect::PutCounter { + count: QuantityExpr::Fixed { value: 2 }, + target: TargetFilter::ParentTarget, + .. + } + ), + "the override must put two counters on the original target for {effect_text:?}; got {:?}", + override_branch.effect + ); + assert!( + !matches!(ability.effect.as_ref(), Effect::Unimplemented { .. }) + && !matches!(override_branch.effect.as_ref(), Effect::Unimplemented { .. }), + "both clauses must lower without Effect::Unimplemented for {effect_text:?}" + ); + } +} + /// CR 117.1 + CR 400.7j + CR 608.2k + CR 614.1a: Stormscale Anarch class — /// the "discard a card at random" cost paid object is checked against the /// "multicolored" property to gate the override damage. The condition is diff --git a/crates/engine/tests/integration/issue_6677_wakandan_royal_guard.rs b/crates/engine/tests/integration/issue_6677_wakandan_royal_guard.rs new file mode 100644 index 0000000000..519e1a73f7 --- /dev/null +++ b/crates/engine/tests/integration/issue_6677_wakandan_royal_guard.rs @@ -0,0 +1,206 @@ +//! Regressions for issue #6677: conditional counter overrides must retain the +//! object established by their antecedent instruction. +//! +//! The real Oracle text first targets a creature, then says "put two +1/+1 +//! counters on it instead" when that creature is another Hero. The override's +//! bare pronoun must resolve to the original target, never Wakandan Royal Guard. +//! Emiel the Blessed covers the parallel trigger-event anaphor path. + +use engine::game::scenario::{GameScenario, P0}; +use engine::types::counter::CounterType; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaCost, ManaType, ManaUnit}; +use engine::types::phase::Phase; + +const WAKANDAN_ROYAL_GUARD_ORACLE: &str = "Vigilance\n\ + When this creature enters, put a +1/+1 counter on target creature. If that creature is another Hero, put two +1/+1 counters on it instead."; + +const EMIEL_THE_BLESSED_ORACLE: &str = "{3}: Exile another target creature you control, then return it to the battlefield under its owner's control.\n\ + Whenever another creature you control enters, you may pay {G/W}. If you do, put a +1/+1 counter on it. If it's a Unicorn, put two +1/+1 counters on it instead. ({G/W} can be paid with either {G} or {W}.)"; + +fn p1p1_counters(state: &engine::types::game_state::GameState, object: ObjectId) -> u32 { + state + .objects + .get(&object) + .and_then(|card| card.counters.get(&CounterType::Plus1Plus1).copied()) + .unwrap_or(0) +} + +fn resolve_guard_targeting_creature(target_is_hero: bool) -> (u32, u32, u32) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let selected = if target_is_hero { + scenario + .add_creature(P0, "Selected Hero", 2, 2) + .with_subtypes(vec!["Hero"]) + .id() + } else { + scenario.add_creature(P0, "Selected Soldier", 2, 2).id() + }; + let unrelated_hero = scenario + .add_creature(P0, "Unselected Hero", 2, 2) + .with_subtypes(vec!["Hero"]) + .id(); + let guard = scenario + .add_creature_to_hand_from_oracle( + P0, + "Wakandan Royal Guard", + 2, + 2, + WAKANDAN_ROYAL_GUARD_ORACLE, + ) + .with_subtypes(vec!["Human", "Soldier", "Hero"]) + .with_mana_cost(ManaCost::generic(0)) + .id(); + + let mut runner = scenario.build(); + let outcome = runner.cast(guard).target_object(selected).resolve(); + + ( + p1p1_counters(outcome.state(), selected), + p1p1_counters(outcome.state(), guard), + p1p1_counters(outcome.state(), unrelated_hero), + ) +} + +/// CR 603.2 + CR 115.1d + CR 608.2c + CR 122.1: the ETB trigger targets the +/// selected Hero, and its matching instead-override puts two counters on that +/// same object. The zero-counter siblings prove the pronoun did not rebound to +/// either the resolving guard or another legal Hero. +#[test] +fn wakandan_royal_guard_doubles_counters_on_the_selected_hero() { + let (selected, guard, unrelated_hero) = resolve_guard_targeting_creature(true); + + assert_eq!( + selected, 2, + "the selected Hero must receive two +1/+1 counters" + ); + assert_eq!( + guard, 0, + "Wakandan Royal Guard must not receive the counters" + ); + assert_eq!( + unrelated_hero, 0, + "an unselected Hero must not receive the counters" + ); +} + +/// CR 603.2 + CR 115.1d + CR 608.2c + CR 122.1: when the chosen creature is +/// not a Hero, the override does not apply and the printed base instruction +/// still places exactly one counter on that chosen object. +#[test] +fn wakandan_royal_guard_keeps_one_counter_on_a_nonhero_target() { + let (selected, guard, unrelated_hero) = resolve_guard_targeting_creature(false); + + assert_eq!( + selected, 1, + "the non-Hero target must receive the base instruction's one counter" + ); + assert_eq!( + guard, 0, + "Wakandan Royal Guard must not receive the counter" + ); + assert_eq!( + unrelated_hero, 0, + "an unrelated Hero must not satisfy the selected-target condition" + ); +} + +/// CR 603.2 + CR 608.2c + CR 122.1: Emiel's first counter instruction and +/// its Unicorn override both refer to the entering creature, represented by +/// `TriggeringSource`. The unrelated Unicorn proves the event reference is +/// preserved rather than widened to a battlefield filter. +#[test] +fn emiel_the_blessed_doubles_counters_on_the_entering_unicorn() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let emiel = scenario + .add_creature_from_oracle(P0, "Emiel the Blessed", 4, 4, EMIEL_THE_BLESSED_ORACLE) + .with_subtypes(vec!["Angel"]) + .id(); + let unrelated_unicorn = scenario + .add_creature(P0, "Unrelated Unicorn", 2, 2) + .with_subtypes(vec!["Unicorn"]) + .id(); + let entering_unicorn = scenario + .add_creature_to_hand(P0, "Entering Unicorn", 2, 2) + .with_subtypes(vec!["Unicorn"]) + .with_mana_cost(ManaCost::generic(0)) + .id(); + scenario.with_mana_pool( + P0, + vec![ManaUnit::new(ManaType::Green, emiel, false, vec![])], + ); + + let mut runner = scenario.build(); + let outcome = runner.cast(entering_unicorn).accept_optional().resolve(); + + assert!( + outcome.state().players[P0.0 as usize] + .mana_pool + .mana + .is_empty(), + "reach-guard: accepting Emiel's optional payment must consume the supplied green mana" + ); + assert_eq!( + p1p1_counters(outcome.state(), entering_unicorn), + 2, + "the entering Unicorn must receive Emiel's two +1/+1 counters" + ); + assert_eq!( + p1p1_counters(outcome.state(), emiel), + 0, + "Emiel must not receive the entering creature's counters" + ); + assert_eq!( + p1p1_counters(outcome.state(), unrelated_unicorn), + 0, + "an unrelated Unicorn must not satisfy the trigger-event anaphor" + ); +} + +/// CR 603.2 + CR 608.2c + CR 122.1: accepting Emiel's payment still performs +/// the printed one-counter continuation when the entering creature does not +/// satisfy the later Unicorn instead-condition. +#[test] +fn emiel_the_blessed_keeps_one_counter_on_a_nonunicorn() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let emiel = scenario + .add_creature_from_oracle(P0, "Emiel the Blessed", 4, 4, EMIEL_THE_BLESSED_ORACLE) + .with_subtypes(vec!["Angel"]) + .id(); + let entering_cat = scenario + .add_creature_to_hand(P0, "Entering Cat", 2, 2) + .with_subtypes(vec!["Cat"]) + .with_mana_cost(ManaCost::generic(0)) + .id(); + scenario.with_mana_pool( + P0, + vec![ManaUnit::new(ManaType::Green, emiel, false, vec![])], + ); + + let mut runner = scenario.build(); + let outcome = runner.cast(entering_cat).accept_optional().resolve(); + + assert!( + outcome.state().players[P0.0 as usize] + .mana_pool + .mana + .is_empty(), + "reach-guard: accepting Emiel's optional payment must consume the supplied green mana" + ); + assert_eq!( + p1p1_counters(outcome.state(), entering_cat), + 1, + "the entering non-Unicorn must receive the paid continuation's one counter" + ); + assert_eq!( + p1p1_counters(outcome.state(), emiel), + 0, + "Emiel must not receive the entering creature's counter" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 3de438afaf..957e2d4319 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -633,6 +633,7 @@ mod issue_654_stridehangar_automaton; mod issue_6566_granted_leave_exile; mod issue_6634_aven_courier; mod issue_6643_party_dude_opponents_attacked; +mod issue_6677_wakandan_royal_guard; mod issue_6678_captain_america_shield_tap_defender; mod issue_680_shalai_and_hallar_forgotten_ancient; mod issue_680_shalai_upkeep_move; From 7323622de586ddaf279f4e3064c1258794f1b37e Mon Sep 17 00:00:00 2001 From: Clayton <118192227+claytonlin1110@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:03:53 -0500 Subject: [PATCH 58/63] fix(engine): stop basic-land mana fallback from bypassing CantBeActivated (#6841) * fix(engine): stop basic-land mana fallback from bypassing CantBeActivated land_mana_options() fell back to unconditional subtype-inferred mana production whenever scan_mana_abilities() returned no options, without checking whether that emptiness meant "no mana ability exists" or "a real mana ability exists but was just filtered out by a legality gate." Karn, the Great Creator correctly filtered a Liquimetal-Coating-turned- artifact land's own {T}: Add ability out of scan_mana_abilities, but the fallback then silently re-added it via bare subtype inference, letting the opponent tap the blocked land for mana anyway (#6469). Gate the fallback on the object genuinely carrying no Effect::Mana ability at all, so Urborg/Blood-Moon-class subtype-only production still works while a real, currently-prohibited ability stays prohibited. Co-Authored-By: Claude Sonnet 5 * docs(PR-6841): cite land-type mana fallback rule * fix(engine): block the bare-subtype-land mana fallback too The first fix only closed the gap for a land carrying an explicit Effect::Mana ability. A land with NO explicit ability at all - just a basic land subtype, the genuine Urborg/Blood-Moon-class case the fallback exists for - hits land_mana_options()'s bare-subtype branch directly, bypassing scan_mana_abilities() entirely and any CantBeActivated check with it. Per CR 305.6 that intrinsic "{T}: Add [mana symbol]" ability is still an activated mana ability, so CR 602.5 prohibitions must block it exactly like a printed one. Add mana_abilities::intrinsic_land_mana_ability_blocked, which builds a minimal synthetic AbilityDefinition for the intrinsic ability and delegates to the single-authority is_blocked_by_cant_be_activated / is_blocked_by_cant_activate_during checks - never re-implements them. Wire it into the fallback, gated on require_current_payability to match how is_active_tap_mana_ability treats real abilities (the auto-tap planning pass doesn't consult per-source legality gates for any mana source, real or intrinsic). Adds a regression test with a bare-subtype artifact land under Karn's prohibition (the case the prior fix's Forest test couldn't reach, since Forest carries an explicit ability) and a positive companion test confirming the ordinary bare-subtype fallback still works with no prohibition in play. Co-Authored-By: Claude Sonnet 5 * fix(engine): route the intrinsic land mana ability through the shared readiness authority intrinsic_land_mana_ability_blocked reimplemented only two of the checks a printed mana ability goes through (CantBeActivated, CantActivateDuring), missing phased-out (CR 702.26b), detained (CR 701.35a), and can't-tap (CR 701.26a + CR 508.1f) sources. A bare-subtype land in any of those states was still offered as a legal mana source, since the equivalent printed mana ability is excluded by mana_ability_ready_without_simulation_gated but the synthesized intrinsic ability was checked against a narrower, hand-picked subset. That readiness authority takes an AbilityDefinition by reference and never indexes obj.abilities, so a synthesized definition with no real storage slot is exactly as valid an input as a printed one - no refactor of the authority needed. Route the synthetic {T}: Add ability through it directly instead of re-implementing any subset of its checks, preserving the require_current_payability split that already keeps the auto-tap planning path (which doesn't consult per-source legality gates for any mana source, real or intrinsic) distinct from the interactive legal-action path this whole family targets. Adds three regression tests (detained, phased-out, can't-tap bare-subtype lands, none of which need a CantBeActivated static to demonstrate the gap) alongside the existing CantBeActivated and positive-fallback coverage. Co-Authored-By: Claude Sonnet 5 * fix(PR-6841): correct activation-cost CR annotations --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: matthewevans --- crates/engine/src/game/casting_tests.rs | 361 +++++++++++++++++++++++ crates/engine/src/game/mana_abilities.rs | 71 ++++- crates/engine/src/game/mana_sources.rs | 64 +++- 3 files changed, 480 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index e8731a8dce..ff9a939063 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -30955,6 +30955,367 @@ fn cant_be_activated_aura_blocks_enchanted_creature_not_others() { ); } +#[test] +fn karn_blocks_liquimetal_coated_opponent_land() { + // Issue #6469: Karn's `TargetFilter::Typed(Artifact)` filter must apply to + // a permanent that becomes an artifact via a continuous type-changing + // effect (Liquimetal Coating's "becomes an artifact in addition to its + // other types until end of turn"), not just to permanents that are + // printed artifacts. Exercises the real GenericEffect -> transient + // continuous effect -> layer-4 AddType pipeline rather than hand-setting + // `card_types`, so a regression in that pipeline would be caught here too. + let mut state = setup_game_at_main_phase(); + + add_cant_be_activated_source( + &mut state, + PlayerId(0), + ProhibitionScope::AllPlayers, + TargetFilter::Typed( + TypedFilter::new(TypeFilter::Artifact).controller(ControllerRef::Opponent), + ), + ); + + let coating = create_object( + &mut state, + CardId(0x1157), + PlayerId(0), + "Liquimetal Coating".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&coating) + .unwrap() + .card_types + .core_types + .push(CoreType::Artifact); + + let land = create_object( + &mut state, + CardId(0x1a2d), + PlayerId(1), + "Utility Land".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&land).unwrap(); + obj.card_types.core_types.push(CoreType::Land); + obj.base_card_types = obj.card_types.clone(); + obj.entered_battlefield_turn = Some(0); + Arc::make_mut(&mut obj.abilities).push( + crate::types::ability::AbilityDefinition::new( + crate::types::ability::AbilityKind::Activated, + crate::types::ability::Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + ) + .cost(AbilityCost::Tap), + ); + } + let land_ability = state.objects[&land].abilities[0].clone(); + + assert!( + !is_blocked_by_cant_be_activated(&state, PlayerId(1), land, &land_ability), + "reach-guard: an uncoated land must not be blocked by Karn" + ); + + let ability = ResolvedAbility::new( + Effect::GenericEffect { + static_abilities: vec![StaticDefinition::new(StaticMode::Continuous) + .affected(TargetFilter::ParentTarget) + .modifications(vec![ContinuousModification::AddType { + core_type: CoreType::Artifact, + }])], + duration: Some(crate::types::ability::Duration::UntilEndOfTurn), + target: Some(TargetFilter::Typed(TypedFilter::new(TypeFilter::Permanent))), + end_cost: None, + }, + vec![TargetRef::Object(land)], + coating, + PlayerId(0), + ); + + let mut events = Vec::new(); + crate::game::effects::effect::resolve(&mut state, &ability, &mut events).unwrap(); + crate::game::layers::evaluate_layers(&mut state); + + assert!( + state.objects[&land] + .card_types + .core_types + .contains(&CoreType::Artifact), + "Liquimetal Coating must make the land an artifact; core_types = {:?}", + state.objects[&land].card_types.core_types + ); + assert!( + is_blocked_by_cant_be_activated(&state, PlayerId(1), land, &land_ability), + "Karn must block a Coating-turned-artifact land controlled by an opponent" + ); +} + +#[test] +fn karn_blocks_liquimetal_coated_forest_from_legal_mana_actions() { + // Issue #6469: the ROOT CAUSE — `land_mana_options`'s basic-land-subtype + // fallback (`mana_sources.rs`) fired whenever `scan_mana_abilities` came + // back empty, without checking WHY it was empty. Karn correctly filters + // the coated Forest's real {T}: Add {G} ability out of `scan_mana_abilities` + // (confirmed by the reach-guard below), but the fallback then mistook that + // legitimate filtering for "no mana ability exists" and re-added an + // unconditional `ability_index: None` option for it, letting the opponent + // tap the Karn-blocked land for mana anyway. Exercises + // `activatable_mana_actions_for_player`, the real legal-action surface + // behind both the manual "tap for mana" UI and AI candidate generation. + let mut state = setup_game_at_main_phase(); + + add_cant_be_activated_source( + &mut state, + PlayerId(0), + ProhibitionScope::AllPlayers, + TargetFilter::Typed( + TypedFilter::new(TypeFilter::Artifact).controller(ControllerRef::Opponent), + ), + ); + + // A real Forest: Land + "Forest" subtype + an explicit {T}: Add {G} ability + // (mirrors card-data.json's actual parsed Forest, not a bare subtype). + let forest = create_object( + &mut state, + CardId(0xF0125), + PlayerId(1), + "Forest".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&forest).unwrap(); + obj.card_types.core_types.push(CoreType::Land); + obj.card_types.subtypes.push("Forest".to_string()); + obj.card_types.core_types.push(CoreType::Artifact); // Liquimetal Coating + obj.base_card_types = obj.card_types.clone(); + obj.entered_battlefield_turn = Some(0); + Arc::make_mut(&mut obj.abilities).push( + crate::types::ability::AbilityDefinition::new( + crate::types::ability::AbilityKind::Activated, + crate::types::ability::Effect::Mana { + produced: crate::types::ability::ManaProduction::Fixed { + colors: vec![ManaColor::Green], + contribution: ManaContribution::Base, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + ) + .cost(AbilityCost::Tap), + ); + } + let forest_ability = state.objects[&forest].abilities[0].clone(); + + assert!( + is_blocked_by_cant_be_activated(&state, PlayerId(1), forest, &forest_ability), + "reach-guard: Karn must block the coated Forest's own {{T}}: Add {{G}} ability" + ); + + let legal_actions = + crate::game::mana_sources::activatable_mana_actions_for_player(&state, PlayerId(1)); + assert!( + !legal_actions + .iter() + .any(|action| action.source_object() == Some(forest)), + "Karn must remove the coated Forest from P1's legal mana actions, got {legal_actions:?}" + ); +} + +#[test] +fn karn_blocks_bare_subtype_artifact_land_from_legal_mana_actions() { + // Issue #6469 follow-up: the fix above only closed the gap for a land + // that carries an explicit `Effect::Mana` ability. A land with NO + // explicit ability at all — just a basic land subtype, the genuine + // Urborg/Blood-Moon-class case `land_mana_options`'s fallback exists for + // (CR 305.6: the "{T}: Add [mana symbol]" ability is intrinsic even with + // no text box) — hits `land_mana_options`'s bare-subtype fallback + // directly, bypassing `scan_mana_abilities` entirely. That intrinsic + // ability is still an activated mana ability (CR 305.6 + CR 605), so + // CR 602.5 activation prohibitions must block it exactly like a printed + // one. Companion to `karn_blocks_liquimetal_coated_forest_from_legal_mana_actions`, + // which covers the explicit-ability half of the same fallback. + let mut state = setup_game_at_main_phase(); + + add_cant_be_activated_source( + &mut state, + PlayerId(0), + ProhibitionScope::AllPlayers, + TargetFilter::Typed( + TypedFilter::new(TypeFilter::Artifact).controller(ControllerRef::Opponent), + ), + ); + + // A bare-subtype Forest: Land + "Forest" subtype, no `abilities` entry at + // all — the intrinsic CR 305.6 ability, made an artifact by Liquimetal + // Coating and controlled by Karn's opponent. + let forest = create_object( + &mut state, + CardId(0xF0126), + PlayerId(1), + "Bare Forest".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&forest).unwrap(); + obj.card_types.core_types.push(CoreType::Land); + obj.card_types.subtypes.push("Forest".to_string()); + obj.card_types.core_types.push(CoreType::Artifact); // Liquimetal Coating + obj.base_card_types = obj.card_types.clone(); + obj.entered_battlefield_turn = Some(0); + } + assert!( + state.objects[&forest].abilities.is_empty(), + "reach-guard: this land must have no explicit AbilityDefinition, so the \ + bare-subtype fallback (not scan_mana_abilities) is the branch under test" + ); + + let legal_actions = + crate::game::mana_sources::activatable_mana_actions_for_player(&state, PlayerId(1)); + assert!( + !legal_actions + .iter() + .any(|action| action.source_object() == Some(forest)), + "Karn must block the coated bare-subtype Forest's intrinsic mana ability too, \ + got {legal_actions:?}" + ); +} + +#[test] +fn bare_subtype_land_still_offers_mana_without_a_prohibition() { + // Positive companion to the regression above: with no CantBeActivated + // static in play, the bare-subtype fallback this whole family guards + // must still work — an ordinary basic land (no explicit ability) is a + // legal mana source via its CR 305.6 intrinsic ability. + let mut state = setup_game_at_main_phase(); + + let forest = create_object( + &mut state, + CardId(0xF0127), + PlayerId(1), + "Bare Forest".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&forest).unwrap(); + obj.card_types.core_types.push(CoreType::Land); + obj.card_types.subtypes.push("Forest".to_string()); + obj.base_card_types = obj.card_types.clone(); + obj.entered_battlefield_turn = Some(0); + } + + let legal_actions = + crate::game::mana_sources::activatable_mana_actions_for_player(&state, PlayerId(1)); + assert!( + legal_actions + .iter() + .any(|action| action.source_object() == Some(forest)), + "an unprohibited bare-subtype land must still offer its intrinsic mana ability, \ + got {legal_actions:?}" + ); +} + +/// Build a bare-subtype Forest (Land + "Forest" subtype, no explicit +/// `abilities` entry) under `controller`, so `land_mana_options`'s +/// bare-subtype fallback — not `scan_mana_abilities` — is the branch under +/// test. Shared by the three sibling-gate regressions below. +fn add_bare_subtype_forest(state: &mut GameState, controller: PlayerId, card_id: u64) -> ObjectId { + let forest = create_object( + state, + CardId(card_id), + controller, + "Bare Forest".to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&forest).unwrap(); + obj.card_types.core_types.push(CoreType::Land); + obj.card_types.subtypes.push("Forest".to_string()); + obj.base_card_types = obj.card_types.clone(); + obj.entered_battlefield_turn = Some(0); + forest +} + +#[test] +fn bare_subtype_land_detained_excluded_from_legal_mana_actions() { + // CR 701.35a + CR 305.6: a detained permanent's activated abilities can't + // be activated — including a bare-subtype land's intrinsic mana ability, + // which `intrinsic_land_mana_ability_blocked` must route through the same + // `mana_ability_ready_without_simulation_gated` readiness authority a + // printed mana ability uses, not just the two activation-prohibition + // statics (issue #6469 follow-up). + let mut state = setup_game_at_main_phase(); + let forest = add_bare_subtype_forest(&mut state, PlayerId(1), 0xF0128); + state + .objects + .get_mut(&forest) + .unwrap() + .detained_by + .insert(PlayerId(0)); + + let legal_actions = + crate::game::mana_sources::activatable_mana_actions_for_player(&state, PlayerId(1)); + assert!( + !legal_actions + .iter() + .any(|action| action.source_object() == Some(forest)), + "a detained bare-subtype land must not offer its intrinsic mana ability, \ + got {legal_actions:?}" + ); +} + +#[test] +fn bare_subtype_land_phased_out_excluded_from_legal_mana_actions() { + // CR 702.26b + CR 305.6: a phased-out permanent is treated as though it + // doesn't exist and can't activate abilities — including a bare-subtype + // land's intrinsic mana ability. + let mut state = setup_game_at_main_phase(); + let forest = add_bare_subtype_forest(&mut state, PlayerId(1), 0xF0129); + state.objects.get_mut(&forest).unwrap().phase_status = + crate::game::game_object::PhaseStatus::PhasedOut { + cause: crate::game::game_object::PhaseOutCause::Directly, + }; + + let legal_actions = + crate::game::mana_sources::activatable_mana_actions_for_player(&state, PlayerId(1)); + assert!( + !legal_actions + .iter() + .any(|action| action.source_object() == Some(forest)), + "a phased-out bare-subtype land must not offer its intrinsic mana ability, \ + got {legal_actions:?}" + ); +} + +#[test] +fn bare_subtype_land_cant_tap_excluded_from_legal_mana_actions() { + // CR 101.2 + CR 107.5 + CR 601.2h + CR 602.2b + CR 305.6: a permanent that + // can't become tapped can't pay a {T} activation cost — including a bare-subtype land's + // intrinsic {T}: Add mana ability. + let mut state = setup_game_at_main_phase(); + let forest = add_bare_subtype_forest(&mut state, PlayerId(1), 0xF012A); + state + .objects + .get_mut(&forest) + .unwrap() + .static_definitions + .push(StaticDefinition::new(StaticMode::CantTap).affected(TargetFilter::SelfRef)); + + let legal_actions = + crate::game::mana_sources::activatable_mana_actions_for_player(&state, PlayerId(1)); + assert!( + !legal_actions + .iter() + .any(|action| action.source_object() == Some(forest)), + "a can't-tap bare-subtype land must not offer its intrinsic mana ability, \ + got {legal_actions:?}" + ); +} + // === CR 605.1a: Pithing Needle mana-ability exemption gate === /// Build a Llanowar-Elves-style mana ability: `{T}: Add {G}` (no targets, produces mana). diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index 941815d24a..534f140215 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -1323,6 +1323,73 @@ impl ManaActivationGates { } } +/// CR 305.6: builds the minimal synthetic `AbilityDefinition` (Tap cost, +/// `Effect::Mana`) standing in for a land's INTRINSIC "{T}: Add [mana +/// symbol]" ability — the ability every land with a basic land type has +/// whether or not any `AbilityDefinition` object represents it. Used so a +/// legality check's `kind`/`exemption`/cost axes (e.g. Damping Matrix's +/// "unless they're mana abilities" carve-out) evaluate identically to how +/// they would against a real, printed mana ability. +fn intrinsic_land_mana_ability_definition(color: ManaColor) -> AbilityDefinition { + AbilityDefinition::new( + crate::types::ability::AbilityKind::Activated, + Effect::Mana { + produced: ManaProduction::Fixed { + colors: vec![color], + contribution: crate::types::ability::ManaContribution::Base, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + ) + .cost(AbilityCost::Tap) +} + +/// CR 305.6 + CR 602.5: Is a land's basic-land-type INTRINSIC mana ability +/// currently blocked — by ANY of the gates a printed mana ability would be +/// checked against? `mana_sources::land_mana_options`'s bare-subtype +/// fallback synthesizes a `ManaSourceOption` for a land with no +/// `AbilityDefinition` object at all (Urborg/Blood-Moon-class grants), but +/// CR 305.6's intrinsic ability is still an activated mana ability, so every +/// gate `mana_ability_ready_without_simulation_gated` applies to a real one — +/// phased-out (CR 702.26b), detained (CR 701.35a), zone (CR 113.6), tapped/ +/// can't-tap (CR 101.2 + CR 107.5 + CR 601.2h + CR 602.2b), summoning sickness +/// (CR 302.6), CantBeActivated/CantActivateDuring (CR 602.5), static +/// activation restrictions (CR 604/605.3b) — must apply to it too. Routes the +/// synthetic definition through that SAME single-authority readiness check +/// rather than re-implementing any subset of it: the function takes an +/// `AbilityDefinition` by reference and never indexes `obj.abilities`, so a +/// synthesized definition with no real storage slot is exactly as valid an +/// input as a printed one. `ability_index: 0` is inert here — the intrinsic +/// ability carries empty `activation_restrictions` (so `ability_index` is +/// never read by that check) and a bare `Tap` cost (whose payability check +/// doesn't consult it either). +pub(crate) fn intrinsic_land_mana_ability_blocked( + state: &GameState, + controller: PlayerId, + object_id: ObjectId, + color: ManaColor, + gates: Option<&ManaActivationGates>, +) -> bool { + let ability_def = intrinsic_land_mana_ability_definition(color); + let ready = match gates { + Some(gates) => mana_ability_ready_without_simulation_gated( + state, + controller, + object_id, + 0, + &ability_def, + gates, + ), + None => { + mana_ability_ready_without_simulation(state, controller, object_id, 0, &ability_def) + } + }; + !ready +} + fn mana_ability_ready_without_simulation( state: &GameState, player: PlayerId, @@ -1380,8 +1447,8 @@ fn mana_ability_ready_without_simulation_gated( if mana_sources::has_tap_component(&ability_def.cost) && obj.tapped { return false; } - // CR 701.26a + CR 508.1f: a "can't become tapped" source (e.g. a goaded mana - // dork) can't activate a tap-cost mana ability. A {Q} untap-cost ability is + // CR 101.2 + CR 107.5 + CR 601.2h + CR 602.2b: a "can't become tapped" + // source can't pay a tap-cost mana ability. A {Q} untap-cost ability is // unaffected — untapping is governed by `StaticMode::CantUntap`. if mana_sources::has_tap_component(&ability_def.cost) && crate::game::restrictions::object_cant_tap(state, source_id) diff --git a/crates/engine/src/game/mana_sources.rs b/crates/engine/src/game/mana_sources.rs index aeabe1a72a..9dd3eeed3a 100644 --- a/crates/engine/src/game/mana_sources.rs +++ b/crates/engine/src/game/mana_sources.rs @@ -2348,26 +2348,62 @@ fn land_mana_options( gates, ); - // Legacy fallback for basic-land subtype-only objects (no explicit mana ability). - if options.is_empty() { + // CR 305.6 + CR 602.5: Legacy fallback for basic-land subtype-only objects that + // carry NO EXPLICIT mana ability at all (a nonbasic granted a basic land + // type by Urborg/Blood Moon-class effects with no accompanying + // `Effect::Mana` grant). This must NOT fire merely because + // `scan_mana_abilities` came back empty — it can be empty because a REAL + // `Effect::Mana` ability exists but was just filtered out by a legality + // gate (CantBeActivated, CantActivateDuring, an unsatisfied activation + // condition). Falling back to unconditional subtype-inferred production + // in that case would silently defeat the gate that filtered it (issue + // #6469: Karn, the Great Creator's "activated abilities of artifacts your + // opponents control can't be activated" stopped blocking a Liquimetal- + // Coating-turned-artifact land's own {T}: Add mana ability, because the + // ability's legitimate absence from `options` was mistaken for "no + // ability exists"). + let has_explicit_mana_ability = obj + .abilities + .iter() + .any(|ability| matches!(*ability.effect, Effect::Mana { .. })); + if options.is_empty() && !has_explicit_mana_ability { if let Some(mana_type) = obj .card_types .subtypes .iter() .find_map(|s| mana_payment::land_subtype_to_mana_type(s)) { - options.push(ManaSourceOption { - object_id, - ability_index: None, - mana_type, - source_could_produce_two_or_more_colors: source_could_produce_two_or_more_colors( - state, object_id, controller, - ), - penalty: ManaSourcePenalty::None, - atomic_combination: None, - restrictions: Vec::new(), - taps_for_mana_overrides: Vec::new(), - }); + // CR 305.6 + CR 602.5: the intrinsic "{T}: Add [mana symbol]" + // ability this fallback synthesizes is still an activated (mana) + // ability — every readiness gate a printed one is checked against + // (phased-out, detained, tapped/can't-tap, summoning sickness, + // CantBeActivated/CantActivateDuring, static activation + // restrictions) must apply to it too, not just the two activation- + // prohibition statics. Mirrors the `require_current_payability` + // gating `is_active_tap_mana_ability` applies to a real ability: the + // auto-tap PLANNING pass (`require_current_payability == false`) + // does not consult per-source legality gates for ANY mana source, + // real or intrinsic, so this only fires on the interactive/ + // legal-action path. + let blocked = require_current_payability + && mana_type_to_color(mana_type).is_some_and(|color| { + mana_abilities::intrinsic_land_mana_ability_blocked( + state, controller, object_id, color, gates, + ) + }); + if !blocked { + options.push(ManaSourceOption { + object_id, + ability_index: None, + mana_type, + source_could_produce_two_or_more_colors: + source_could_produce_two_or_more_colors(state, object_id, controller), + penalty: ManaSourcePenalty::None, + atomic_combination: None, + restrictions: Vec::new(), + taps_for_mana_overrides: Vec::new(), + }); + } } } From 3f57f8079e922ed9edf57137dc538690b8f4f52d Mon Sep 17 00:00:00 2001 From: Hurry Date: Fri, 31 Jul 2026 19:43:19 -0400 Subject: [PATCH 59/63] fix(ai): reject dominated Colorless convoke-family taps during ManaPayment (#6840) * fix(ai): reject dominated Colorless convoke-family taps during ManaPayment Metallic Rebuke's {2}{U} Improvise cast could dead-end: mana_payment_actions offers a Colorless TapForConvoke marker for every Improvise/Waterbend/Convoke eligible permanent, with nothing preferring a dual-purpose permanent's native colored mana ability while a colored pip is still outstanding. Tapping such a permanent via the Colorless marker first permanently strands the pip, and the AI dead-ends in ManaPayment with no legal completion, hitting fallback_action's debug_assert (a release panic) via search.rs's documented "can_cast_object_now guarantees completability" invariant -- true for the pre-cast check, not for tap-channel execution order. Add convoke_native_tap_still_demanded (mana_colors.rs), the tap-channel sibling of tap_strands_demanded_color, reusing its existing color_is_demanded helper rather than duplicating it. Wire it into tactical_gate.rs's assess_candidate as a new TapForConvoke{Colorless} arm: reject the Colorless tap only when a sibling native-ability candidate for the same object could still pay the pending cast's outstanding colored demand -- zero-cost dominance, not a scoring preference, since the native ability can always still cover the trailing generic slot afterward. Four new tactical_gate.rs regression tests cover: the dominated-Colorless rejection, the sibling native tap staying allowed, the tap being allowed once colored demand is satisfied (not a blanket ban), and a permanent with no native colored option being unaffected (gate stays scoped to true dominance). Closes #6837 * fix(ai): recognize a colored TapForConvoke as a dominating sibling too sibling_native_tap_pays_demand only handled TapLandForMana and ActivateAbility as siblings that could still cover an outstanding colored demand -- it missed that mana_payment_actions emits a colored TapForConvoke candidate alongside the Colorless one for CR 702.51a Convoke (not just Improvise/Waterbend, which are always colorless). A Convoke spell with a remaining colored pip and a matching-colored creature left the Colorless marker Allow, since the previous code never recognized the colored sibling on the same object as dominating it -- reintroducing the exact ManaPayment dead-end this fix targets, just via Convoke instead of Improvise. Add a TapForConvoke{same object_id} arm matching color_is_demanded on its own mana_type; Colorless naturally evaluates false so it can't spuriously match itself. New regression drives the real production candidate path (candidate_actions_broad -> mana_payment_actions) for a Convoke spell with a colored pip and a matching-colored creature, rather than a synthetic Improvise-only sibling: asserts the Colorless candidate is Reject while the real colored sibling stays Allow. Fails on the prior head. * fix(ai): only treat a tap-cost native mana ability as a dominating sibling sibling_native_tap_pays_demand's ActivateAbility arm gated the Colorless convoke-family marker off ANY same-object ability that could produce a demanded color, without checking whether that ability's own cost actually taps the permanent. The engine deliberately keeps tapless mana abilities (e.g. a sacrifice-based one) in the ManaPayment candidate set alongside a Colorless Improvise/Convoke tap on the same object -- they are not mutually exclusive (Colorless first, then the tapless ability, is a legal completing sequence). The prior code rejected the Colorless candidate solely because the tapless sibling existed, which can itself recreate the ManaPayment dead-end this fix targets, just in the opposite direction. Gate the ActivateAbility arm on the ability's own cost categories (AbilityCost::categories(), CR 118) containing CostCategory::TapsSelf, using the engine's existing cost-component authority rather than re-matching cost shapes by hand -- it already flattens Composite costs. Two new regressions drive the real production ManaPayment candidate set (candidate_actions_broad -> mana_payment_actions) for an Improvise-eligible artifact with a same-object mana ability: a tapless (Sacrifice-cost) one leaves the Colorless candidate Allow (fails on the prior head), and a tap-cost one still gates it to Reject (regression guard for the existing behavior). --------- Co-authored-by: matthewevans --- crates/phase-ai/src/mana_colors.rs | 91 +++++- crates/phase-ai/src/tactical_gate.rs | 471 ++++++++++++++++++++++++++- 2 files changed, 560 insertions(+), 2 deletions(-) diff --git a/crates/phase-ai/src/mana_colors.rs b/crates/phase-ai/src/mana_colors.rs index 32221dd7c4..89ba21d630 100644 --- a/crates/phase-ai/src/mana_colors.rs +++ b/crates/phase-ai/src/mana_colors.rs @@ -6,9 +6,12 @@ //! (`subtypes` + `abilities`) so a `GameObject` view and a `CardFace` view share //! a single implementation, mirroring the `*_parts` pattern in `features`. +use engine::ai_support::CandidateAction; use engine::game::mana_payment::{land_subtype_to_mana_type, outer_cost_color_demand, ColorDemand}; use engine::game::mana_sources::{activatable_mana_actions_for_player, mana_color_to_type}; -use engine::types::ability::{AbilityDefinition, AbilityKind, Effect, ManaProduction}; +use engine::types::ability::{ + AbilityDefinition, AbilityKind, CostCategory, Effect, ManaProduction, +}; use engine::types::actions::GameAction; use engine::types::game_state::GameState; use engine::types::identifiers::ObjectId; @@ -158,6 +161,92 @@ pub(crate) fn tap_strands_demanded_color( }) } +/// CR 702.51a (Convoke) / CR 702.126a (Improvise) / Waterbend: whether tapping +/// `object_id` for its Colorless convoke-family marker should be rejected +/// because a currently-legal sibling candidate at this exact `ManaPayment` +/// decision lets `object_id` instead pay a colored pip the pending cast still +/// demands, via its own native mana ability. +/// +/// This is zero-cost dominance, not a preference: both actions spend the SAME +/// single tap on the SAME permanent, but the native ability can still cover +/// the trailing generic slot once colored demand clears (or pay the colored +/// pip directly), while the Colorless marker can never retroactively produce +/// a stranded color. Companion to `tap_strands_demanded_color` above — that +/// function fixed this same dead-end bug class for land tap-color selection; +/// this is the convoke-family tap-channel-selection variant: nothing +/// previously preferred a permanent's native colored channel over its +/// Colorless convoke-family marker, so a dual-purpose permanent (e.g. an +/// artifact land that also taps for a color) could be spent via the marker +/// first, permanently stranding a colored pip and dead-ending `ManaPayment`. +pub(crate) fn convoke_native_tap_still_demanded( + state: &GameState, + candidates: &[CandidateAction], + object_id: ObjectId, +) -> bool { + let Some(pending_cast) = state.pending_cast.as_deref() else { + return false; + }; + let demand = outer_cost_color_demand(&pending_cast.cost); + if demand == [0u32; 5] { + return false; + } + candidates + .iter() + .any(|c| sibling_native_tap_pays_demand(state, &c.action, object_id, demand)) +} + +fn sibling_native_tap_pays_demand( + state: &GameState, + action: &GameAction, + object_id: ObjectId, + demand: ColorDemand, +) -> bool { + match action { + GameAction::TapLandForMana { selection } => { + selection.source.object_id == object_id + && color_is_demanded(demand, selection.mana_type) + } + // Only a tap-cost native ability actually competes for this same tap: + // a tapless ability (e.g. a sacrifice-based mana ability) can still be + // activated AFTER paying the Colorless marker, so it never strands a + // colored pip and must not gate the Colorless action. Use the cost's + // own category classification (CR 118) rather than re-matching cost + // shapes by hand -- it already flattens Composite costs correctly. + GameAction::ActivateAbility { + source_id, + ability_index, + } if *source_id == object_id => state + .objects + .get(source_id) + .and_then(|obj| obj.abilities.get(*ability_index)) + .is_some_and(|ability| { + let taps_self = ability + .cost + .as_ref() + .is_some_and(|cost| cost.categories().contains(&CostCategory::TapsSelf)); + if !taps_self { + return false; + } + let mut colors = Vec::new(); + if let Effect::Mana { produced, .. } = &*ability.effect { + collect_mana_production_colors(&mut colors, produced); + } + colors.iter().any(|&c| color_is_demanded(demand, c)) + }), + // CR 702.51a: Convoke (unlike Improvise/Waterbend) offers a colored + // marker per color the creature has, alongside the Colorless one -- + // `mana_payment_actions` emits both for the same object. A colored + // `TapForConvoke` on the SAME object is just as dominating a sibling + // as a native land/ability tap: it pays a matching colored pip, so + // the Colorless marker is never the only way to spend this creature. + GameAction::TapForConvoke { + object_id: sibling_id, + mana_type, + } if *sibling_id == object_id => color_is_demanded(demand, *mana_type), + _ => false, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/phase-ai/src/tactical_gate.rs b/crates/phase-ai/src/tactical_gate.rs index c7cd16897c..054e50bba7 100644 --- a/crates/phase-ai/src/tactical_gate.rs +++ b/crates/phase-ai/src/tactical_gate.rs @@ -5,9 +5,10 @@ use engine::game::combat::AttackTarget; use engine::types::ability::{AbilityCondition, Effect, PtValue, TargetFilter, TargetRef}; use engine::types::actions::GameAction; use engine::types::card_type::CoreType; -use engine::types::game_state::GameState; +use engine::types::game_state::{GameState, WaitingFor}; use engine::types::identifiers::ObjectId; use engine::types::keywords::Keyword; +use engine::types::mana::ManaType; use engine::types::phase::Phase; use engine::types::player::PlayerId; @@ -151,6 +152,31 @@ fn assess_candidate(ctx: &PolicyContext<'_>) -> GateDecision { } } GameAction::ChooseTarget { target: None } => GateDecision::Allow, + // CR 702.51a (Convoke) / CR 702.126a (Improvise) / Waterbend: a + // dual-purpose permanent's Colorless convoke-family marker must not be + // taken while a sibling candidate for the SAME object could still pay + // an outstanding colored pip via its native mana ability — spending + // the Colorless marker first permanently strands that pip and + // dead-ends `ManaPayment` (the Metallic Rebuke bug: + // `search::fallback_action`'s "can_cast_object_now has a gap" panic). + // Zero-cost dominance, not a scoring preference: the native ability + // can always still cover the trailing generic slot afterward. + GameAction::TapForConvoke { + object_id, + mana_type: ManaType::Colorless, + } => { + if matches!(ctx.decision.waiting_for, WaitingFor::ManaPayment { .. }) + && crate::mana_colors::convoke_native_tap_still_demanded( + ctx.state, + &ctx.decision.candidates, + *object_id, + ) + { + GateDecision::Reject + } else { + GateDecision::Allow + } + } GameAction::SelectTargets { targets } => { for target in targets { if let Some(rejection) = reject_futile_target(ctx, target) { @@ -1158,4 +1184,447 @@ mod tests { assert_ne!(gate_for(8, 7), GateDecision::Reject); assert_ne!(gate_for(4, 3), GateDecision::Reject); } + + /// Build an Improvise `ManaPayment` decision context: a `TapForConvoke` + /// Colorless candidate for `object_id`, plus whatever sibling candidates + /// the caller supplies for that same dual-purpose permanent. + fn improvise_mana_payment_decision( + sibling_candidates: Vec, + object_id: ObjectId, + ) -> AiDecisionContext { + let mut candidates = vec![CandidateAction { + action: GameAction::TapForConvoke { + object_id, + mana_type: ManaType::Colorless, + }, + metadata: ActionMetadata::for_actor(Some(P0), TacticalClass::Mana), + }]; + candidates.extend(sibling_candidates); + AiDecisionContext { + waiting_for: WaitingFor::ManaPayment { + player: P0, + convoke_mode: Some(engine::types::game_state::ConvokeMode::Improvise), + }, + candidates, + } + } + + fn native_blue_tap_candidate(object_id: ObjectId) -> CandidateAction { + CandidateAction { + action: GameAction::TapLandForMana { + selection: engine::types::mana::ManaSourceSelection { + source: engine::types::identifiers::ObjectIncarnationRef { + object_id, + incarnation: 0, + }, + ability_index: None, + mana_type: ManaType::Blue, + output: engine::types::mana::ManaSourceOutput::Concrete(ManaType::Blue), + atomic_combination: None, + restrictions: Vec::new(), + penalty: engine::types::mana::ManaSourcePenalty::None, + taps_for_mana: Vec::new(), + }, + }, + metadata: ActionMetadata::for_actor(Some(P0), TacticalClass::Mana), + } + } + + fn convoke_candidate_ctx<'a>( + state: &'a GameState, + decision: &'a AiDecisionContext, + config: &'a crate::config::AiConfig, + context: &'a AiContext, + ) -> PolicyContext<'a> { + PolicyContext { + state, + decision, + candidate: &decision.candidates[0], + ai_player: P0, + config, + context, + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + } + } + + /// CR 702.51a + CR 702.126a: a dual-purpose permanent (an artifact land + /// producing {U} natively) must not be tapped for its Colorless Improvise + /// marker while the pending cast's {U} pip is still outstanding — that + /// would strand the pip and dead-end `ManaPayment` (the Metallic Rebuke + /// bug: crates/phase-ai/src/search.rs's `fallback_action` panic). + #[test] + fn rejects_convoke_colorless_tap_when_native_ability_still_covers_colored_demand() { + let mut state = GameState::new_two_player(42); + state.pending_cast = Some(Box::new(PendingCast::new( + ObjectId(900), + CardId(900), + ResolvedAbility::new( + Effect::Draw { + count: engine::types::ability::QuantityExpr::Fixed { value: 0 }, + target: TargetFilter::Controller, + }, + Vec::new(), + ObjectId(900), + P0, + ), + ManaCost::Cost { + shards: vec![engine::types::mana::ManaCostShard::Blue], + generic: 2, + }, + ))); + let object_id = ObjectId(901); + let decision = + improvise_mana_payment_decision(vec![native_blue_tap_candidate(object_id)], object_id); + let config = create_config(AiDifficulty::VeryHard, Platform::Wasm); + let context = AiContext::empty(&config.weights); + let ctx = convoke_candidate_ctx(&state, &decision, &config, &context); + assert_eq!(assess_candidate(&ctx), GateDecision::Reject); + } + + /// The sibling native-ability tap for the same permanent must stay + /// `Allow` — the fix removes only the redundant Colorless path, not the + /// source's usability. + #[test] + fn allows_native_tap_when_colorless_marker_is_gated() { + let mut state = GameState::new_two_player(42); + state.pending_cast = Some(Box::new(PendingCast::new( + ObjectId(900), + CardId(900), + ResolvedAbility::new( + Effect::Draw { + count: engine::types::ability::QuantityExpr::Fixed { value: 0 }, + target: TargetFilter::Controller, + }, + Vec::new(), + ObjectId(900), + P0, + ), + ManaCost::Cost { + shards: vec![engine::types::mana::ManaCostShard::Blue], + generic: 2, + }, + ))); + let object_id = ObjectId(901); + let decision = + improvise_mana_payment_decision(vec![native_blue_tap_candidate(object_id)], object_id); + let config = create_config(AiDifficulty::VeryHard, Platform::Wasm); + let context = AiContext::empty(&config.weights); + let ctx = PolicyContext { + state: &state, + decision: &decision, + candidate: &decision.candidates[1], + ai_player: P0, + config: &config, + context: &context, + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + assert_eq!(assess_candidate(&ctx), GateDecision::Allow); + } + + /// Once colored demand is satisfied (a generic-only remaining cost), the + /// Colorless marker is fine again — this isn't a blanket ban on + /// Improvise/Convoke for dual-purpose permanents. + #[test] + fn allows_convoke_colorless_tap_once_colored_demand_is_satisfied() { + let mut state = GameState::new_two_player(42); + state.pending_cast = Some(Box::new(PendingCast::new( + ObjectId(900), + CardId(900), + ResolvedAbility::new( + Effect::Draw { + count: engine::types::ability::QuantityExpr::Fixed { value: 0 }, + target: TargetFilter::Controller, + }, + Vec::new(), + ObjectId(900), + P0, + ), + ManaCost::Cost { + shards: Vec::new(), + generic: 3, + }, + ))); + let object_id = ObjectId(901); + let decision = + improvise_mana_payment_decision(vec![native_blue_tap_candidate(object_id)], object_id); + let config = create_config(AiDifficulty::VeryHard, Platform::Wasm); + let context = AiContext::empty(&config.weights); + let ctx = convoke_candidate_ctx(&state, &decision, &config, &context); + assert_eq!(assess_candidate(&ctx), GateDecision::Allow); + } + + /// A permanent with no sibling native colored option (a plain + /// non-mana-producing artifact) is unaffected by the gate — it's scoped + /// to true tap-channel dominance, not all Colorless taps. + #[test] + fn allows_convoke_colorless_tap_on_permanent_with_no_native_colored_option() { + let mut state = GameState::new_two_player(42); + state.pending_cast = Some(Box::new(PendingCast::new( + ObjectId(900), + CardId(900), + ResolvedAbility::new( + Effect::Draw { + count: engine::types::ability::QuantityExpr::Fixed { value: 0 }, + target: TargetFilter::Controller, + }, + Vec::new(), + ObjectId(900), + P0, + ), + ManaCost::Cost { + shards: vec![engine::types::mana::ManaCostShard::Blue], + generic: 2, + }, + ))); + let object_id = ObjectId(901); + let decision = improvise_mana_payment_decision(Vec::new(), object_id); + let config = create_config(AiDifficulty::VeryHard, Platform::Wasm); + let context = AiContext::empty(&config.weights); + let ctx = convoke_candidate_ctx(&state, &decision, &config, &context); + assert_eq!(assess_candidate(&ctx), GateDecision::Allow); + } + + /// CR 702.51a: the production Convoke candidate path (`mana_payment_actions` + /// via `candidate_actions_broad`) offers a Colorless marker AND a matching + /// colored marker for the same creature when its color is in the cost. The + /// Improvise-only tests above build a synthetic native-mana sibling and + /// never exercise this real Convoke-generated pair -- confirmed missing by + /// review on #6840: `sibling_native_tap_pays_demand` didn't recognize a + /// colored `TapForConvoke` on the same object as a dominating sibling, so a + /// real Convoke spell with a colored pip could still dead-end. + #[test] + fn rejects_convoke_colorless_tap_when_real_convoke_colored_sibling_covers_demand() { + let mut scenario = GameScenario::new(); + let creature = scenario.add_creature(P0, "Convoke Creature", 2, 2).id(); + let mut runner = scenario.build(); + { + let state = runner.state_mut(); + state.objects.get_mut(&creature).unwrap().color = + vec![engine::types::mana::ManaColor::Blue]; + state.pending_cast = Some(Box::new(PendingCast::new( + ObjectId(900), + CardId(900), + ResolvedAbility::new( + Effect::Draw { + count: engine::types::ability::QuantityExpr::Fixed { value: 0 }, + target: TargetFilter::Controller, + }, + Vec::new(), + ObjectId(900), + P0, + ), + ManaCost::Cost { + shards: vec![engine::types::mana::ManaCostShard::Blue], + generic: 1, + }, + ))); + state.waiting_for = WaitingFor::ManaPayment { + player: P0, + convoke_mode: Some(engine::types::game_state::ConvokeMode::Convoke), + }; + } + let state = runner.state(); + let candidates = engine::ai_support::candidate_actions_broad(state); + let colorless = candidates + .iter() + .find(|c| { + matches!( + c.action, + GameAction::TapForConvoke { + object_id, + mana_type: ManaType::Colorless, + } if object_id == creature + ) + }) + .expect("production candidate path must offer the Colorless convoke tap") + .clone(); + let colored = candidates + .iter() + .find(|c| { + matches!( + c.action, + GameAction::TapForConvoke { + object_id, + mana_type: ManaType::Blue, + } if object_id == creature + ) + }) + .expect("production candidate path must offer the matching colored convoke tap") + .clone(); + + let decision = AiDecisionContext { + waiting_for: state.waiting_for.clone(), + candidates: candidates.clone(), + }; + let config = create_config(AiDifficulty::VeryHard, Platform::Wasm); + let context = AiContext::empty(&config.weights); + + let colorless_ctx = PolicyContext { + state, + decision: &decision, + candidate: &colorless, + ai_player: P0, + config: &config, + context: &context, + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + assert_eq!(assess_candidate(&colorless_ctx), GateDecision::Reject); + + let colored_ctx = PolicyContext { + state, + decision: &decision, + candidate: &colored, + ai_player: P0, + config: &config, + context: &context, + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + assert_eq!(assess_candidate(&colored_ctx), GateDecision::Allow); + } + + /// Builds an Improvise-eligible artifact with one `Activated` mana ability + /// producing Blue under the given `cost`, plus a `{1}{U}` pending cast, and + /// returns the production `ManaPayment` candidates + /// (`candidate_actions_broad` -> `mana_payment_actions`) for it. + fn improvise_artifact_with_mana_ability_candidates( + cost: Option, + ) -> ( + engine::game::scenario::GameRunner, + ObjectId, + Vec, + ) { + let mut scenario = GameScenario::new(); + let artifact = scenario.add_creature(P0, "Improvise Artifact", 0, 0).id(); + let mut runner = scenario.build(); + { + let state = runner.state_mut(); + let obj = state.objects.get_mut(&artifact).unwrap(); + obj.card_types.core_types.push(CoreType::Artifact); + let mut mana_ability = engine::types::ability::AbilityDefinition::new( + engine::types::ability::AbilityKind::Activated, + Effect::Mana { + produced: engine::types::ability::ManaProduction::Fixed { + colors: vec![engine::types::mana::ManaColor::Blue], + contribution: engine::types::ability::ManaContribution::Base, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + ); + mana_ability.cost = cost; + std::sync::Arc::make_mut(&mut obj.abilities).push(mana_ability); + + state.pending_cast = Some(Box::new(PendingCast::new( + ObjectId(900), + CardId(900), + ResolvedAbility::new( + Effect::Draw { + count: engine::types::ability::QuantityExpr::Fixed { value: 0 }, + target: TargetFilter::Controller, + }, + Vec::new(), + ObjectId(900), + P0, + ), + ManaCost::Cost { + shards: vec![engine::types::mana::ManaCostShard::Blue], + generic: 1, + }, + ))); + state.waiting_for = WaitingFor::ManaPayment { + player: P0, + convoke_mode: Some(engine::types::game_state::ConvokeMode::Improvise), + }; + } + let candidates = engine::ai_support::candidate_actions_broad(runner.state()); + (runner, artifact, candidates) + } + + fn find_colorless_convoke_candidate( + candidates: &[CandidateAction], + object_id: ObjectId, + ) -> CandidateAction { + candidates + .iter() + .find(|c| { + matches!( + c.action, + GameAction::TapForConvoke { + object_id: o, + mana_type: ManaType::Colorless, + } if o == object_id + ) + }) + .expect("production candidate path must offer the Colorless convoke tap") + .clone() + } + + /// Review finding on #6840: a tapless mana ability (e.g. a + /// sacrifice-based one) on the SAME permanent as the Colorless Improvise + /// marker does not compete for the tap -- both can legally be used in the + /// same payment (Colorless first, then sacrifice the permanent for its + /// ability), so it must not gate the Colorless action. Drives the real + /// production `ManaPayment` candidate set, not a synthetic sibling. + #[test] + fn allows_colorless_improvise_tap_when_sibling_mana_ability_is_tapless() { + let (runner, artifact, candidates) = improvise_artifact_with_mana_ability_candidates(Some( + engine::types::ability::AbilityCost::Sacrifice( + engine::types::ability::SacrificeCost::count(TargetFilter::Any, 1), + ), + )); + let state = runner.state(); + let colorless = find_colorless_convoke_candidate(&candidates, artifact); + let decision = AiDecisionContext { + waiting_for: state.waiting_for.clone(), + candidates: candidates.clone(), + }; + let config = create_config(AiDifficulty::VeryHard, Platform::Wasm); + let context = AiContext::empty(&config.weights); + let ctx = PolicyContext { + state, + decision: &decision, + candidate: &colorless, + ai_player: P0, + config: &config, + context: &context, + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + assert_eq!(assess_candidate(&ctx), GateDecision::Allow); + } + + /// Regression guard for the fix above: a genuine tap-cost native mana + /// ability on the same permanent still gates the Colorless marker, via + /// the real production candidate path. + #[test] + fn rejects_colorless_improvise_tap_when_sibling_mana_ability_taps() { + let (runner, artifact, candidates) = improvise_artifact_with_mana_ability_candidates(Some( + engine::types::ability::AbilityCost::Tap, + )); + let state = runner.state(); + let colorless = find_colorless_convoke_candidate(&candidates, artifact); + let decision = AiDecisionContext { + waiting_for: state.waiting_for.clone(), + candidates: candidates.clone(), + }; + let config = create_config(AiDifficulty::VeryHard, Platform::Wasm); + let context = AiContext::empty(&config.weights); + let ctx = PolicyContext { + state, + decision: &decision, + candidate: &colorless, + ai_player: P0, + config: &config, + context: &context, + cast_facts: None, + search_depth: crate::policies::context::SearchDepth::Root, + }; + assert_eq!(assess_candidate(&ctx), GateDecision::Reject); + } } From bca2034e59e97f29babbb6ca5f7dc291f63b7ad5 Mon Sep 17 00:00:00 2001 From: Bradd McBrearty <35860549+mcbradd@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:44:19 -0700 Subject: [PATCH 60/63] fix(build): Windows-compat fixes for Tilt cmd parsing and Scryfall fetch race (#6775) * fix(build): Windows-compat fixes for Tilt cmd parsing and Scryfall fetch race Tilt runs STRING cmds through cmd.exe on Windows, which mangles nested double-quotes before bash ever sees them; switch wasm/clippy/card-data to list-form cmd to bypass cmd.exe's string parsing entirely. scryfall_download()'s temp-file+rename relies on POSIX rename silently replacing a file another process has open; Windows can't do that, so the losing side of setup.sh's parallel Scryfall fetches hard-failed instead of harmlessly losing. Fall back to accepting the winner's file when rename fails. Co-Authored-By: Claude Sonnet 5 * fix(scripts): caller-supplied validator for Scryfall fetch + mv-failure coverage Resolves review feedback on Windows-compat Scryfall fetch: - scryfall_download/scryfall_fetch_bulk accept optional validator arg (default scryfall_validate_json); single authority applied at both accept points (fresh download and mv-failure destination reuse). - gen-scryfall-sets.sh passes a .data-aware validator, validates a cached SETS_FILE before the OUTPUT early-exit (invalid cache is deleted and refetched once), and gains SCRYFALL_SETS_FILE/OUTPUT/URL override seams for hermetic testing. - scryfall.test.ts: hermetic mv-failure fixture via function shadowing of curl/mv (cases 1-5b) plus validator-discrimination cases (6/6b); every recovery-branch case asserts a branch-unique marker. Co-Authored-By: Claude * fix(PR-6775): preserve validated Windows rename recovery * test(PR-6775): pin rename failure diagnostics --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Matt Evans <1388610+matthewevans@users.noreply.github.com> Co-authored-by: matthewevans --- Tiltfile | 17 +- .../src/services/__tests__/scryfall.test.ts | 365 +++++++++++++++++- scripts/gen-scryfall-sets.sh | 42 +- scripts/lib/scryfall-fetch.sh | 83 +++- 4 files changed, 468 insertions(+), 39 deletions(-) diff --git a/Tiltfile b/Tiltfile index a5d69b3205..ae681c017a 100644 --- a/Tiltfile +++ b/Tiltfile @@ -50,7 +50,14 @@ DRAFT_WASM_SRC = ['crates/draft-wasm/src/'] # build-wasm.sh honors CARGO_TARGET_DIR (defaulting to target/ for CI/deploy # callers that don't set it), so only this dev-loop invocation is relocated. local_resource('wasm', - cmd = 'CARGO_TARGET_DIR=target/wasm ./scripts/build-wasm.sh', + # List-form cmd: Tilt runs a STRING cmd through the platform shell (cmd.exe on + # Windows), which can't parse POSIX `VAR=val cmd` syntax or `./script.sh` -- + # and worse, cmd.exe mangles nested double-quotes before bash ever sees them, + # so a 'bash -c "..."' STRING wrapper fails with "unexpected EOF" (found the + # hard way). A list-form cmd bypasses cmd.exe's string parsing entirely -- + # Tilt execs the argv array directly, so bash receives the script text as one + # untouched element. On Mac/Linux this is equally valid and equally a no-op. + cmd = ['bash', '-c', 'CARGO_TARGET_DIR=target/wasm ./scripts/build-wasm.sh'], deps = ENGINE_SRC + AI_SRC + WASM_SRC + DRAFT_CORE_SRC + DRAFT_WASM_SRC, ignore = TMP_IGNORE, allow_parallel = True, @@ -190,7 +197,9 @@ local_resource('test-frontend', # also gives it its own build lock, so it never queues behind the native test # builds. Cost: a second debug tree on disk (reclaimed by cargo-sweep). local_resource('clippy', - cmd = 'CARGO_TARGET_DIR=target/clippy cargo clippy --all-targets -- -D warnings && CARGO_TARGET_DIR=target/clippy ./scripts/check-interaction-bindings.sh --check', + # List-form cmd: see the 'wasm' resource above for why (a STRING 'bash -c "..."' + # gets its quotes mangled by cmd.exe on Windows; list-form bypasses that). + cmd = ['bash', '-c', 'CARGO_TARGET_DIR=target/clippy cargo clippy --all-targets -- -D warnings && CARGO_TARGET_DIR=target/clippy ./scripts/check-interaction-bindings.sh --check'], deps = ['crates/', 'client/src/adapter/generated/interaction/index.ts', 'scripts/check-interaction-bindings.sh'], ignore = TMP_IGNORE, auto_init = 'lint' in enabled, @@ -213,7 +222,9 @@ local_resource('check-frontend', # --------------------------------------------------------------------------- local_resource('card-data', - cmd = './scripts/gen-card-data.sh', + # List-form cmd: see the 'wasm' resource above for why (a STRING 'bash -c "..."' + # gets its quotes mangled by cmd.exe on Windows; list-form bypasses that). + cmd = ['bash', '-c', './scripts/gen-card-data.sh'], deps = ENGINE_SRC, # gen-card-data.sh promotes these tracked files under crates/engine/data/, which is # in ENGINE_SRC (deps). Watching card-data's own generated outputs makes every diff --git a/client/src/services/__tests__/scryfall.test.ts b/client/src/services/__tests__/scryfall.test.ts index a22e2a86d6..0ae81fc2b2 100644 --- a/client/src/services/__tests__/scryfall.test.ts +++ b/client/src/services/__tests__/scryfall.test.ts @@ -1,8 +1,16 @@ import { execFileSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const REPO_ROOT = path.resolve( @@ -10,6 +18,15 @@ const REPO_ROOT = path.resolve( "../../../..", ); +function withTempDir(run: (dir: string) => void, prefix = "scryfall-") { + const dir = mkdtempSync(path.join(tmpdir(), prefix)); + try { + run(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + function makeLocalDataMap( cards: Record, ): Response { @@ -485,15 +502,6 @@ describe("fetchCardImageAssetByOracleId — reversible cards (issue #2031)", () describe("Scryfall generation scripts — reversible cards (issue #2031)", () => { const oracleId = "ea9709b6-4c37-4d5a-b04d-cd4c42e4f9dd"; - function withTempDir(run: (dir: string) => void) { - const dir = mkdtempSync(path.join(tmpdir(), "scryfall-gen-")); - try { - run(dir); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - } - it("keys image data by face oracle_id when reversible cards omit root oracle_id", () => { withTempDir((dir) => { const input = path.join(dir, "oracle-cards.json"); @@ -987,3 +995,338 @@ describe("local face size resolution", () => { expect(resolvePrintingImageUrl(placeholder, 0, "small")).toBeNull(); }); }); + +describe("scryfall-fetch mv-failure recovery (PR #6775 review)", () => { + const LIB_PATH = path + .join(REPO_ROOT, "scripts/lib/scryfall-fetch.sh") + .replace(/\\/g, "/"); + + // Runs `scryfall_download` in a sourced shell where `curl` and `mv` are + // shadowed by shell functions. Function-shadowing (not a `cp` reassignment + // of SCRYFALL_CURL) is required: SCRYFALL_CURL's real curl invocation is + // array-expanded ("${SCRYFALL_CURL[@]}" -o "$tmp" "$url"), so a stub must be + // a same-named function to intercept it, not a value substituted in for the + // array's first word. The shared finalizer captures mv stderr while it + // decides whether another writer produced a valid destination, so the + // anti-vacuity marker for mv is recorded through a shell variable rather + // than stderr text. The validator is different: the real source + // deliberately runs it inside a subshell (`( "$validator" "$file" )`, see + // scryfall_download's header comment) to keep a caller-supplied + // validator's shell-variable writes from leaking back into this library's + // scope. That isolation is intentional and stays in place — but it means + // a shell-variable marker for the validator would read back as "never + // called" even when it was. A subshell isolates variable writes, not file + // writes, so the validator stub instead records its own invocation as a + // marker *file* under `dir`. + function runMvFailureScript( + dir: string, + opts: { + validator?: string; + destContent?: string; // undefined => destination absent + curlPayload?: string; + mvSucceeds?: boolean; + }, + ): { + out: string; + dest: string; + stderr: string; + validatorCalls: number; + } { + const dest = path.join(dir, "dest.json").replace(/\\/g, "/"); + const payload = path.join(dir, "fixture-payload.json").replace(/\\/g, "/"); + const marker = path.join(dir, "validator-called").replace(/\\/g, "/"); + const stderr = path.join(dir, "scryfall.stderr").replace(/\\/g, "/"); + writeFileSync(payload, opts.curlPayload ?? JSON.stringify({ ok: true })); + if (opts.destContent !== undefined) { + writeFileSync(dest, opts.destContent); + } + + const validatorArg = opts.validator ? ` "${opts.validator}"` : ""; + // The marker file (not a shell variable — see the class comment above) + // proves the caller-supplied validator was actually invoked, not just + // that a validator arg was passed: a stub that hardcodes "validator arg + // present -> fail" without ever calling through would leave no marker + // behind, and the invocation count below would remain zero. + const validatorDef = opts.validator + ? `${opts.validator}() { echo 1 >> "${marker}"; jq -e '.data' "$1" >/dev/null 2>&1; }\n` + : ""; + const mvDef = opts.mvSucceeds + ? "" // no override: the real `mv` binary runs, and succeeds. + : 'mv() { MV_CALLED=1; return 1; }\n'; + + const finalScript = ` +set -uo pipefail +source "${LIB_PATH}" +SCRYFALL_CURL=(curl) +curl() { + local out="" + while [ $# -gt 0 ]; do + case "$1" in + -o) out="$2"; shift 2 ;; + *) shift ;; + esac + done + cp "${payload}" "$out" +} +${mvDef}${validatorDef}scryfall_download "https://example.invalid/data.json" "${dest}"${validatorArg} 2> "${stderr}" +echo "RC=$?" +echo "MV_CALLED=\${MV_CALLED:-0}" +echo "TMP_COUNT=$(ls -1 "${dest}".* 2>/dev/null | wc -l)" +`; + + const out = execFileSync("bash", ["-c", finalScript], { + cwd: REPO_ROOT, + stdio: "pipe", + timeout: 20_000, + }).toString(); + const validatorCalls = existsSync(marker) + ? readFileSync(marker, "utf8").trim().split("\n").length + : 0; + return { + out, + dest, + stderr: readFileSync(stderr, "utf8"), + validatorCalls, + }; + } + + it("case 1: mv fails, destination exists and passes the default validator -> rc 0, tmp cleaned, destination byte-identical to before", () => { + withTempDir((dir) => { + const before = JSON.stringify({ ok: true, marker: "pre-existing" }); + const { out, dest } = runMvFailureScript(dir, { destContent: before }); + + expect(out).toContain("MV_CALLED=1"); + expect(out).toContain("RC=0"); + expect(out).toMatch(/TMP_COUNT=0/); + expect(readFileSync(dest, "utf8")).toBe(before); + }); + }); + + it("case 2: mv fails, destination absent -> rc 1, tmp cleaned", () => { + withTempDir((dir) => { + const { out, dest, stderr } = runMvFailureScript(dir, {}); + + expect(out).toContain("MV_CALLED=1"); + expect(out).toContain("RC=1"); + expect(out).toMatch(/TMP_COUNT=0/); + expect(stderr).toContain("scryfall: could not rename"); + expect(() => readFileSync(dest, "utf8")).toThrow(); + }); + }); + + it("case 3: mv fails, destination is invalid JSON -> rc 1, tmp cleaned", () => { + withTempDir((dir) => { + const before = "not-json{"; + const { out, dest, stderr } = runMvFailureScript(dir, { + destContent: before, + }); + + expect(out).toContain("MV_CALLED=1"); + expect(out).toContain("RC=1"); + expect(out).toMatch(/TMP_COUNT=0/); + expect(stderr).toContain("scryfall: could not rename"); + // Untouched: the recovery path must never overwrite a bad destination + // with the freshly-downloaded (but un-mv'd) tmp content either. + expect(readFileSync(dest, "utf8")).toBe(before); + }); + }); + + it("case 4: mv fails, destination is valid JSON but fails the caller-supplied validator -> rc 1, validator actually invoked", () => { + withTempDir((dir) => { + // Syntactically valid JSON, but missing the `.data` field the + // caller-supplied validator requires — must NOT be accepted merely + // because it parses. + const before = JSON.stringify({ foo: "bar" }); + const { out, validatorCalls } = runMvFailureScript(dir, { + destContent: before, + // The download itself must PASS the caller validator (it is checked + // on the tmp file before the mv) so this case reaches the mv-failure + // recovery branch and fails there, on the destination's shape. + curlPayload: JSON.stringify({ data: [{ fresh: true }] }), + validator: "scryfall_test_validate_has_data", + }); + + expect(out).toContain("MV_CALLED=1"); + expect(out).toContain("RC=1"); + // The tmp gate and the failed-rename recovery check both run the + // caller-supplied validator. Pinning two calls proves this failed on + // the destination's shape rather than merely at the pre-mv gate. + expect(validatorCalls).toBe(2); + }); + }); + + it("case 4b (positive control for case 4): mv fails, destination is valid JSON and PASSES the caller-supplied validator -> rc 0, destination unchanged", () => { + withTempDir((dir) => { + const before = JSON.stringify({ data: [{ foo: "bar" }] }); + const { out, dest, validatorCalls } = runMvFailureScript(dir, { + destContent: before, + // Same pre-mv gate consideration as case 4: the download must pass + // the caller validator for the recovery branch to be reached at all. + curlPayload: JSON.stringify({ data: [{ fresh: true }] }), + validator: "scryfall_test_validate_has_data", + }); + + expect(out).toContain("MV_CALLED=1"); + expect(out).toContain("RC=0"); + expect(readFileSync(dest, "utf8")).toBe(before); + // Both acceptance points must use the caller-supplied validator. + expect(validatorCalls).toBe(2); + }); + }); + + it("case 5: mv succeeds -> normal path, destination written from the download, no recovery marker", () => { + withTempDir((dir) => { + const payload = JSON.stringify({ ok: true, fresh: true }); + const { out, dest } = runMvFailureScript(dir, { + mvSucceeds: true, + curlPayload: payload, + }); + + expect(out).not.toContain("MV_CALLED=1"); + expect(out).toContain("RC=0"); + expect(readFileSync(dest, "utf8")).toBe(payload); + }); + }); + + it("case 5b: freshly-downloaded content fails the caller-supplied validator -> rc 1, destination never written", () => { + withTempDir((dir) => { + // Valid JSON (passes the transport-level scryfall_validate_json + // default gate that always runs on the fresh tmp file) but missing the + // `.data` field the caller-supplied validator requires. The caller + // validator gates the tmp file BEFORE the mv, so the bad body must + // never land at the destination where a later non-validating reader + // (scryfall_fetch_bulk's other callers) would trust it. + const payload = JSON.stringify({ ok: true, fresh: true }); + const { out, dest, validatorCalls } = runMvFailureScript(dir, { + mvSucceeds: true, + curlPayload: payload, + validator: "scryfall_test_validate_has_data", + }); + + expect(out).not.toContain("MV_CALLED=1"); + expect(validatorCalls).toBe(1); + expect(out).toContain("RC=1"); + expect(existsSync(dest)).toBe(false); + // No orphaned tmp file either. + expect(out).toMatch(/TMP_COUNT=0/); + }); + }); + + // Runs gen-scryfall-sets.sh hermetically: a stub `curl` on PATH only ever + // serves file:// URLs (our fixture) and fails fast — no live-network + // fallback — for anything else, so a regression that stops reading the + // SCRYFALL_SETS_FILE/URL/OUTPUT seams can never hit the live + // https://api.scryfall.com/sets endpoint from these tests. + function runSetsScript(dir: string, cachedContent: string): Record { + const binDir = path.join(dir, "bin"); + mkdirSync(binDir); + const stubPath = path.join(binDir, "curl"); + writeFileSync( + stubPath, + `#!/usr/bin/env bash +out="" +url="" +while [ $# -gt 0 ]; do + case "$1" in + -o) out="$2"; shift 2 ;; + http*|file://*) url="$1"; shift ;; + *) shift ;; + esac +done +case "$url" in + file://*) + src="\${url#file://}" + case "$src" in + /?:*) src="\${src#/}" ;; + esac + cp "$src" "$out" + exit 0 + ;; + *) + echo "stub-curl: refusing non-file:// URL: $url" >&2 + exit 1 + ;; +esac +`, + ); + chmodSync(stubPath, 0o755); + + const badCache = path.join(dir, "sets.json"); + writeFileSync(badCache, cachedContent); + const validFixture = path.join(dir, "fixture-sets.json"); + writeFileSync( + validFixture, + JSON.stringify({ + data: [ + { + code: "abc", + name: "A Boring Cardboard", + icon_svg_uri: "https://img.example/abc.svg", + released_at: "2024-01-01", + }, + ], + }), + ); + const output = path.join(dir, "scryfall-sets.json"); + + let threw: unknown; + try { + execFileSync("bash", [path.join(REPO_ROOT, "scripts/gen-scryfall-sets.sh")], { + // A temp cwd keeps a misbehaving run (seams unread, real relative + // "data/scryfall" + "client/public/scryfall-sets.json" paths used + // instead) from writing into the actual repo checkout. + cwd: dir, + env: { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + SCRYFALL_SETS_FILE: badCache, + SCRYFALL_SETS_URL: pathToFileURL(validFixture).href, + SCRYFALL_SETS_OUTPUT: output, + }, + stdio: "pipe", + timeout: 20_000, + }); + } catch (err) { + threw = err; + } + + if (threw !== undefined) { + // Assert *why* it failed, not just *that* it failed — a broken stub, + // missing bash, or a PATH that doesn't propagate through MSYS would + // also throw. The only regression this branch should ever report is + // "seams unread, so the script reached for its hardcoded + // https://api.scryfall.com URL and our stub curl refused it"; any + // other failure cause fails loudly on the assertion below instead. + const err = threw as { stderr?: Buffer | string; stdout?: Buffer | string }; + const stderrText = err.stderr?.toString() ?? ""; + expect(stderrText).toContain("stub-curl: refusing non-file:// URL"); + } + + expect(threw).toBeUndefined(); + return JSON.parse(readFileSync(output, "utf8")); + } + + it("case 6: gen-scryfall-sets.sh discards an invalid cached sets file and refetches from SCRYFALL_SETS_URL", () => { + withTempDir((dir) => { + const generated = runSetsScript(dir, "not-json{"); + expect(generated.abc).toMatchObject({ name: "A Boring Cardboard" }); + }); + }); + + it.each([ + ["empty data array", JSON.stringify({ data: [] })], + ["non-array data", JSON.stringify({ data: "oops" })], + ])( + "case 6b (%s): a cached sets file that is valid JSON but fails the .data array-shape check is discarded and refetched", + (_label, cachedContent) => { + withTempDir((dir) => { + // jq -e '.data' alone is truthy for any non-null value: {"data":"oops"} + // would crash the map() transform, and {"data":[]} would be cached as + // a permanently-empty scryfall-sets.json behind the OUTPUT early-exit. + // Both must be treated exactly like a corrupt cache: discard, refetch. + const generated = runSetsScript(dir, cachedContent); + expect(generated.abc).toMatchObject({ name: "A Boring Cardboard" }); + }); + }, + ); +}); diff --git a/scripts/gen-scryfall-sets.sh b/scripts/gen-scryfall-sets.sh index 82af816f22..f843367c06 100755 --- a/scripts/gen-scryfall-sets.sh +++ b/scripts/gen-scryfall-sets.sh @@ -5,24 +5,48 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/lib/scryfall-fetch.sh" DATA_DIR="data/scryfall" -SETS_FILE="$DATA_DIR/sets.json" -OUTPUT="client/public/scryfall-sets.json" +SETS_FILE="${SCRYFALL_SETS_FILE:-$DATA_DIR/sets.json}" +OUTPUT="${SCRYFALL_SETS_OUTPUT:-client/public/scryfall-sets.json}" +SETS_URL="${SCRYFALL_SETS_URL:-https://api.scryfall.com/sets}" echo "=== Scryfall Sets Data Generation ===" +# scryfall_sets_validate_data FILE — true iff FILE has a top-level `.data` +# non-empty ARRAY, the shape the jq transform below requires (CodeRabbit PR +# #6775 review: a structurally-valid-but-empty/malformed JSON body must not +# be treated as a usable sets cache). `type == "array"` matters: jq -e on a +# bare `.data` is truthy for any non-null value, so {"data":"oops"} would +# pass and then crash the map() transform below; length > 0 keeps an empty +# body from being cached as a permanently-empty scryfall-sets.json. +scryfall_sets_validate_data() { + jq -e '.data | type == "array" and length > 0' "$1" >/dev/null 2>&1 +} + +# Nothing else reads SETS_FILE, so an already-generated OUTPUT means there +# is no work left at all -- exit before the cache check to avoid a needless +# refetch of a discarded cache in an already-complete build. +if [ -f "$OUTPUT" ]; then + echo "Skipping generation — $OUTPUT already exists (delete to regenerate)." + exit 0 +fi + +# A cached SETS_FILE from a previous run may be stale/malformed (partial +# write, incompatible schema); validate it unconditionally before deciding +# whether a download is needed. This is a local file check only -- it never +# touches the network by itself, only the refetch below does. +if [ -f "$SETS_FILE" ] && ! scryfall_sets_validate_data "$SETS_FILE"; then + echo "scryfall: cached $SETS_FILE failed validation, discarding..." >&2 + rm -f "$SETS_FILE" +fi + # Download sets data if not present if [ ! -f "$SETS_FILE" ]; then echo "Downloading Scryfall sets data..." - mkdir -p "$DATA_DIR" - scryfall_download "https://api.scryfall.com/sets" "$SETS_FILE" + mkdir -p "$(dirname "$SETS_FILE")" + scryfall_download "$SETS_URL" "$SETS_FILE" scryfall_sets_validate_data echo "Downloaded $SETS_FILE." fi -if [ -f "$OUTPUT" ]; then - echo "Skipping generation — $OUTPUT already exists (delete to regenerate)." - exit 0 -fi - echo "Generating $OUTPUT..." mkdir -p "$(dirname "$OUTPUT")" diff --git a/scripts/lib/scryfall-fetch.sh b/scripts/lib/scryfall-fetch.sh index b1220f5536..681d31ae60 100644 --- a/scripts/lib/scryfall-fetch.sh +++ b/scripts/lib/scryfall-fetch.sh @@ -35,13 +35,45 @@ scryfall_validate_json() { jq -e 'type' "$1" >/dev/null 2>&1 } -# scryfall_download URL FILE — download URL with retries to a unique temp, -# validate it, then atomically rename into place. The temp+rename keeps -# concurrent writers (setup.sh fetches default-cards.json from two scripts at -# once) and interrupted/throttled downloads from corrupting or clobbering a -# good FILE — readers only ever see the old or new complete file. +# scryfall_finalize_download TMP FILE VALIDATOR — atomically install TMP, or +# accept an existing valid FILE when a concurrent writer wins a Windows rename +# race. On an actual failure, preserve the move diagnostic for the caller. +scryfall_finalize_download() { + local tmp="$1" file="$2" validator="$3" move_error + move_error=$(mktemp "${file}.mv-error.XXXXXX") + if mv -f "$tmp" "$file" 2>"$move_error"; then + rm -f "$move_error" + return 0 + fi + if [ -f "$file" ] && ( "$validator" "$file" ); then + rm -f "$tmp" "$move_error" + return 0 + fi + echo "scryfall: could not rename $tmp into $file:" >&2 + cat "$move_error" >&2 + rm -f "$tmp" "$move_error" + return 1 +} + +# scryfall_download URL FILE [VALIDATOR] — download URL with retries to a +# unique temp, validate it, then atomically rename into place. The temp+rename +# keeps concurrent writers (setup.sh fetches default-cards.json from two +# scripts at once) and interrupted/throttled downloads from corrupting or +# clobbering a good FILE — readers only ever see the old or new complete file. +# +# VALIDATOR is the name of a function to run instead of the default +# scryfall_validate_json when deciding whether a downloaded/pre-existing FILE +# is trustworthy enough to keep (e.g. a caller that needs a specific +# top-level key, not just "is this JSON"). It gates both the common path -- +# the freshly-downloaded tmp file BEFORE the mv into FILE, so a body that +# fails it never lands where a later non-validating reader would trust it -- +# and the mv-failure recovery path below, which re-checks a pre-existing +# FILE left behind by a concurrent writer. scryfall_validate_json itself +# always gates the freshly-downloaded tmp file first -- that check exists to +# catch a throttled/truncated Cloudflare body, a transport-level concern +# independent of the caller's semantic shape. scryfall_download() { - local url="$1" file="$2" tmp + local url="$1" file="$2" validator="${3:-scryfall_validate_json}" tmp tmp=$(mktemp "${file}.XXXXXX") if ! "${SCRYFALL_CURL[@]}" -o "$tmp" "$url"; then rm -f "$tmp" @@ -52,7 +84,20 @@ scryfall_download() { rm -f "$tmp" return 1 fi - mv -f "$tmp" "$file" + # Caller-supplied semantic validation runs on the tmp file, before the mv. + # Skipped for the default validator: the identical bytes already passed the + # identical check above, and default_cards.json-sized bodies make a second + # full jq parse measurably wasteful. Run in a subshell for the same + # scope-isolation reason as the recovery path below. + if [ "$validator" != scryfall_validate_json ] && ! ( "$validator" "$tmp" ); then + echo "scryfall: download of $url failed $validator" >&2 + rm -f "$tmp" + return 1 + fi + # POSIX rename silently replaces FILE even if another process has it open, + # while Windows can reject the losing writer. The shared finalizer preserves + # that concurrent-writer recovery for both JSON and JSONL bulk downloads. + scryfall_finalize_download "$tmp" "$file" "$validator" } # jq prelude shared by the gen-scryfall-*.sh transforms. Prepend it to a jq @@ -77,12 +122,12 @@ def js_downcase: | implode; ' -# scryfall_download_jsonl_gzip URL FILE — download Scryfall's gzip-compressed +# scryfall_download_jsonl_gzip URL FILE [VALIDATOR] — download Scryfall's gzip-compressed # JSON Lines bulk format, validate its card-object records, and stream it into # the JSON array the generators consume. The temp+rename has the same atomicity -# guarantee as scryfall_download. +# guarantee and optional semantic validator as scryfall_download. scryfall_download_jsonl_gzip() { - local url="$1" file="$2" archive tmp + local url="$1" file="$2" validator="${3:-scryfall_validate_json}" archive tmp archive=$(mktemp "${file}.jsonl.gz.XXXXXX") tmp=$(mktemp "${file}.XXXXXX") if ! "${SCRYFALL_CURL[@]}" -o "$archive" "$url"; then @@ -96,16 +141,22 @@ scryfall_download_jsonl_gzip() { rm -f "$archive" "$tmp" return 1 fi + if [ "$validator" != scryfall_validate_json ] && ! ( "$validator" "$tmp" ); then + echo "scryfall: download of $url failed $validator" >&2 + rm -f "$archive" "$tmp" + return 1 + fi rm -f "$archive" - mv -f "$tmp" "$file" + scryfall_finalize_download "$tmp" "$file" "$validator" } -# scryfall_fetch_bulk TYPE FILE — resolve and download a bulk-data export by +# scryfall_fetch_bulk TYPE FILE [VALIDATOR] — resolve and download a bulk-data export by # type (e.g. oracle_cards, default_cards). Prefer Scryfall's legacy JSON array # download when present; otherwise normalize its JSON Lines export to that -# same array shape for existing generators. +# same array shape for existing generators. VALIDATOR is applied to either +# completed output shape before it is promoted. scryfall_fetch_bulk() { - local type="$1" file="$2" metadata uri jsonl_uri + local type="$1" file="$2" validator="${3:-scryfall_validate_json}" metadata uri jsonl_uri metadata=$("${SCRYFALL_CURL[@]}" "https://api.scryfall.com/bulk-data" \ | jq -cer --arg t "$type" '.data[] | select(.type == $t) | { download_uri, @@ -114,12 +165,12 @@ scryfall_fetch_bulk() { || return 1 uri=$(jq -r '.download_uri // empty' <<< "$metadata") if [ -n "$uri" ]; then - scryfall_download "$uri" "$file" + scryfall_download "$uri" "$file" "$validator" return fi jsonl_uri=$(jq -r '.jsonl_download_uri // empty' <<< "$metadata") if [ -n "$jsonl_uri" ]; then - scryfall_download_jsonl_gzip "$jsonl_uri" "$file" + scryfall_download_jsonl_gzip "$jsonl_uri" "$file" "$validator" return fi echo "scryfall: no bulk download URI for type '$type'" >&2 From 58e8f4be959f363df9c29e71adb3995c26bc9a4b Mon Sep 17 00:00:00 2001 From: Matt Evans <1388610+matthewevans@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:35:30 -0700 Subject: [PATCH 61/63] chore: refresh metagame feeds (#6847) --- .../public/feeds/mtggoldfish-commander.json | 1867 +++++++++-------- client/public/feeds/mtggoldfish-modern.json | 2 +- client/public/feeds/mtggoldfish-pioneer.json | 349 +-- client/public/feeds/mtggoldfish-standard.json | 192 +- 4 files changed, 1202 insertions(+), 1208 deletions(-) diff --git a/client/public/feeds/mtggoldfish-commander.json b/client/public/feeds/mtggoldfish-commander.json index 8241f8f9bc..a42ce36667 100644 --- a/client/public/feeds/mtggoldfish-commander.json +++ b/client/public/feeds/mtggoldfish-commander.json @@ -5,392 +5,356 @@ "icon": "G", "format": "commander", "version": 1, - "updated": "2026-07-31T00:00:00Z", + "updated": "2026-08-01T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/commander", "decks": [ { "name": "Doctor Doom, King of Latveria", "author": "MTGGoldfish", - "colors": [], + "colors": [ + "B", + "R", + "U" + ], "tags": [ "metagame" ], "main": [ { "count": 1, - "name": "Baron Strucker, HYDRA Overlord <019e8d87-f118-7026-a8b2-cfef2eca55be> [MSH]" - }, - { - "count": 1, - "name": "Lady Loki's Manifestation <019eb29c-410e-7d71-9d7f-314d9488e464> [MSC]" - }, - { - "count": 1, - "name": "Moonstone, Harsh Mistress <019e9caf-0d43-7213-9733-fc10b1eaa3dc> [MSH]" - }, - { - "count": 1, - "name": "Madame Hydra <019e89aa-fbe0-72a3-9475-3de2c2b50625> [MSH] (F)" - }, - { - "count": 1, - "name": "Chameleon, Master of Disguise <019edce9-ed6f-7a1e-a9e4-327fc639233a> [MSC]" + "name": "Ad Nauseam" }, { "count": 1, - "name": "Tombstone, Career Criminal <019edced-5617-732b-b681-800c637d8865> [MSC]" - }, - { - "count": 1, - "name": "Helmut Zemo, Mastermind <019eb287-0b92-7551-80c6-64836978d799> [MSC]" + "name": "Ash Barrens" }, { "count": 1, - "name": "The Frightful Four <019eb287-047f-796e-97c5-e0361765beef> [MSC]" + "name": "Barren Moor" }, { "count": 1, - "name": "Iron Monger, Sadistic Tycoon <019eb287-035f-7c29-ac83-a2ecd62df3e7> [MSC]" + "name": "Blasted Landscape" }, { "count": 1, - "name": "Klaw, Master of Sound <019eb287-024d-73cb-9c7e-582ade96e4b9> [MSC]" + "name": "Blood Crypt" }, { "count": 1, - "name": "Killmonger, Ruthless Usurper <019eb286-ffb2-7fb3-a057-e7bcf8d99e19> [MSC]" + "name": "Bloodstained Mire" }, { "count": 1, - "name": "Living Laser <019eb288-466e-71cc-9694-a638436095d8> [MSC]" + "name": "Bojuka Bog" }, { "count": 1, - "name": "Puppet Master, String Puller <019eb288-de2e-766d-9575-eafb543ae527> [MSC]" + "name": "Canyon Slough" }, { "count": 1, - "name": "Stilt-Man, Towering Terror <019eb289-a304-7864-8425-c3e4f55291dc> [MSC]" + "name": "Cascade Bluffs" }, { "count": 1, - "name": "Red Ghost, Intangible Genius <019eb292-5c19-7160-8f34-91fd447d95a1> [MSC]" + "name": "Castle Vantress" }, { "count": 1, - "name": "The Squadron Sinister <019eb292-5aff-7793-b763-c382d0163493> [MSC]" + "name": "City of Brass" }, { "count": 1, - "name": "Typhoid Mary, Fractured <019eb292-59dd-7cb1-b2b4-29bc92cfe3bc> [MSC]" + "name": "Clearwater Pathway" }, { "count": 1, - "name": "Ultron, Unlimited <019eb292-58ce-79dc-8555-bb4a88f56cc9> [MSC]" + "name": "Command Tower" }, { "count": 1, - "name": "Taskmaster, Mercenary Mimic <019ea7c9-3385-7034-9ac1-5a05411db56a> [MSH]" + "name": "Creeping Tar Pit" }, { "count": 1, - "name": "Leader, Super-Genius <019e8957-94cc-7087-9d26-c5ec39fe8504> [MSH]" + "name": "Crumbling Necropolis" }, { "count": 1, - "name": "Loki's Double <019eb29c-3f60-746b-88c7-30b6a10c5c24> [MSC]" + "name": "Dark Depths" }, { "count": 1, - "name": "Talisman of Indulgence <019edced-1231-780b-8bb0-f548dfc9d927> [MSC]" + "name": "Demonic Tutor" }, { "count": 1, - "name": "Talisman of Dominance <019eb2a4-4498-7e43-b881-74e45ca623fa> [MSC]" + "name": "Desert of the Fervent" }, { "count": 1, - "name": "Swiftfoot Boots <019eb2a4-43a0-7c7b-9478-4d8b301b391b> [MSC]" + "name": "Desert of the Glorified" }, { "count": 1, - "name": "Sol Ring <019edcec-db35-73ed-a1dd-18d01e1764ee> [MSC]" + "name": "Desert of the Mindful" }, { "count": 1, - "name": "Patchwork Banner <019edcec-5312-79fb-a9a2-d2ebd334bfe7> [MSC]" + "name": "Desolate Lighthouse" }, { "count": 1, - "name": "Arcane Signet <019edce9-889c-79c5-bbb7-f7f82959e873> [MSC]" + "name": "Dimir Aqueduct" }, { "count": 1, - "name": "Loki's Scepter <019edceb-e6d3-7576-88c0-1bfe95504b32> [MSC]" + "name": "Dragonskull Summit" }, { "count": 1, - "name": "Doom's Time Platform <019eb292-5465-7bb1-8faf-dbe9172e703b> [MSC]" + "name": "Drowned Catacomb" }, { "count": 1, - "name": "Currency Converter <019eb2a4-3fa2-71be-8875-c5b055ff2356> [MSC]" + "name": "Exotic Orchard" }, { "count": 1, - "name": "Progenitor's Icon <019edcec-71b1-75c8-bb9b-5f015eff669a> [MSC]" + "name": "Faerie Conclave" }, { "count": 1, - "name": "Propaganda <019edcec-7550-7d35-bf84-34346405f4fa> [MSC]" + "name": "Fetid Pools" }, { "count": 1, - "name": "Night's Whisper <019edcec-3fa0-7e4b-a1ca-26aa5261bc9c> [MSC]" + "name": "Field of the Dead" }, { "count": 1, - "name": "Syphon Mind <019edced-0d3b-7d49-a53e-40d51e6899e9> [MSC]" + "name": "Forgotten Cave" }, { "count": 1, - "name": "Withering Torment <019edced-b4cf-7b2c-ad86-9e352b040e35> [MSC]" + "name": "Geier Reach Sanitarium" }, { "count": 1, - "name": "Vandalblast <019edced-7a2e-78bd-bcfa-78a982538996> [MSC]" + "name": "Ghitu Encampment" }, { "count": 1, - "name": "Terminate <019edced-1c77-7731-8308-544b4cafb238> [MSC]" + "name": "Glint-Horn Buccaneer" }, { "count": 1, - "name": "Extract Power <019eb287-0de6-71ba-9c6e-e2b96260e070> [MSC]" + "name": "Graven Cairns" }, { "count": 1, - "name": "Glorious Purpose <019eb287-0cdc-751f-ac10-017836020bc4> [MSC]" + "name": "Grim Tutor" }, { "count": 1, - "name": "Age of Ultron <019eb287-08be-730b-9568-40def6b056e1> [MSC]" + "name": "Halimar Depths" }, { - "count": 1, - "name": "Endless Ranks of HYDRA <019eb287-062b-7aaa-b471-6b79bfae5777> [MSC]" + "count": 6, + "name": "Island" }, { "count": 1, - "name": "Archnemesis <019eb2a4-3307-72ec-ae45-0b9b6960ce98> [MSC]" + "name": "Izzet Boilerworks" }, { "count": 1, - "name": "Kindred Dominance <019edceb-d02a-7d3a-88c4-4fb87a70fdb6> [MSC]" + "name": "Kher Keep" }, { "count": 1, - "name": "Lethal Scheme <019edceb-da6e-7ca4-ab76-3d1bdd926ae3> [MSC]" + "name": "Lavaclaw Reaches" }, { "count": 1, - "name": "Toxic Deluge <019edced-5a5a-7613-8f98-4c3a622f2aed> [MSC]" + "name": "Lonely Sandbar" }, { "count": 1, - "name": "Blasphemous Act <019edce9-c1bc-7949-8012-b8ae4c112202> [MSC]" + "name": "Luxury Suite" }, { "count": 1, - "name": "Chaos Warp <019edce9-eee2-7bd5-ad96-044104a384f2> [MSC]" + "name": "Mana Confluence" }, { "count": 1, - "name": "Bedevil <019edce9-a529-777e-ba4f-d3fb964a960c> [MSC]" + "name": "Maze of Ith" }, { "count": 1, - "name": "Too Evil to Stay Dead <019ea7c8-cdd7-7d11-a24a-0b820de1ca16> [MSH]" + "name": "Mikokoro, Center of the Sea" }, { "count": 1, - "name": "Super Intelligence <019e8e59-23e8-7f04-abe8-ca973e389895> [MSH]" + "name": "Morphic Pool" }, { "count": 1, - "name": "Visions of Villainy <019ea7c8-d18e-7512-bf68-afa74193c49f> [MSH] (F)" + "name": "Mortuary Mire" }, { - "count": 1, - "name": "Construct a Cosmic Cube <019e895a-9226-7909-b943-0dd11f6c4396> [MSH]" + "count": 6, + "name": "Mountain" }, { "count": 1, - "name": "Frozen in Ice <019ea7c8-9d21-7e4a-94e0-1bb74cbbbe16> [MSH] (F)" + "name": "Mystic Sanctuary" }, { "count": 1, - "name": "Villainous Hideout <019ea7c9-6f5f-790f-8b21-728b5304951e> [MSH]" + "name": "Polluted Delta" }, { "count": 1, - "name": "Terramorphic Expanse <019edced-1f35-70a8-ade2-bad31e5a7009> [MSC]" + "name": "Polluted Mire" }, { "count": 1, - "name": "Secluded Courtyard <019edcec-b41b-79c0-9c38-a74cc3b191a5> [MSC]" + "name": "Rakdos Carnarium" }, { "count": 1, - "name": "Path of Ancestry <019edcec-549b-71b3-93a1-701f1bf568f7> [MSC]" + "name": "Reliquary Tower" }, { "count": 1, - "name": "Crumbling Necropolis <019edcea-b8d5-7c5e-a7fc-364e6a1ac225> [MSC]" + "name": "Remote Isle" }, { "count": 1, - "name": "Command Tower <019edcea-8d1d-7881-bda7-06847d19318d> [MSC]" + "name": "Riverglide Pathway" }, { "count": 1, - "name": "Canyon Slough <019edce9-d821-7282-ba00-f1694f6f6c20> [MSC]" + "name": "Scalding Tarn" }, { "count": 1, - "name": "Coastal Peak <019edcea-4649-78d5-ac5f-299184e42a9e> [MSC]" + "name": "Scavenger Grounds" }, { "count": 1, - "name": "Choked Estuary <019edce9-f343-78da-82ad-354a51ae5c83> [MSC]" + "name": "Scheming Symmetry" }, { "count": 1, - "name": "Drowned Catacomb <019edceb-0c10-7ed3-bd15-4a487f57f9cc> [MSC]" + "name": "Smoldering Crater" }, { "count": 1, - "name": "Dragonskull Summit <019edceb-0a15-7dda-90ea-f057426305d9> [MSC]" + "name": "Smoldering Marsh" }, { "count": 1, - "name": "Fetid Pools <019edceb-2a2d-7b54-be38-8fb26393dce1> [MSC]" + "name": "Sokenzan, Crucible of Defiance" }, { "count": 1, - "name": "Exotic Orchard <019edceb-14de-7b67-a839-469760075ee3> [MSC]" + "name": "Spawning Pool" }, { "count": 1, - "name": "Foreboding Ruins <019edceb-411a-77db-b1a1-234882400823> [MSC]" + "name": "Steam Vents" }, { "count": 1, - "name": "Frostboil Snarl <019edceb-4650-7bed-9027-7966e6f5262e> [MSC]" + "name": "Strip Mine" }, { "count": 1, - "name": "Scavenger Grounds <019edcec-adbf-7f34-987b-10926d88b387> [MSC]" + "name": "Sulfur Falls" }, { "count": 1, - "name": "Luxury Suite <019edceb-f388-74e7-87c3-2ed4e2713ac7> [MSC]" + "name": "Sunken Hollow" }, { "count": 1, - "name": "Scorched Geyser <019edcec-af89-7a8d-a263-f0b0501e2751> [MSC]" + "name": "Sunken Ruins" }, { - "count": 1, - "name": "Sulfur Falls <019edcec-fdc5-7549-ad76-59bf3b19b826> [MSC]" + "count": 7, + "name": "Swamp" }, { "count": 1, - "name": "Hidden Lair <019e8e29-de46-793d-a230-ada6ee82cae5> [MSH]" + "name": "Takenuma, Abandoned Mire" }, { "count": 1, - "name": "Dark Fortress <019e8e29-da87-7d2a-956d-61558e474c1c> [MSH]" + "name": "Temple of Deceit" }, { "count": 1, - "name": "Baxter Building <019e8ce8-99ba-7b5e-9bc4-61fabdfb38d0> [MSH]" - }, - { - "count": 2, - "name": "Swamp <019edce9-34bd-78f3-86ba-786ea04571dc> [MSH]" - }, - { - "count": 3, - "name": "Swamp <019e89ab-2405-725a-bd42-02fa35a6ce98> [MSH]" - }, - { - "count": 2, - "name": "Island <019e89ab-2252-7112-b43c-d2b76119627a> [MSH]" - }, - { - "count": 2, - "name": "Island <019edce8-727b-73d2-a81f-229be6a0f7ad> [MSH]" - }, - { - "count": 2, - "name": "Island <019e89ab-1cd3-7c88-bc0a-a52ba7167b2b> [MSH]" + "name": "Temple of Epiphany" }, { "count": 1, - "name": "Mountain <019e89ab-2564-74bd-9cdd-373b0d723cb3> [MSH]" + "name": "Temple of Malice" }, { "count": 1, - "name": "Mountain <019edce8-c488-7d38-a198-28d0a817068e> [MSH]" + "name": "Thassa's Oracle" }, { "count": 1, - "name": "Mountain <019e89ab-1e6e-76c8-bd02-1dc6d92d27aa> [MSH]" + "name": "Thawing Glaciers" }, { "count": 1, - "name": "Doctor Doom, King of Latveria <019e89cd-782a-7403-97b4-32f281557cee> [MSC] (F)" + "name": "Thespian's Stage" }, { "count": 1, - "name": "We Say Thee Nay! <019ea7c8-afc5-736a-9536-1fe0455952e9> [MSH]" + "name": "Tolaria West" }, { "count": 1, - "name": "Carnage, Crimson Chaos [SPM]" + "name": "Treasure Hunt" }, { "count": 1, - "name": "Doom Reigns Supreme <019e89aa-aae3-7f3d-b212-b5f4a6e3bb06> [MSH] (F)" + "name": "Urborg, Tomb of Yawgmoth" }, { "count": 1, - "name": "Black Market Connections <019eb2a4-139b-748b-bac4-1c7e468e68d5> [MSC]" + "name": "Vampiric Tutor" }, { "count": 1, - "name": "Liliana Vess [PRM-MED] (F)" + "name": "Vesuva" }, { "count": 1, - "name": "Reanimate [LTC]" + "name": "Wandering Fumarole" }, { "count": 1, - "name": "Negate [STA]" + "name": "Watery Grave" }, { "count": 1, - "name": "Arcane Denial <019edce9-844c-794c-afc4-285894fc0d65> [MSC]" + "name": "Xander's Lounge" }, { "count": 1, - "name": "Untimely Malfunction [DSK]" + "name": "Zombie Infestation" }, { "count": 1, - "name": "Norman Osborn [SPM]" + "name": "Doctor Doom, King of Latveria" } ], "sideboard": [] @@ -549,10 +513,6 @@ "count": 1, "name": "Sokka, Swordmaster" }, - { - "count": 1, - "name": "Silence" - }, { "count": 1, "name": "Sram, Senior Edificer" @@ -649,10 +609,6 @@ "count": 1, "name": "Worldslayer" }, - { - "count": 1, - "name": "Alhammarret's Archive" - }, { "count": 1, "name": "Ancient Tomb" @@ -681,10 +637,6 @@ "count": 1, "name": "Howling Mine" }, - { - "count": 1, - "name": "Aura Blast" - }, { "count": 1, "name": "Cut a Deal" @@ -703,17 +655,30 @@ }, { "count": 1, - "name": "Psychosis Crawler" + "name": "Authority of the Consuls" + }, + { + "count": 1, + "name": "Adelbert Steiner" + }, + { + "count": 1, + "name": "Armory Automaton" + }, + { + "count": 1, + "name": "Gods Willing" } ], "sideboard": [] }, { - "name": "Tony Stark", + "name": "Muldrotha, the Gravetide", "author": "MTGGoldfish", "colors": [ - "U", - "R" + "G", + "B", + "U" ], "tags": [ "metagame" @@ -721,1012 +686,1060 @@ "main": [ { "count": 1, - "name": "An Offer You Can't Refuse" + "name": "Satyr Wayfinder" }, { "count": 1, - "name": "Arcane Signet" + "name": "Spore Frog" }, { "count": 1, - "name": "Gogo, Master of Mimicry" + "name": "Haywire Mite" }, { "count": 1, - "name": "Counterspell" + "name": "Gravebreaker Lamia" }, { "count": 1, - "name": "Narset's Reversal" + "name": "Sakura-Tribe Elder" }, { "count": 1, - "name": "Isochron Scepter" + "name": "Doc Aurlock, Grizzled Genius" }, { "count": 1, - "name": "Sol Ring" + "name": "Stitcher's Supplier" }, { "count": 1, - "name": "Command Tower" + "name": "Kheru Goldkeeper" }, { "count": 1, - "name": "Terramorphic Expanse" + "name": "Teval, the Balanced Scale" }, { - "count": 22, - "name": "Island" + "count": 1, + "name": "Aftermath Analyst" }, { "count": 1, - "name": "Swiftfoot Boots" + "name": "Uro, Titan of Nature's Wrath" }, { "count": 1, - "name": "Giggling Skitterspike" + "name": "Rootcoil Creeper" }, { "count": 1, - "name": "Lightning Greaves" + "name": "Lotuslight Dancers" }, { "count": 1, - "name": "Mithril Coat" + "name": "Twilight Diviner" }, { "count": 1, - "name": "Skullclamp" + "name": "Wight of the Reliquary" }, { "count": 1, - "name": "Lithoform Engine" + "name": "Counterspell" }, { "count": 1, - "name": "Twincast" + "name": "Assassin's Trophy" }, { "count": 1, - "name": "Basalt Monolith" + "name": "Grisly Salvage" }, { "count": 1, - "name": "Wandering Archaic" + "name": "Cultivate" }, { "count": 1, - "name": "Sapphire Medallion" + "name": "Kodama's Reach" }, { "count": 1, - "name": "Thought Vessel" + "name": "Three Visits" }, { "count": 1, - "name": "Dramatic Reversal" + "name": "Farseek" }, { "count": 1, - "name": "Rapid Hybridization" + "name": "Buried Alive" }, { "count": 1, - "name": "Taigam, Master Opportunist" + "name": "Toxic Deluge" }, { "count": 1, - "name": "Gifts Ungiven" + "name": "Breach the Multiverse" }, { "count": 1, - "name": "Flare of Denial" + "name": "Terror Tide" }, { "count": 1, - "name": "Pollywog Prodigy" + "name": "Unmarked Grave" }, { "count": 1, - "name": "Pirated Copy" + "name": "Hedge Shredder" }, { "count": 1, - "name": "Mana Drain" + "name": "Nihil Spellbomb" }, { "count": 1, - "name": "Mystic Sanctuary" + "name": "Seal of Removal" }, { "count": 1, - "name": "Flow State" + "name": "Awaken the Honored Dead" }, { "count": 1, - "name": "Mystic Forge" + "name": "Teval's Judgment" }, { "count": 1, - "name": "Krark-Clan Ironworks" + "name": "Insidious Roots" }, { "count": 1, - "name": "Unctus, Grand Metatect" + "name": "Imprisoned in the Moon" }, { "count": 1, - "name": "Foundry Inspector" + "name": "Invasion of Amonkhet" }, { "count": 1, - "name": "Mechanized Production" + "name": "Grist, the Hunger Tide" }, { "count": 1, - "name": "Ornithopter of Paradise" + "name": "Grist, Voracious Larva" }, { "count": 1, - "name": "Tezzeret, Artifice Master" + "name": "Bojuka Bog" }, { "count": 1, - "name": "Sculpting Steel" + "name": "Cephalid Coliseum" }, { "count": 1, - "name": "Fugitive Droid" + "name": "Kishla Village" }, { "count": 1, - "name": "Platinum Angel" + "name": "Sol Ring" }, { "count": 1, - "name": "Fabricate" + "name": "Arcane Signet" }, { "count": 1, - "name": "Jhoira's Familiar" + "name": "Talisman of Resilience" }, { "count": 1, - "name": "Urza, Lord High Artificer" + "name": "Talisman of Dominance" }, { "count": 1, - "name": "Forensic Gadgeteer" + "name": "Talisman of Curiosity" }, { "count": 1, - "name": "Training Grounds" + "name": "Fellwar Stone" }, { "count": 1, - "name": "Grand Architect" + "name": "Chromatic Lantern" }, { "count": 1, - "name": "Memnarch" + "name": "Command Tower" }, { "count": 1, - "name": "Mystical Tutor" + "name": "Opulent Palace" }, { "count": 1, - "name": "Sensei's Divining Top" + "name": "Woodland Cemetery" }, { "count": 1, - "name": "Cybermen Squadron" + "name": "Hinterland Harbor" }, { "count": 1, - "name": "Phyrexian Metamorph" + "name": "Drowned Catacomb" }, { "count": 1, - "name": "Spellskite" + "name": "Sunken Hollow" }, { "count": 1, - "name": "Vision, Synthezoid Avenger" + "name": "Dreamroot Cascade" }, { "count": 1, - "name": "Arcane Denial" + "name": "Deathcap Glade" }, { "count": 1, - "name": "Fellwar Stone" + "name": "Waterlogged Grove" }, { "count": 1, - "name": "Gilded Lotus" + "name": "Fabled Passage" }, { "count": 1, - "name": "Sword of the Animist" + "name": "Deathrite Shaman" }, { "count": 1, - "name": "Mirage Mirror" + "name": "Llanowar Elves" }, { "count": 1, - "name": "Pili-Pala" + "name": "Elvish Mystic" }, { "count": 1, - "name": "Tony Stark" + "name": "Elves of Deep Shadow" }, { - "count": 10, - "name": "Mountain" + "count": 1, + "name": "Llanowar Wastes" }, { "count": 1, - "name": "Talisman of Creativity" + "name": "Yavimaya Coast" }, { "count": 1, - "name": "Steam Vents" + "name": "Underground River" }, { "count": 1, - "name": "Enthusiastic Mechanaut" + "name": "Growing Rites of Itlimoc" }, { "count": 1, - "name": "Flare of Duplication" + "name": "Sidisi, Brood Tyrant" }, { "count": 1, - "name": "Mystic Remora" + "name": "Accursed Marauder" }, { "count": 1, - "name": "Iron Man, Master of Machines" + "name": "Tormod, the Desecrator" }, { - "count": 1, - "name": "Hulkbuster Armor" + "count": 6, + "name": "Forest" }, { - "count": 1, - "name": "Blackblade Reforged" - } - ], - "sideboard": [] - }, - { - "name": "Muldrotha, the Gravetide", - "author": "MTGGoldfish", - "colors": [], - "tags": [ - "metagame" - ], - "main": [ + "count": 5, + "name": "Swamp" + }, { - "count": 1, - "name": "Genesis Wave [FDN]" + "count": 4, + "name": "Island" }, { "count": 1, - "name": "Rakshasa's Bargain [TDM]" + "name": "Vile Entomber" }, { "count": 1, - "name": "Broodspinner [DSK]" + "name": "Honest Rutstein" }, { "count": 1, - "name": "Titan's Grave <019d4a3e-d6ab-7d86-be06-bb27ada89890> [SOS]" + "name": "Harrow" }, { "count": 1, - "name": "Ghalta, Primal Hunger [FDN]" + "name": "Negate" }, { "count": 1, - "name": "Ancient Greenwarden [OTC]" + "name": "Grapple with the Past" }, { "count": 1, - "name": "Goreclaw, Terror of Qal Sisma [BLC]" + "name": "Exotic Orchard" }, { "count": 1, - "name": "Rakshasa's Disdain [FRF]" + "name": "Blighted Woodland" }, { "count": 1, - "name": "Orazca Relic [AFC]" + "name": "Wild Growth" }, { "count": 1, - "name": "Implement of Malice [AER]" + "name": "Witness Protection" }, { "count": 1, - "name": "Ichor Wellspring [C14]" + "name": "Matzalantli, the Great Door" }, { "count": 1, - "name": "Explore [BLC]" + "name": "Molt Tender" }, { "count": 1, - "name": "Champion of Stray Souls [C19]" + "name": "Malevolent Rumble" }, { "count": 1, - "name": "Huskburster Swarm [BLB]" + "name": "End-Raze Forerunners" }, { "count": 1, - "name": "Drowned Secrets [GRN]" + "name": "Kamahl, Heart of Krosa" }, { "count": 1, - "name": "Kheru Goldkeeper [TDM]" + "name": "Syr Konrad, the Grim" }, { "count": 1, - "name": "Sultai Ascendancy [KTK]" + "name": "Viscera Seer" }, { "count": 1, - "name": "Costly Plunder [XLN]" - }, + "name": "Muldrotha, the Gravetide" + } + ], + "sideboard": [] + }, + { + "name": "Valgavoth, Harrower of Souls", + "author": "MTGGoldfish", + "colors": [ + "B", + "R" + ], + "tags": [ + "metagame" + ], + "main": [ { "count": 1, - "name": "Corpse Churn [OGW]" + "name": "Valgavoth, Harrower of Souls" }, { "count": 1, - "name": "Lie in Wait [TDM]" + "name": "The Lord of Pain" }, { "count": 1, - "name": "Sinister Concoction [SOI]" + "name": "Persistent Constrictor" }, { "count": 1, - "name": "Refute [FDN] (F)" + "name": "Sadistic Shell Game" }, { "count": 1, - "name": "Fellwar Stone [BLC]" + "name": "Suspended Sentence" }, { "count": 1, - "name": "Silent Dart [M21]" + "name": "Barbflare Gremlin" }, { "count": 1, - "name": "Unstable Obelisk [AFC]" + "name": "Gleeful Arsonist" }, { "count": 1, - "name": "Lotuslight Dancers [TDM]" + "name": "Spiked Corridor // Torture Pit" }, { "count": 1, - "name": "Moonshadow [ECL]" + "name": "Star Athlete" }, { "count": 1, - "name": "Slavering Branchsnapper [DSK]" + "name": "Seance Board" }, { "count": 1, - "name": "Ishkanah, Grafwidow [EMN]" + "name": "Bedevil" }, { "count": 1, - "name": "Dreadwing Scavenger [FDN] (F)" + "name": "Mogis, God of Slaughter" }, { "count": 1, - "name": "Nashi, Searcher in the Dark [DSK]" + "name": "Braids, Arisen Nightmare" }, { "count": 1, - "name": "Awaken the Honored Dead [TDM]" + "name": "Decree of Pain" }, { "count": 1, - "name": "Wayfarer's Bauble [SCD]" + "name": "Fate Unraveler" }, { "count": 1, - "name": "Kiora, the Rising Tide [FDN]" + "name": "Kederekt Parasite" }, { "count": 1, - "name": "Command Tower [DSC]" + "name": "Mask of Griselbrand" }, { "count": 1, - "name": "Jungle Hollow [FDN]" + "name": "Massacre Girl" }, { "count": 1, - "name": "Thornwood Falls [FDN] (F)" + "name": "Massacre Wurm" }, { "count": 1, - "name": "Dismal Backwater [FDN]" + "name": "Nightshade Harvester" }, { "count": 1, - "name": "Drowned Catacomb [XLN]" + "name": "Blasphemous Act" }, { "count": 1, - "name": "Dimir Guildgate [GTC]" + "name": "Brash Taunter" }, { "count": 1, - "name": "Paradox Gardens <019d4a3e-d0ec-79cf-9253-5c19bfecde94> [SOS]" + "name": "Chaos Warp" }, { "count": 1, - "name": "Arcane Signet [BLC]" + "name": "Combustible Gearhulk" }, { "count": 1, - "name": "Mind Stone [BLC]" + "name": "Enchanter's Bane" }, { "count": 1, - "name": "Commander's Sphere [AFC]" + "name": "Harsh Mentor" }, { "count": 1, - "name": "Archfiend's Vessel [M21]" + "name": "Rampaging Ferocidon" }, { "count": 1, - "name": "Sultai Charm [KTK] (F)" + "name": "Tectonic Giant" }, { "count": 1, - "name": "Mycosynth Wellspring [C14]" + "name": "Florian, Voldaren Scion" }, { "count": 1, - "name": "Sol Ring [TDC]" + "name": "Kaervek the Merciless" }, { "count": 1, - "name": "Stratosoarer [ECL]" + "name": "Rakdos, Lord of Riots" }, { "count": 1, - "name": "Tolarian Terror [FDN]" + "name": "Spiteful Visions" }, { "count": 1, - "name": "Eddymurk Crab [BLB]" + "name": "Stormfist Crusader" }, { "count": 1, - "name": "Rottenmouth Viper [BLB]" + "name": "Theater of Horrors" }, { "count": 1, - "name": "Sultai Banner [KTK]" + "name": "Vial Smasher the Fierce" }, { "count": 1, - "name": "Armillary Sphere [C19]" + "name": "Basilisk Collar" }, { "count": 1, - "name": "Revenge of the Rats [FDN]" + "name": "Solemn Simulacrum" }, { "count": 1, - "name": "Dimir Charm [GK1]" + "name": "Blackcleave Cliffs" }, { "count": 1, - "name": "Search for Azcanta [XLN] (F)" + "name": "Canyon Slough" }, { "count": 1, - "name": "Nihil Spellbomb [SCD]" + "name": "Dragonskull Summit" }, { "count": 1, - "name": "Dimir Signet [GK1]" + "name": "Exotic Orchard" }, { "count": 1, - "name": "Daggermaw Megalodon [DSK]" + "name": "Foreboding Ruins" }, { "count": 1, - "name": "Gurmag Nightwatch [TDM]" + "name": "Graven Cairns" }, { "count": 1, - "name": "Spine of Ish Sah [C14]" + "name": "Shadowblood Ridge" }, { "count": 1, - "name": "Think Twice [FDN]" + "name": "Shivan Gorge" }, { "count": 1, - "name": "Midnight Tilling [ECL]" + "name": "Smoldering Marsh" }, { "count": 1, - "name": "Unsummon [M11]" + "name": "Spinerock Knoll" }, { "count": 1, - "name": "Disdainful Stroke [KHM]" + "name": "Sulfurous Springs" }, { "count": 1, - "name": "Murderous Compulsion [C19]" + "name": "Temple of Malice" }, { "count": 1, - "name": "In Garruk's Wake [C19]" + "name": "Witch's Clinic" }, { "count": 1, - "name": "Mire in Misery [C19]" + "name": "Fear of Burning Alive" }, { "count": 1, - "name": "Staff of Nin [C15]" + "name": "Grab the Prize" }, { "count": 1, - "name": "Witch's Cauldron [M21]" + "name": "Terramorphic Expanse" }, { "count": 1, - "name": "Ravenous Amulet [FDN]" + "name": "Blood Pact" }, { "count": 1, - "name": "Implement of Ferocity [AER]" + "name": "Blood Seeker" }, { - "count": 9, - "name": "Island <285> [FDN]" + "count": 1, + "name": "Feed the Swarm" }, { - "count": 9, - "name": "Forest <285> [DSK]" + "count": 1, + "name": "Arcane Signet" }, { - "count": 8, - "name": "Swamp <371> [STX]" + "count": 1, + "name": "Lightning Greaves" }, { "count": 1, - "name": "Muldrotha, the Gravetide [FDN]" - } - ], - "sideboard": [] - }, - { - "name": "Valgavoth, Harrower of Souls", - "author": "MTGGoldfish", - "colors": [ - "B", - "R" - ], - "tags": [ - "metagame" - ], - "main": [ + "name": "Sol Ring" + }, { "count": 1, - "name": "Valgavoth, Harrower of Souls" + "name": "Command Tower" }, { "count": 1, - "name": "The Lord of Pain" + "name": "Bastion of Remembrance" }, { "count": 1, - "name": "Persistent Constrictor" + "name": "Blood Artist" }, { "count": 1, - "name": "Sadistic Shell Game" + "name": "Falkenrath Noble" }, { "count": 1, - "name": "Suspended Sentence" + "name": "Gray Merchant of Asphodel" }, { "count": 1, - "name": "Barbflare Gremlin" + "name": "Infernal Grasp" }, { "count": 1, - "name": "Gleeful Arsonist" + "name": "Morbid Opportunist" }, { "count": 1, - "name": "Spiked Corridor // Torture Pit" + "name": "Sign in Blood" }, { "count": 1, - "name": "Star Athlete" + "name": "Syr Konrad, the Grim" }, { "count": 1, - "name": "Seance Board" + "name": "Light Up the Stage" }, { "count": 1, - "name": "Bedevil" + "name": "Kardur, Doomscourge" }, { "count": 1, - "name": "Mogis, God of Slaughter" + "name": "Mayhem Devil" }, { "count": 1, - "name": "Braids, Arisen Nightmare" + "name": "Rakdos Charm" }, { "count": 1, - "name": "Decree of Pain" + "name": "Fellwar Stone" }, { "count": 1, - "name": "Fate Unraveler" + "name": "Mind Stone" }, { "count": 1, - "name": "Kederekt Parasite" + "name": "Rakdos Signet" }, { "count": 1, - "name": "Mask of Griselbrand" + "name": "Talisman of Indulgence" }, { "count": 1, - "name": "Massacre Girl" + "name": "Thought Vessel" }, { "count": 1, - "name": "Massacre Wurm" + "name": "Ash Barrens" }, { "count": 1, - "name": "Nightshade Harvester" + "name": "Bloodfell Caves" }, { "count": 1, - "name": "Blasphemous Act" + "name": "Evolving Wilds" }, { "count": 1, - "name": "Brash Taunter" + "name": "Geothermal Bog" }, { "count": 1, - "name": "Chaos Warp" + "name": "Leechridden Swamp" }, { "count": 1, - "name": "Combustible Gearhulk" + "name": "Tainted Peak" }, { "count": 1, - "name": "Enchanter's Bane" + "name": "Temple of the False God" }, + { + "count": 8, + "name": "Swamp" + }, + { + "count": 8, + "name": "Mountain" + } + ], + "sideboard": [ { "count": 1, - "name": "Harsh Mentor" + "name": "Your Nightmares Are Delicious" }, { "count": 1, - "name": "Rampaging Ferocidon" + "name": "You Exist Only to Amuse" }, { "count": 1, - "name": "Tectonic Giant" + "name": "When Will You Learn?" }, { "count": 1, - "name": "Florian, Voldaren Scion" + "name": "Running Is Useless" }, { "count": 1, - "name": "Kaervek the Merciless" + "name": "Reality Is Mine to Control" }, { "count": 1, - "name": "Rakdos, Lord of Riots" + "name": "No Secret Is Hidden from Me" }, { "count": 1, - "name": "Spiteful Visions" + "name": "My Crushing Masterstroke" }, { "count": 1, - "name": "Stormfist Crusader" + "name": "I Call for Slaughter" }, { "count": 1, - "name": "Theater of Horrors" + "name": "Fear My Authority" }, { "count": 1, - "name": "Vial Smasher the Fierce" + "name": "I Will Savor Your Agony" + } + ] + }, + { + "name": "Kaalia of the Vast", + "author": "MTGGoldfish", + "colors": [ + "R", + "B", + "W" + ], + "tags": [ + "metagame" + ], + "main": [ + { + "count": 11, + "name": "Mountain" + }, + { + "count": 11, + "name": "Swamp" + }, + { + "count": 11, + "name": "Plains" }, { "count": 1, - "name": "Basilisk Collar" + "name": "Sol Ring" }, { "count": 1, - "name": "Solemn Simulacrum" + "name": "Arcane Signet" }, { "count": 1, - "name": "Blackcleave Cliffs" + "name": "Thought Vessel" }, { "count": 1, - "name": "Canyon Slough" + "name": "Burning-Rune Demon" }, { "count": 1, - "name": "Dragonskull Summit" + "name": "Razaketh, the Foulblooded" }, { "count": 1, - "name": "Exotic Orchard" + "name": "Rune-Scarred Demon" }, { "count": 1, - "name": "Foreboding Ruins" + "name": "Varragoth, Bloodsky Sire" }, { "count": 1, - "name": "Graven Cairns" + "name": "Tataru Taru" }, { "count": 1, - "name": "Shadowblood Ridge" + "name": "Black Widow, Agile Avenger" }, { "count": 1, - "name": "Shivan Gorge" + "name": "Imperial Recruiter" }, { "count": 1, - "name": "Smoldering Marsh" + "name": "Rionya, Fire Dancer" }, { "count": 1, - "name": "Spinerock Knoll" + "name": "Orthion, Hero of Lavabrink" }, { "count": 1, - "name": "Sulfurous Springs" + "name": "The Fire Crystal" }, { "count": 1, - "name": "Temple of Malice" + "name": "Delina, Wild Mage" }, { "count": 1, - "name": "Witch's Clinic" + "name": "Jaxis, the Troublemaker" }, { "count": 1, - "name": "Fear of Burning Alive" + "name": "Ragavan, Nimble Pilferer" }, { "count": 1, - "name": "Grab the Prize" + "name": "Smothering Tithe" }, { "count": 1, - "name": "Terramorphic Expanse" + "name": "Mangara, the Diplomat" }, { "count": 1, - "name": "Blood Pact" + "name": "Deafening Silence" }, { "count": 1, - "name": "Blood Seeker" + "name": "Winota, Joiner of Forces" }, { "count": 1, - "name": "Feed the Swarm" + "name": "Ethersworn Canonist" }, { "count": 1, - "name": "Arcane Signet" + "name": "Grand Abolisher" }, { "count": 1, - "name": "Lightning Greaves" + "name": "Silence" }, { "count": 1, - "name": "Sol Ring" + "name": "Orim's Chant" }, { "count": 1, - "name": "Command Tower" + "name": "Kaalia of the Vast" }, { "count": 1, - "name": "Bastion of Remembrance" + "name": "Rule of Law" }, { "count": 1, - "name": "Blood Artist" + "name": "Eidolon of Rhetoric" }, { "count": 1, - "name": "Falkenrath Noble" + "name": "Dauntless Dismantler" }, { "count": 1, - "name": "Gray Merchant of Asphodel" + "name": "Archon of Emeria" }, { "count": 1, - "name": "Infernal Grasp" + "name": "Drannith Magistrate" }, { "count": 1, - "name": "Morbid Opportunist" + "name": "Esper Sentinel" }, { "count": 1, - "name": "Sign in Blood" + "name": "Archivist of Oghma" }, { "count": 1, - "name": "Syr Konrad, the Grim" + "name": "Sevinne's Reclamation" }, { "count": 1, - "name": "Light Up the Stage" + "name": "Heliod, Sun-Crowned" }, { "count": 1, - "name": "Kardur, Doomscourge" + "name": "Elesh Norn, Mother of Machines" }, { "count": 1, - "name": "Mayhem Devil" + "name": "Karmic Guide" }, { "count": 1, - "name": "Rakdos Charm" + "name": "Underworld Breach" }, { "count": 1, - "name": "Fellwar Stone" + "name": "Dragonhawk, Fate's Tempest" }, { "count": 1, - "name": "Mind Stone" + "name": "Hoarding Broodlord" }, { "count": 1, - "name": "Rakdos Signet" + "name": "Knollspine Dragon" }, { "count": 1, - "name": "Talisman of Indulgence" + "name": "Worldgorger Dragon" }, { "count": 1, - "name": "Thought Vessel" + "name": "Animate Dead" }, { "count": 1, - "name": "Ash Barrens" + "name": "Angel's Grace" }, { "count": 1, - "name": "Bloodfell Caves" + "name": "Faithless Looting" }, { "count": 1, - "name": "Evolving Wilds" + "name": "Winds of Change" }, { "count": 1, - "name": "Geothermal Bog" + "name": "Wheel of Fortune" }, { "count": 1, - "name": "Leechridden Swamp" + "name": "Talisman of Conviction" }, { "count": 1, - "name": "Tainted Peak" + "name": "Talisman of Hierarchy" }, { "count": 1, - "name": "Temple of the False God" + "name": "Talisman of Indulgence" }, { - "count": 8, - "name": "Swamp" + "count": 1, + "name": "Jeska's Will" }, { - "count": 8, - "name": "Mountain" - } - ], - "sideboard": [ + "count": 1, + "name": "Dark Ritual" + }, { "count": 1, - "name": "Your Nightmares Are Delicious" + "name": "Pyretic Ritual" }, { "count": 1, - "name": "You Exist Only to Amuse" + "name": "Seething Song" }, { "count": 1, - "name": "When Will You Learn?" + "name": "Dark Deal" }, { "count": 1, - "name": "Running Is Useless" + "name": "Will of the Jeskai" }, { "count": 1, - "name": "Reality Is Mine to Control" + "name": "Snort" }, { "count": 1, - "name": "No Secret Is Hidden from Me" + "name": "Toxic Deluge" }, { "count": 1, - "name": "My Crushing Masterstroke" + "name": "Demonic Tutor" }, { "count": 1, - "name": "I Call for Slaughter" + "name": "Vampiric Tutor" }, { "count": 1, - "name": "Fear My Authority" + "name": "Wishclaw Talisman" }, { "count": 1, - "name": "I Will Savor Your Agony" + "name": "Beseech the Mirror" + }, + { + "count": 1, + "name": "Themberchaud" + }, + { + "count": 1, + "name": "Gamble" + }, + { + "count": 1, + "name": "Diabolic Tutor" + }, + { + "count": 1, + "name": "Idyllic Tutor" + }, + { + "count": 1, + "name": "Enlightened Tutor" + }, + { + "count": 1, + "name": "Shadow the Hedgehog" } - ] + ], + "sideboard": [] }, { "name": "Edgar Markov", "author": "MTGGoldfish", "colors": [ + "B", "R", - "W", - "B" + "W" ], "tags": [ "metagame" @@ -1734,436 +1747,448 @@ "main": [ { "count": 1, - "name": "Sorin, Imperious Bloodlord" + "name": "Edgar Markov" }, { "count": 1, - "name": "Sorin, Lord of Innistrad" + "name": "Arcane Signet" }, { "count": 1, - "name": "Sorin, Solemn Visitor" + "name": "Blade of the Bloodchief" }, { "count": 1, - "name": "Arnyn, Deathbloom Botanist" + "name": "Herald's Horn" }, { "count": 1, - "name": "Ascendant Evincar" + "name": "Orzhov Signet" }, { "count": 1, - "name": "Astarion, the Decadent" + "name": "Rakdos Signet" }, { "count": 1, - "name": "Bloodghast" + "name": "Skullclamp" }, { "count": 1, - "name": "Bloodhall Priest" + "name": "Sol Ring" }, { "count": 1, - "name": "Bloodletter of Aclazotz" + "name": "Talisman of Hierarchy" }, { "count": 1, - "name": "Bloodvial Purveyor" + "name": "Vanquisher's Banner" }, { "count": 1, - "name": "Butcher of Malakir" + "name": "Blood Crypt" }, { "count": 1, - "name": "Captivating Vampire" + "name": "Bloodstained Mire" }, { "count": 1, - "name": "Cordial Vampire" + "name": "Bojuka Bog" }, { "count": 1, - "name": "Crossway Troublemakers" + "name": "Cavern of Souls" }, { "count": 1, - "name": "Dominating Vampire" + "name": "Caves of Koilos" }, { "count": 1, - "name": "Drana's Emissary" + "name": "Command Tower" }, { "count": 1, - "name": "Edgar, Charmed Groom" + "name": "Dragonskull Summit" }, { "count": 1, - "name": "Gatekeeper of Malakir" + "name": "Exotic Orchard" }, { "count": 1, - "name": "Indulgent Aristocrat" + "name": "Godless Shrine" }, { "count": 1, - "name": "Iroas, God of Victory" + "name": "Isolated Chapel" }, { "count": 1, - "name": "Preacher of the Schism" + "name": "Luxury Suite" }, { "count": 1, - "name": "Sanctum Seeker" + "name": "Marsh Flats" }, { - "count": 1, - "name": "Scheming Silvertongue" + "count": 3, + "name": "Mountain" }, { "count": 1, - "name": "Scion of Opulence" + "name": "Nomad Outpost" }, { "count": 1, - "name": "Strefan, Maurer Progenitor" + "name": "Path of Ancestry" }, { - "count": 1, - "name": "Stromkirk Captain" + "count": 5, + "name": "Plains" }, { "count": 1, - "name": "Unholy Officiant" + "name": "Sacred Foundry" }, { "count": 1, - "name": "Vampire Nighthawk" + "name": "Savai Triome" }, { "count": 1, - "name": "Voldaren Ambusher" + "name": "Secluded Courtyard" + }, + { + "count": 7, + "name": "Swamp" }, { "count": 1, - "name": "Voldaren Epicure" + "name": "Unclaimed Territory" }, { "count": 1, - "name": "Welcoming Vampire" + "name": "Vault of Champions" }, { "count": 1, - "name": "Lightning Greaves" + "name": "Vault of the Archangel" }, { "count": 1, - "name": "Monument to Endurance" + "name": "Voldaren Estate" }, { "count": 1, - "name": "Sol Ring" + "name": "Blood Artist" }, { "count": 1, - "name": "Swiftfoot Boots" + "name": "Bloodletter of Aclazotz" }, { "count": 1, - "name": "Wedding Ring" + "name": "Bloodline Keeper" }, { "count": 1, - "name": "Worn Powerstone" + "name": "Bloodthirsty Conqueror" }, { "count": 1, - "name": "Anguished Unmaking" + "name": "Captivating Vampire" }, { "count": 1, - "name": "Bitter Triumph" + "name": "Champion of Dusk" }, { "count": 1, - "name": "Boros Charm" + "name": "Charismatic Conqueror" }, { "count": 1, - "name": "Fracture" + "name": "Clavileno, First of the Blessed" }, { "count": 1, - "name": "Go for the Throat" + "name": "Cordial Vampire" }, { "count": 1, - "name": "Tragic Slip" + "name": "Cruel Celebrant" }, { "count": 1, - "name": "Unexpected Windfall" + "name": "Drana, Liberator of Malakir" }, { "count": 1, - "name": "Vampires' Vengeance" + "name": "Edgar, Charmed Groom" }, { "count": 1, - "name": "Village Rites" + "name": "Elenda, the Dusk Rose" }, { "count": 1, - "name": "Bloody Betrayal" + "name": "Forerunner of the Legion" }, { "count": 1, - "name": "Edgar's Awakening" + "name": "Indulgent Aristocrat" }, { "count": 1, - "name": "End Hostilities" + "name": "Knight of the Ebon Legion" }, { "count": 1, - "name": "End the Festivities" + "name": "Legion Lieutenant" }, { "count": 1, - "name": "Farewell" + "name": "Malakir Bloodwitch" }, { "count": 1, - "name": "Improvisation Capstone" + "name": "Markov Baron" }, { "count": 1, - "name": "Meltdown" + "name": "Master of Dark Rites" }, { "count": 1, - "name": "Olivia's Wrath" + "name": "Mavren Fein, Dusk Apostle" }, { "count": 1, - "name": "Reforge the Soul" + "name": "Patron of the Vein" }, { "count": 1, - "name": "Rite of Oblivion" + "name": "Sanctum Seeker" }, { "count": 1, - "name": "Social Snub" + "name": "Stromkirk Captain" }, { "count": 1, - "name": "Spectacular Pileup" + "name": "Twilight Prophet" }, { "count": 1, - "name": "Sundering Eruption" + "name": "Vampire Nighthawk" }, { "count": 1, - "name": "Mass Hysteria" + "name": "Vampire Socialite" }, { "count": 1, - "name": "Ninja Teen" + "name": "Vampire of the Dire Moon" }, { "count": 1, - "name": "Sanguine Bond" + "name": "Vengeful Bloodwitch" }, { "count": 1, - "name": "Shiny Impetus" + "name": "Viscera Seer" }, { "count": 1, - "name": "Wedding Announcement" + "name": "Vito, Thorn of the Dusk Rose" }, { "count": 1, - "name": "Boros Garrison" + "name": "Welcoming Vampire" }, { "count": 1, - "name": "Boros Guildgate" + "name": "Yahenni, Undying Partisan" }, { "count": 1, - "name": "Command Tower" + "name": "Anointed Procession" }, { "count": 1, - "name": "Mortuary Mire" + "name": "Exquisite Blood" }, { - "count": 9, - "name": "Mountain" + "count": 1, + "name": "Phyrexian Arena" }, { - "count": 7, - "name": "Plains" + "count": 1, + "name": "Sanguine Bond" }, { "count": 1, - "name": "Rakdos Carnarium" + "name": "Shared Animosity" }, { "count": 1, - "name": "Scoured Barrens" + "name": "Stensia Masquerade" }, { "count": 1, - "name": "Stone Quarry" + "name": "Anguished Unmaking" }, { "count": 1, - "name": "Sundown Pass" + "name": "Boros Charm" }, { - "count": 9, - "name": "Swamp" + "count": 1, + "name": "Dark Ritual" }, { "count": 1, - "name": "Voldaren Estate" + "name": "Path to Exile" }, { "count": 1, - "name": "Windbrisk Heights" + "name": "Swords to Plowshares" }, { "count": 1, - "name": "Edgar Markov" - } - ], - "sideboard": [] - }, - { - "name": "Krenko, Mob Boss", - "author": "MTGGoldfish", - "colors": [ - "R" - ], - "tags": [ - "metagame" - ], - "main": [ + "name": "Teferi's Protection" + }, { "count": 1, - "name": "Abrade" + "name": "Vampiric Tutor" }, { "count": 1, - "name": "Arcane Signet" + "name": "Village Rites" }, { "count": 1, - "name": "Blasphemous Act" + "name": "Sorin, Imperious Bloodlord" }, { "count": 1, - "name": "Broadside Bombardiers" + "name": "Damn" }, { "count": 1, - "name": "Dogpile" + "name": "Demonic Tutor" }, { "count": 1, - "name": "Enterprising Scallywag" + "name": "New Blood" }, { "count": 1, - "name": "Exuberant Fuseling" + "name": "Olivia's Wrath" }, { "count": 1, - "name": "Fireblade Charger" + "name": "Pact of the Serpent" }, { "count": 1, - "name": "Gempalm Incinerator" + "name": "Ruinous Ultimatum" + } + ], + "sideboard": [] + }, + { + "name": "Krenko, Mob Boss", + "author": "MTGGoldfish", + "colors": [ + "R" + ], + "tags": [ + "metagame" + ], + "main": [ + { + "count": 1, + "name": "Lightning Bolt" }, { "count": 1, - "name": "Goblin Dark-Dwellers" + "name": "Krenko, Mob Boss" + }, + { + "count": 31, + "name": "Mountain" }, { "count": 1, - "name": "Goblin Traprunner" + "name": "Goblin Researcher" }, { "count": 1, - "name": "Guttersnipe" + "name": "Goblin Oriflamme" }, { "count": 1, - "name": "Hidden Volcano" + "name": "Goblin Shortcutter" }, { "count": 1, - "name": "Howlsquad Heavy" + "name": "Hordeling Outburst" }, { "count": 1, - "name": "Impulsive Pilferer" + "name": "Riot Piker" }, { "count": 1, - "name": "Lightning Greaves" + "name": "Goblin Lackey" }, { "count": 1, - "name": "Molten Gatekeeper" + "name": "Icon of Ancestry" }, { - "count": 25, - "name": "Mountain" + "count": 1, + "name": "Barge In" }, { "count": 1, - "name": "Myriad Landscape" + "name": "Impact Tremors" }, { "count": 1, - "name": "Orcish Siegemaster" + "name": "Goblin Arsonist" }, { "count": 1, - "name": "Outnumber" + "name": "Fists of Flame" }, { "count": 1, - "name": "Path of Ancestry" + "name": "Titan's Strength" }, { "count": 1, - "name": "Raid Bombardment" + "name": "Goblin Motivator" }, { "count": 1, - "name": "Sarpadian Simulacrum" + "name": "Goblin War Drums" }, { "count": 1, - "name": "Shared Animosity" + "name": "Goblin Piledriver" }, { "count": 1, - "name": "Siege-Gang Commander" + "name": "Pashalik Mons" }, { "count": 1, - "name": "Skirk Commando" + "name": "Dragon Fodder" }, { "count": 1, @@ -2171,208 +2196,208 @@ }, { "count": 1, - "name": "Squee, Dubious Monarch" + "name": "Boneclub Berserker" }, { "count": 1, - "name": "The Fire Crystal" + "name": "Zada, Hedron Grinder" }, { "count": 1, - "name": "Treasure Nabber" + "name": "Mardu Scout" }, { "count": 1, - "name": "Underfoot Underdogs" + "name": "Boggart Brute" }, { "count": 1, - "name": "Unholy Heat" + "name": "Volley Veteran" }, { "count": 1, - "name": "Wild Ride" + "name": "Krenko, Tin Street Kingpin" }, { "count": 1, - "name": "Hexing Squelcher" + "name": "Ember Hauler" }, { "count": 1, - "name": "Collective Inferno" + "name": "Goblin Negotiation" }, { "count": 1, - "name": "Dawn-Blessed Pennant" + "name": "Mogg Sentry" }, { "count": 1, - "name": "Goblin Warchief" + "name": "Goblin War Party" }, { "count": 1, - "name": "Skirk Prospector" + "name": "Blood Sun" }, { "count": 1, - "name": "Goblin Matron" + "name": "Reckless Ransacking" }, { "count": 1, - "name": "Goblin Chieftain" + "name": "Lunar Frenzy" }, { "count": 1, - "name": "Impact Tremors" + "name": "Goblin Trailblazer" }, { "count": 1, - "name": "Brightstone Ritual" + "name": "Door of Destinies" }, { "count": 1, - "name": "Hobgoblin Bandit Lord" + "name": "Anger" }, { "count": 1, - "name": "Goblin King" + "name": "Siege-Gang Commander" }, { "count": 1, - "name": "Krenko, Baron of Tin Street" + "name": "Goblin Grenade" }, { "count": 1, - "name": "Krenko, Tin Street Kingpin" + "name": "Goblin Guide" }, { "count": 1, - "name": "Mana Echoes" + "name": "Daggersail Aeronaut" }, { "count": 1, - "name": "Goblin Trashmaster" + "name": "Heraldic Banner" }, { "count": 1, - "name": "Conspicuous Snoop" + "name": "Frenzied Goblin" }, { "count": 1, - "name": "Goblin Recruiter" + "name": "Torbran, Thane of Red Fell" }, { "count": 1, - "name": "Goblin Chirurgeon" + "name": "Castle Embereth" }, { "count": 1, - "name": "Goblin War Strike" + "name": "Courageous Goblin" }, { "count": 1, - "name": "Skullclamp" + "name": "Torch Courier" }, { "count": 1, - "name": "Jeska's Will" + "name": "Relentless Assault" }, { "count": 1, - "name": "Gamble" + "name": "Stingscourger" }, { "count": 1, - "name": "Muxus, Goblin Grandee" + "name": "Berserkers' Onslaught" }, { "count": 1, - "name": "Rundvelt Hordemaster" + "name": "Goblin Matron" }, { "count": 1, - "name": "General Kreat, the Boltbringer" + "name": "Goblin Taskmaster" }, { "count": 1, - "name": "Purphoros, God of the Forge" + "name": "Reckless Air Strike" }, { "count": 1, - "name": "Warren Instigator" + "name": "Fanatical Firebrand" }, { "count": 1, - "name": "Goblin Lackey" + "name": "Goblin Ringleader" }, { "count": 1, - "name": "Marvin, Murderous Mimic" + "name": "Goblinslide" }, { "count": 1, - "name": "Deflecting Swat" + "name": "Ferocity of the Wilds" }, { "count": 1, - "name": "Great Train Heist" + "name": "Flame Slash" }, { "count": 1, - "name": "Ruby Medallion" + "name": "Quest for the Goblin Lord" }, { "count": 1, - "name": "Umbral Mantle" + "name": "Goblin Bombardment" }, { "count": 1, - "name": "Helm of the Host" + "name": "Ruby Medallion" }, { "count": 1, - "name": "Koth, Fire of Resistance" + "name": "Brightstone Ritual" }, { "count": 1, - "name": "Three Tree City" + "name": "Goblin Warchief" }, { "count": 1, - "name": "Den of the Bugbear" + "name": "Rundvelt Hordemaster" }, { "count": 1, - "name": "Valakut, the Molten Pinnacle" + "name": "Conspicuous Snoop" }, { "count": 1, - "name": "Nykthos, Shrine to Nyx" + "name": "Goblin Chieftain" }, { "count": 1, - "name": "City on Fire" + "name": "Purphoros, God of the Forge" }, { "count": 1, - "name": "Chronicle of Victory" + "name": "Skullclamp" }, { "count": 1, - "name": "Krenko, Mob Boss" + "name": "Skirk Prospector" + }, + { + "count": 1, + "name": "Kiki-Jiki, Mirror Breaker" } ], "sideboard": [] }, { - "name": "The Ur-Dragon", + "name": "The Unbeatable Squirrel Girl", "author": "MTGGoldfish", "colors": [ - "B", - "R", - "U", - "G", - "W" + "G" ], "tags": [ "metagame" @@ -2380,733 +2405,731 @@ "main": [ { "count": 1, - "name": "The Ur-Dragon" + "name": "Tippy-Toe, Terrific Partner" }, { "count": 1, - "name": "Ancient Silver Dragon" + "name": "Goobbue Gardener" }, { "count": 1, - "name": "Astral Dragon" + "name": "Bloodline Pretender" }, { "count": 1, - "name": "Atarka, World Render" + "name": "Nut Collector" }, { "count": 1, - "name": "Birds of Paradise" + "name": "Jaheira, Friend of the Forest" }, { "count": 1, - "name": "Bloom Tender" + "name": "Scurry Oak" }, { "count": 1, - "name": "Cavern-Hoard Dragon" + "name": "The Unbeatable Squirrel Girl" }, { "count": 1, - "name": "Delighted Halfling" + "name": "Woodland Champion" }, { "count": 1, - "name": "Dragonhawk, Fate's Tempest" + "name": "Keeper of Fables" }, { "count": 1, - "name": "Dragonlord Dromoka" + "name": "Squirrel Sovereign" }, { "count": 1, - "name": "Goldspan Dragon" + "name": "Squirrel Mob" }, { "count": 1, - "name": "Hellkite Courser" + "name": "End-Raze Forerunners" }, { "count": 1, - "name": "Klauth, Unrivaled Ancient" + "name": "Toski, Bearer of Secrets" }, { "count": 1, - "name": "Lathliss, Dragon Queen" + "name": "Eternal Witness" }, { "count": 1, - "name": "Miirym, Sentinel Wyrm" + "name": "Essence Warden" }, { "count": 1, - "name": "Morophon, the Boundless" + "name": "Chatter of the Squirrel" }, { "count": 1, - "name": "Old Gnawbone" + "name": "Chorus of Might" }, { "count": 1, - "name": "Realmwalker" + "name": "Might of the Masses" }, { "count": 1, - "name": "Rith, Liberated Primeval" + "name": "Chatterstorm" }, { "count": 1, - "name": "Rivaz of the Claw" + "name": "Three Visits" }, { "count": 1, - "name": "Roaming Throne" + "name": "Fog" }, { "count": 1, - "name": "Sarkhan, Soul Aflame" + "name": "Nature's Lore" }, { "count": 1, - "name": "Savage Ventmaw" + "name": "Moment's Peace" }, { "count": 1, - "name": "Scion of Draco" + "name": "Titania's Command" }, { "count": 1, - "name": "Scourge of Valkas" + "name": "Squirrel Sanctuary" }, { "count": 1, - "name": "Terror of the Peaks" + "name": "Squirrel Nest" }, { "count": 1, - "name": "Tiamat" + "name": "Cloakwood Hermit" }, { "count": 1, - "name": "Twinflame Tyrant" + "name": "Sylvan Anthem" }, { "count": 1, - "name": "Ureni of the Unwritten" + "name": "Colossification" }, { "count": 1, - "name": "Utvara Hellkite" + "name": "Druid's Call" }, { "count": 1, - "name": "Wrathful Red Dragon" + "name": "Darksteel Plate" }, { "count": 1, - "name": "Zurgo and Ojutai" + "name": "Lightning Greaves" }, { "count": 1, - "name": "Arcane Signet" + "name": "Chitterspitter" }, { "count": 1, - "name": "Cursed Mirror" + "name": "Sol Ring" }, { "count": 1, - "name": "Firdoch Core" + "name": "Summoner's Grimoire" }, { "count": 1, - "name": "Mana Vault" + "name": "Door of Destinies" }, { "count": 1, - "name": "Mox Jasper" + "name": "Herald's Horn" }, { "count": 1, - "name": "Sol Ring" + "name": "Hall of Triumph" }, { "count": 1, - "name": "The Great Henge" + "name": "Path of Ancestry" }, { "count": 1, - "name": "Urza's Incubator" + "name": "War Room" }, { "count": 1, - "name": "Aggravated Assault" + "name": "Temple of the False God" }, { "count": 1, - "name": "Dragon Tempest" + "name": "Reliquary Tower" }, { - "count": 1, - "name": "Mystic Remora" + "count": 25, + "name": "Forest" }, { "count": 1, - "name": "Rhythm of the Wild" + "name": "Gingerbread Cabin" }, { "count": 1, - "name": "Rhystic Study" + "name": "Curious Forager" }, { "count": 1, - "name": "Temur Ascendancy" + "name": "Thornvault Forager" }, { "count": 1, - "name": "Up the Beanstalk" + "name": "Honored Dreyleader" }, { "count": 1, - "name": "Assassin's Trophy" + "name": "Deep Forest Hermit" }, { "count": 1, - "name": "Cyclonic Rift" + "name": "Bloodroot Apothecary" }, { "count": 1, - "name": "Enlightened Tutor" + "name": "Bushy Bodyguard" }, { "count": 1, - "name": "Heroic Intervention" + "name": "Cache Grab" }, { "count": 1, - "name": "Smuggler's Surprise" + "name": "Verdant Command" }, { "count": 1, - "name": "Stubborn Denial" + "name": "Rootcast Apprenticeship" }, { "count": 1, - "name": "Swan Song" + "name": "For the Common Good" }, { "count": 1, - "name": "Swords to Plowshares" + "name": "Preposterous Proportions" }, { "count": 1, - "name": "Teferi's Protection" + "name": "Go Nuts!" }, { "count": 1, - "name": "Vampiric Tutor" + "name": "Wear Down" }, { "count": 1, - "name": "Worldly Tutor" + "name": "Sword of the Squeak" }, { "count": 1, - "name": "Blasphemous Act" + "name": "Maskwood Nexus" }, { "count": 1, - "name": "Crux of Fate" + "name": "Acorn Catapult" }, { "count": 1, - "name": "Demonic Tutor" + "name": "Oakhollow Village" }, { "count": 1, - "name": "Farseek" + "name": "The Shire" }, { "count": 1, - "name": "Nature's Lore" + "name": "Swarmyard" }, { "count": 1, - "name": "Patriarch's Bidding" + "name": "Valley Mightcaller" }, { "count": 1, - "name": "Skyshroud Claim" + "name": "Champion of Lambholt" }, { "count": 1, - "name": "Three Visits" + "name": "Metallic Mimic" }, { "count": 1, - "name": "Arid Mesa" + "name": "Spider-Ham, Peter Porker" }, { "count": 1, - "name": "Blood Crypt" + "name": "Realmwalker" }, { "count": 1, - "name": "Bloodstained Mire" + "name": "Scurry of Squirrels" }, { "count": 1, - "name": "Boseiju, Who Endures" + "name": "Treetop Sentries" }, { "count": 1, - "name": "Breeding Pool" + "name": "Scurrid Colony" }, { "count": 1, - "name": "Cavern of Souls" + "name": "Chameleon Colossus" }, { "count": 1, - "name": "City of Brass" + "name": "Gilded Goose" }, { "count": 1, - "name": "Command Tower" + "name": "Surrak and Goreclaw" }, { "count": 1, - "name": "Exotic Orchard" + "name": "Nylea, God of the Hunt" }, { "count": 1, - "name": "Flooded Strand" - }, + "name": "Ohran Frostfang" + } + ], + "sideboard": [] + }, + { + "name": "Jodah, the Unifier", + "author": "MTGGoldfish", + "colors": [ + "B", + "R", + "U", + "G", + "W" + ], + "tags": [ + "metagame" + ], + "main": [ { "count": 1, - "name": "Forest" + "name": "Indatha Triome" }, { "count": 1, - "name": "Godless Shrine" + "name": "Ketria Triome" }, { "count": 1, - "name": "Hallowed Fountain" + "name": "Raugrin Triome" }, { "count": 1, - "name": "Haven of the Spirit Dragon" + "name": "Savai Triome" }, { "count": 1, - "name": "Indatha Triome" + "name": "Zagoth Triome" }, { "count": 1, - "name": "Ketria Triome" + "name": "Spara's Headquarters" }, { "count": 1, - "name": "Maelstrom of the Spirit Dragon" + "name": "Raffine's Tower" }, { "count": 1, - "name": "Mana Confluence" + "name": "Xander's Lounge" }, { "count": 1, - "name": "Marsh Flats" + "name": "Ziatora's Proving Ground" }, { "count": 1, - "name": "Misty Rainforest" + "name": "Jetmir's Garden" }, { "count": 1, - "name": "Mountain" + "name": "Plaza of Heroes" }, { "count": 1, - "name": "Overgrown Tomb" + "name": "Command Tower" }, { "count": 1, - "name": "Plaza of Heroes" + "name": "Reliquary Tower" }, { "count": 1, - "name": "Polluted Delta" + "name": "Boseiju, Who Endures" }, { "count": 1, - "name": "Reflecting Pool" + "name": "Shizo, Death's Storehouse" }, { "count": 1, - "name": "Sacred Foundry" + "name": "Minamo, School at Water's Edge" }, { "count": 1, - "name": "Scalding Tarn" + "name": "Blood Crypt" }, { "count": 1, - "name": "Steam Vents" + "name": "Breeding Pool" }, { "count": 1, - "name": "Stomping Ground" + "name": "Godless Shrine" }, { "count": 1, - "name": "Temple Garden" + "name": "Hallowed Fountain" }, { "count": 1, - "name": "Verdant Catacombs" + "name": "Overgrown Tomb" }, { "count": 1, - "name": "Watery Grave" + "name": "Sacred Foundry" }, { "count": 1, - "name": "Windswept Heath" + "name": "Steam Vents" }, { "count": 1, - "name": "Wooded Foothills" - } - ], - "sideboard": [] - }, - { - "name": "Kaalia of the Vast", - "author": "MTGGoldfish", - "colors": [ - "W", - "R", - "B" - ], - "tags": [ - "metagame" - ], - "main": [ + "name": "Stomping Ground" + }, { "count": 1, - "name": "Gisela, Blade of Goldnight" + "name": "Temple Garden" }, { "count": 1, - "name": "Aurelia, the Warleader" + "name": "Watery Grave" }, { "count": 1, - "name": "Sephara, Sky's Blade" + "name": "Misty Rainforest" }, { "count": 1, - "name": "Liesa, Forgotten Archangel" + "name": "Arid Mesa" }, { "count": 1, - "name": "Reya Dawnbringer" + "name": "Verdant Catacombs" }, { - "count": 1, - "name": "Rakdos, Patron of Chaos" + "count": 2, + "name": "Forest" }, { "count": 1, - "name": "Rakdos, Lord of Riots" + "name": "Plains" }, { "count": 1, - "name": "Drana and Linvala" + "name": "Mountain" }, { "count": 1, - "name": "Isshin, Two Heavens as One" + "name": "Urza's Ruinous Blast" }, { "count": 1, - "name": "Drakuseth, Maw of Flames" + "name": "Sisay, Weatherlight Captain" }, { "count": 1, - "name": "Lyra Dawnbringer" + "name": "Primevals' Glorious Rebirth" }, { "count": 1, - "name": "Akroma, Angel of Wrath" + "name": "Shanid, Sleepers' Scourge" }, { "count": 1, - "name": "Neriv, Heart of the Storm" + "name": "Surrak Dragonclaw" }, { "count": 1, - "name": "Tariel, Reckoner of Souls" + "name": "Heroes' Podium" }, { "count": 1, - "name": "Rune-Scarred Demon" + "name": "The One Ring" }, { "count": 1, - "name": "Master of Cruelties" + "name": "Sol Ring" }, { "count": 1, - "name": "Hellkite Tyrant" + "name": "Arcane Signet" }, { "count": 1, - "name": "Archfiend of Depravity" + "name": "Annie Joins Up" }, { "count": 1, - "name": "Angel of Serenity" + "name": "Jhoira, Weatherlight Captain" }, { "count": 1, - "name": "Angel of Despair" + "name": "Hajar, Loyal Bodyguard" }, { "count": 1, - "name": "Serra's Emissary" + "name": "Reki, the History of Kamigawa" }, { "count": 1, - "name": "Blast-Furnace Hellkite" + "name": "Esika, God of the Tree" }, { "count": 1, - "name": "Steel Hellkite" + "name": "Farseek" }, { "count": 1, - "name": "Harvester of Souls" + "name": "Swords to Plowshares" }, { "count": 1, - "name": "Firemane Commando" + "name": "Nature's Lore" }, { "count": 1, - "name": "Angelic Arbiter" + "name": "Delighted Halfling" }, { "count": 1, - "name": "Bloodgift Demon" + "name": "Kutzil, Malamet Exemplar" }, { "count": 1, - "name": "Aegis Angel" + "name": "Smothering Tithe" }, { "count": 1, - "name": "Terminate" + "name": "Teferi's Protection" }, { "count": 1, - "name": "Boros Charm" + "name": "Cyclonic Rift" }, { "count": 1, - "name": "Path to Exile" + "name": "Fierce Guardianship" }, { "count": 1, - "name": "Mortify" + "name": "Demonic Tutor" }, { "count": 1, - "name": "Swords to Plowshares" + "name": "Vampiric Tutor" }, { "count": 1, - "name": "Crackling Doom" + "name": "Worldly Tutor" }, { "count": 1, - "name": "Utter End" + "name": "Serah Farron" }, { "count": 1, - "name": "Rakdos Charm" + "name": "Kethis, the Hidden Hand" }, { "count": 1, - "name": "Despark" + "name": "Ratadrabik of Urborg" }, { "count": 1, - "name": "Demonic Counsel" + "name": "Shalai, Voice of Plenty" }, { "count": 1, - "name": "Read the Bones" + "name": "Aragorn, the Uniter" }, { "count": 1, - "name": "Sign in Blood" + "name": "Captain Sisay" }, { "count": 1, - "name": "Vindicate" + "name": "Toski, Bearer of Secrets" }, { "count": 1, - "name": "Armageddon" + "name": "Yoshimaru, Ever Faithful" }, { "count": 1, - "name": "Ruinous Ultimatum" + "name": "Boromir, Warden of the Tower" }, { "count": 1, - "name": "Lightning Greaves" + "name": "Venat, Heart of Hydaelyn" }, { "count": 1, - "name": "Swiftfoot Boots" + "name": "Cadric, Soul Kindler" }, { "count": 1, - "name": "Lavaspur Boots" + "name": "Halana and Alena, Partners" }, { "count": 1, - "name": "Whispersilk Cloak" + "name": "Maelstrom Wanderer" }, { "count": 1, - "name": "Boros Signet" + "name": "Najeela, the Blade-Blossom" }, { "count": 1, - "name": "Rakdos Signet" + "name": "Samut, Voice of Dissent" }, { "count": 1, - "name": "Orzhov Signet" + "name": "Havi, the All-Father" }, { "count": 1, - "name": "Arcane Signet" + "name": "Gold-Forged Thopteryx" }, { "count": 1, - "name": "Chromatic Lantern" + "name": "Gandalf the White" }, { "count": 1, - "name": "Sol Ring" + "name": "Basim Ibn Ishaq" }, { "count": 1, - "name": "Dragon Tempest" + "name": "Atraxa, Grand Unifier" }, { "count": 1, - "name": "Rising of the Day" + "name": "Ragavan, Nimble Pilferer" }, { "count": 1, - "name": "Phyrexian Arena" + "name": "Rona, Herald of Invasion" }, { "count": 1, - "name": "Warstorm Surge" + "name": "Ramos, Dragon Engine" }, { "count": 1, - "name": "Kaya's Ghostform" + "name": "Etali, Primal Storm" }, { "count": 1, - "name": "Rampage of the Valkyries" + "name": "Desynchronization" }, { "count": 1, - "name": "Windcrag Siege" + "name": "Deadly Rollick" }, { "count": 1, - "name": "All-Out Assault" + "name": "Growth Spiral" }, { "count": 1, - "name": "Dihada, Binder of Wills" + "name": "Anguished Unmaking" }, { "count": 1, - "name": "Rogue's Passage" + "name": "Void Rend" }, { "count": 1, - "name": "Seraph Sanctuary" + "name": "Kamahl's Druidic Vow" }, { "count": 1, - "name": "Boros Garrison" + "name": "Yawgmoth's Vile Offering" }, { "count": 1, - "name": "Orzhov Basilica" + "name": "Helm of the Host" }, { "count": 1, - "name": "Rakdos Carnarium" + "name": "The Great Henge" }, { "count": 1, - "name": "Nomad Outpost" + "name": "Flowering of the White Tree" }, { "count": 1, - "name": "Command Tower" + "name": "Rakdos Joins Up" }, { "count": 1, - "name": "Clifftop Retreat" + "name": "Kellan Joins Up" }, { "count": 1, - "name": "Isolated Chapel" + "name": "Bard Class" }, { "count": 1, - "name": "Dragonskull Summit" - }, - { - "count": 9, - "name": "Plains" + "name": "The Kenriths' Royal Funeral" }, { - "count": 9, - "name": "Mountain" + "count": 1, + "name": "Dihada, Binder of Wills" }, { - "count": 9, - "name": "Swamp" + "count": 1, + "name": "Shadowspear" }, { "count": 1, - "name": "Kaalia of the Vast" + "name": "Jodah, the Unifier" } ], "sideboard": [] }, { - "name": "Jodah, the Unifier", + "name": "The Ur-Dragon", "author": "MTGGoldfish", "colors": [ - "B", - "R", "U", + "R", + "B", "G", "W" ], @@ -3116,103 +3139,103 @@ "main": [ { "count": 1, - "name": "Indatha Triome" + "name": "The World Tree" }, { "count": 1, - "name": "Ketria Triome" + "name": "Badlands" }, { "count": 1, - "name": "Raugrin Triome" + "name": "Steam Vents" }, { "count": 1, - "name": "Savai Triome" + "name": "Ziatora's Proving Ground" }, { "count": 1, - "name": "Zagoth Triome" + "name": "Verdant Catacombs" }, { "count": 1, - "name": "Spara's Headquarters" + "name": "Cavern of Souls" }, { "count": 1, - "name": "Raffine's Tower" + "name": "Blood Crypt" }, { - "count": 1, - "name": "Xander's Lounge" + "count": 2, + "name": "Forest" }, { "count": 1, - "name": "Ziatora's Proving Ground" + "name": "Misty Rainforest" }, { - "count": 1, - "name": "Jetmir's Garden" + "count": 2, + "name": "Island" }, { "count": 1, - "name": "Plaza of Heroes" + "name": "Jetmir's Garden" }, { "count": 1, - "name": "Command Tower" + "name": "Reflecting Pool" }, { "count": 1, - "name": "Reliquary Tower" + "name": "Maelstrom of the Spirit Dragon" }, { "count": 1, - "name": "Boseiju, Who Endures" + "name": "Arid Mesa" }, { - "count": 1, - "name": "Shizo, Death's Storehouse" + "count": 3, + "name": "Mountain" }, { "count": 1, - "name": "Minamo, School at Water's Edge" + "name": "Chrome Mox" }, { - "count": 1, - "name": "Blood Crypt" + "count": 2, + "name": "Swamp" }, { - "count": 1, - "name": "Breeding Pool" + "count": 2, + "name": "Plains" }, { "count": 1, - "name": "Godless Shrine" + "name": "Marsh Flats" }, { "count": 1, - "name": "Hallowed Fountain" + "name": "Plateau" }, { "count": 1, - "name": "Overgrown Tomb" + "name": "Flooded Strand" }, { "count": 1, - "name": "Sacred Foundry" + "name": "Spara's Headquarters" }, { "count": 1, - "name": "Steam Vents" + "name": "Haven of the Spirit Dragon" }, { "count": 1, - "name": "Stomping Ground" + "name": "Godless Shrine" }, { "count": 1, - "name": "Temple Garden" + "name": "Xander's Lounge" }, { "count": 1, @@ -3220,295 +3243,275 @@ }, { "count": 1, - "name": "Misty Rainforest" - }, - { - "count": 1, - "name": "Arid Mesa" + "name": "Taiga" }, { "count": 1, - "name": "Verdant Catacombs" - }, - { - "count": 2, - "name": "Forest" - }, - { - "count": 1, - "name": "Plains" - }, - { - "count": 1, - "name": "Mountain" + "name": "Command Tower" }, { "count": 1, - "name": "Urza's Ruinous Blast" + "name": "Raffine's Tower" }, { "count": 1, - "name": "Sisay, Weatherlight Captain" + "name": "Tropical Island" }, { "count": 1, - "name": "Primevals' Glorious Rebirth" + "name": "Tundra" }, { "count": 1, - "name": "Shanid, Sleepers' Scourge" + "name": "Volcanic Island" }, { "count": 1, - "name": "Surrak Dragonclaw" + "name": "Sol Ring" }, { "count": 1, - "name": "Heroes' Podium" + "name": "Birds of Paradise" }, { "count": 1, - "name": "The One Ring" + "name": "Ignoble Hierarch" }, { "count": 1, - "name": "Sol Ring" + "name": "Worldly Tutor" }, { "count": 1, - "name": "Arcane Signet" + "name": "Scroll Rack" }, { "count": 1, - "name": "Annie Joins Up" + "name": "Sylvan Scrying" }, { "count": 1, - "name": "Jhoira, Weatherlight Captain" + "name": "Farseek" }, { "count": 1, - "name": "Hajar, Loyal Bodyguard" + "name": "Mana Drain" }, { "count": 1, - "name": "Reki, the History of Kamigawa" + "name": "Mirror of the Forebears" }, { "count": 1, - "name": "Esika, God of the Tree" + "name": "Cyclonic Rift" }, { "count": 1, - "name": "Farseek" + "name": "Lightning Greaves" }, { "count": 1, - "name": "Swords to Plowshares" + "name": "Roiling Dragonstorm" }, { "count": 1, - "name": "Nature's Lore" + "name": "Dragonlord's Servant" }, { "count": 1, - "name": "Delighted Halfling" + "name": "Dragon Tempest" }, { "count": 1, - "name": "Kutzil, Malamet Exemplar" + "name": "Demonic Tutor" }, { "count": 1, - "name": "Smothering Tithe" + "name": "Kodama's Reach" }, { "count": 1, - "name": "Teferi's Protection" + "name": "Herald's Horn" }, { "count": 1, - "name": "Cyclonic Rift" + "name": "Cultivate" }, { "count": 1, - "name": "Fierce Guardianship" - }, - { - "count": 1, - "name": "Demonic Tutor" + "name": "Rhystic Study" }, { "count": 1, - "name": "Vampiric Tutor" + "name": "Force of Despair" }, { "count": 1, - "name": "Worldly Tutor" + "name": "Chromatic Lantern" }, { "count": 1, - "name": "Serah Farron" + "name": "Teferi's Protection" }, { "count": 1, - "name": "Kethis, the Hidden Hand" + "name": "Rivaz of the Claw" }, { "count": 1, - "name": "Ratadrabik of Urborg" + "name": "Sarkhan, Soul Aflame" }, { "count": 1, - "name": "Shalai, Voice of Plenty" + "name": "Dragon's Hoard" }, { "count": 1, - "name": "Aragorn, the Uniter" + "name": "Sarkhan's Triumph" }, { "count": 1, - "name": "Captain Sisay" + "name": "Grim Tutor" }, { "count": 1, - "name": "Toski, Bearer of Secrets" + "name": "Dragonspeaker Shaman" }, { "count": 1, - "name": "Yoshimaru, Ever Faithful" + "name": "Smothering Tithe" }, { "count": 1, - "name": "Boromir, Warden of the Tower" + "name": "The One Ring" }, { "count": 1, - "name": "Venat, Heart of Hydaelyn" + "name": "Smaug the Magnificent" }, { "count": 1, - "name": "Cadric, Soul Kindler" + "name": "Taigam, Ojutai Master" }, { "count": 1, - "name": "Halana and Alena, Partners" + "name": "Dragon Arch" }, { "count": 1, - "name": "Maelstrom Wanderer" + "name": "Crux of Fate" }, { "count": 1, - "name": "Najeela, the Blade-Blossom" + "name": "Kindred Discovery" }, { "count": 1, - "name": "Samut, Voice of Dissent" + "name": "Wrathful Red Dragon" }, { "count": 1, - "name": "Havi, the All-Father" + "name": "Goldspan Dragon" }, { "count": 1, - "name": "Gold-Forged Thopteryx" + "name": "Goldlust Triad" }, { "count": 1, - "name": "Gandalf the White" + "name": "Scion of the Ur-Dragon" }, { "count": 1, - "name": "Basim Ibn Ishaq" + "name": "Sarkhan Unbroken" }, { "count": 1, - "name": "Atraxa, Grand Unifier" + "name": "Surrak Dragonclaw" }, { "count": 1, - "name": "Ragavan, Nimble Pilferer" + "name": "Thrakkus the Butcher" }, { "count": 1, - "name": "Rona, Herald of Invasion" + "name": "Zurgo and Ojutai" }, { "count": 1, - "name": "Ramos, Dragon Engine" + "name": "Steel Hellkite" }, { "count": 1, - "name": "Etali, Primal Storm" + "name": "Two-Headed Hellkite" }, { "count": 1, - "name": "Desynchronization" + "name": "Ancient Copper Dragon" }, { "count": 1, - "name": "Deadly Rollick" + "name": "Dragonback Assault" }, { "count": 1, - "name": "Growth Spiral" + "name": "Dragonlord Dromoka" }, { "count": 1, - "name": "Anguished Unmaking" + "name": "Dragonlord Kolaghan" }, { "count": 1, - "name": "Void Rend" + "name": "Lathliss, Dragon Queen" }, { "count": 1, - "name": "Kamahl's Druidic Vow" + "name": "Miirym, Sentinel Wyrm" }, { "count": 1, - "name": "Yawgmoth's Vile Offering" + "name": "Ramos, Dragon Engine" }, { "count": 1, - "name": "Helm of the Host" + "name": "Hellkite Tyrant" }, { "count": 1, - "name": "The Great Henge" + "name": "Bladewing the Risen" }, { "count": 1, - "name": "Flowering of the White Tree" + "name": "Balefire Dragon" }, { "count": 1, - "name": "Rakdos Joins Up" + "name": "Ancient Bronze Dragon" }, { "count": 1, - "name": "Kellan Joins Up" + "name": "Ancient Brass Dragon" }, { "count": 1, - "name": "Bard Class" + "name": "Ancient Gold Dragon" }, { "count": 1, - "name": "The Kenriths' Royal Funeral" + "name": "Tiamat" }, { "count": 1, - "name": "Dihada, Binder of Wills" + "name": "Utvara Hellkite" }, { "count": 1, - "name": "Shadowspear" + "name": "Ancient Silver Dragon" }, { "count": 1, - "name": "Jodah, the Unifier" + "name": "The Ur-Dragon" } ], "sideboard": [] diff --git a/client/public/feeds/mtggoldfish-modern.json b/client/public/feeds/mtggoldfish-modern.json index ec1f1e94b7..017228e4dd 100644 --- a/client/public/feeds/mtggoldfish-modern.json +++ b/client/public/feeds/mtggoldfish-modern.json @@ -5,7 +5,7 @@ "icon": "G", "format": "modern", "version": 1, - "updated": "2026-07-31T00:00:00Z", + "updated": "2026-08-01T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/modern", "decks": [ { diff --git a/client/public/feeds/mtggoldfish-pioneer.json b/client/public/feeds/mtggoldfish-pioneer.json index fd77f21ca0..61551be429 100644 --- a/client/public/feeds/mtggoldfish-pioneer.json +++ b/client/public/feeds/mtggoldfish-pioneer.json @@ -5,7 +5,7 @@ "icon": "G", "format": "pioneer", "version": 1, - "updated": "2026-07-31T00:00:00Z", + "updated": "2026-08-01T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/pioneer", "decks": [ { @@ -866,117 +866,6 @@ } ] }, - { - "name": "Orzhov Greasefang", - "author": "MTGGoldfish", - "colors": [ - "B", - "W" - ], - "tags": [ - "metagame" - ], - "main": [ - { - "count": 2, - "name": "Bleachbone Verge" - }, - { - "count": 4, - "name": "Brightclimb Pathway" - }, - { - "count": 4, - "name": "Thoughtseize" - }, - { - "count": 4, - "name": "Fatal Push" - }, - { - "count": 4, - "name": "Fleeting Spirit" - }, - { - "count": 1, - "name": "Swamp" - }, - { - "count": 4, - "name": "Greasefang, Okiba Boss" - }, - { - "count": 4, - "name": "Guardian of New Benalia" - }, - { - "count": 4, - "name": "Iron-Shield Elf" - }, - { - "count": 1, - "name": "Lively Dirge" - }, - { - "count": 4, - "name": "Monument to Endurance" - }, - { - "count": 4, - "name": "Parhelion II" - }, - { - "count": 1, - "name": "Plains" - }, - { - "count": 4, - "name": "Sheltered by Ghosts" - }, - { - "count": 1, - "name": "Plains" - }, - { - "count": 1, - "name": "Takenuma, Abandoned Mire" - }, - { - "count": 4, - "name": "The Mycosynth Gardens" - }, - { - "count": 4, - "name": "Godless Shrine" - }, - { - "count": 4, - "name": "Concealed Courtyard" - }, - { - "count": 1, - "name": "Urborg, Tomb of Yawgmoth" - } - ], - "sideboard": [ - { - "count": 4, - "name": "Doorkeeper Thrull" - }, - { - "count": 4, - "name": "Soul-Guide Lantern" - }, - { - "count": 4, - "name": "Vanishing Verse" - }, - { - "count": 3, - "name": "Pest Control" - } - ] - }, { "name": "Izzet Lessons", "author": "MTGGoldfish", @@ -1128,6 +1017,117 @@ } ] }, + { + "name": "Orzhov Greasefang", + "author": "MTGGoldfish", + "colors": [ + "B", + "W" + ], + "tags": [ + "metagame" + ], + "main": [ + { + "count": 2, + "name": "Bleachbone Verge" + }, + { + "count": 4, + "name": "Brightclimb Pathway" + }, + { + "count": 4, + "name": "Thoughtseize" + }, + { + "count": 4, + "name": "Fatal Push" + }, + { + "count": 4, + "name": "Fleeting Spirit" + }, + { + "count": 1, + "name": "Swamp" + }, + { + "count": 4, + "name": "Greasefang, Okiba Boss" + }, + { + "count": 4, + "name": "Guardian of New Benalia" + }, + { + "count": 4, + "name": "Iron-Shield Elf" + }, + { + "count": 1, + "name": "Lively Dirge" + }, + { + "count": 4, + "name": "Monument to Endurance" + }, + { + "count": 4, + "name": "Parhelion II" + }, + { + "count": 1, + "name": "Plains" + }, + { + "count": 4, + "name": "Sheltered by Ghosts" + }, + { + "count": 1, + "name": "Plains" + }, + { + "count": 1, + "name": "Takenuma, Abandoned Mire" + }, + { + "count": 4, + "name": "The Mycosynth Gardens" + }, + { + "count": 4, + "name": "Godless Shrine" + }, + { + "count": 4, + "name": "Concealed Courtyard" + }, + { + "count": 1, + "name": "Urborg, Tomb of Yawgmoth" + } + ], + "sideboard": [ + { + "count": 4, + "name": "Doorkeeper Thrull" + }, + { + "count": 4, + "name": "Soul-Guide Lantern" + }, + { + "count": 4, + "name": "Vanishing Verse" + }, + { + "count": 3, + "name": "Pest Control" + } + ] + }, { "name": "Izzet Phoenix", "author": "MTGGoldfish", @@ -1260,10 +1260,9 @@ ] }, { - "name": "Dimir Self-Bounce", + "name": "Mono-Black Midrange", "author": "MTGGoldfish", "colors": [ - "U", "B" ], "tags": [ @@ -1272,125 +1271,149 @@ "main": [ { "count": 4, - "name": "Boomerang Basics" + "name": "Unholy Annex // Ritual Chamber" }, { - "count": 4, - "name": "Clearwater Pathway" + "count": 3, + "name": "Castle Locthwain" }, { - "count": 4, - "name": "Cryogen Relic" + "count": 1, + "name": "Cecil, Dark Knight" }, { - "count": 4, - "name": "Narset, Parter of Veils" + "count": 7, + "name": "Swamp" }, { - "count": 4, - "name": "Watery Grave" + "count": 1, + "name": "End of the Hunt" }, { "count": 4, - "name": "Fear of Isolation" + "name": "Fatal Push" }, { - "count": 4, - "name": "Gloomlake Verge" + "count": 2, + "name": "Field of Ruin" }, { "count": 4, - "name": "Hopeless Nightmare" + "name": "Gifted Aetherborn" }, { - "count": 4, - "name": "Momentum Breaker" + "count": 1, + "name": "Go Blank" }, { - "count": 4, - "name": "Nowhere to Run" + "count": 7, + "name": "Swamp" }, { - "count": 1, - "name": "Otawara, Soaring City" + "count": 3, + "name": "Graveyard Trespasser" }, { - "count": 4, - "name": "Shipwreck Marsh" + "count": 1, + "name": "Invoke Despair" }, { "count": 4, - "name": "Stormchaser's Talent" + "name": "Mutavault" }, { - "count": 2, - "name": "Swamp" + "count": 1, + "name": "Morlun, Devourer of Spiders" }, { - "count": 2, - "name": "Stock Up" + "count": 1, + "name": "Liliana of the Veil" }, { - "count": 4, - "name": "This Town Ain't Big Enough" + "count": 1, + "name": "Preacher of the Schism" }, { "count": 2, - "name": "Underground River" + "name": "Sheoldred, the Apocalypse" }, { - "count": 4, - "name": "Darkslick Shores" + "count": 1, + "name": "Go for the Throat" }, { - "count": 2, - "name": "Island" + "count": 1, + "name": "Takenuma, Abandoned Mire" }, { - "count": 2, - "name": "Urborg, Tomb of Yawgmoth" + "count": 1, + "name": "The Meathook Massacre" }, { - "count": 4, - "name": "Petrified Hamlet" + "count": 1, + "name": "The Soul Stone" }, { - "count": 4, - "name": "Fatal Push" + "count": 2, + "name": "Duress" }, { "count": 1, - "name": "Takenuma, Abandoned Mire" + "name": "Urborg, Tomb of Yawgmoth" }, { "count": 4, "name": "Thoughtseize" + }, + { + "count": 2, + "name": "Bitterbloom Bearer" } ], "sideboard": [ { - "count": 2, - "name": "The Meathook Massacre" + "count": 1, + "name": "Duress" }, { - "count": 4, - "name": "Change the Equation" + "count": 1, + "name": "Damping Sphere" }, { "count": 1, - "name": "Yorion, Sky Nomad" + "name": "End of the Hunt" }, { - "count": 3, - "name": "Unlicensed Hearse" + "count": 1, + "name": "Extinction Event" }, { - "count": 3, + "count": 1, + "name": "Go Blank" + }, + { + "count": 2, "name": "Invoke Despair" }, { "count": 2, - "name": "Bitter Triumph" + "name": "Necromentia" + }, + { + "count": 2, + "name": "Ray of Enfeeblement" + }, + { + "count": 1, + "name": "Strategic Betrayal" + }, + { + "count": 1, + "name": "The Meathook Massacre" + }, + { + "count": 2, + "name": "Unlicensed Hearse" } ] } diff --git a/client/public/feeds/mtggoldfish-standard.json b/client/public/feeds/mtggoldfish-standard.json index e516d0dacc..9ebac5a25b 100644 --- a/client/public/feeds/mtggoldfish-standard.json +++ b/client/public/feeds/mtggoldfish-standard.json @@ -5,7 +5,7 @@ "icon": "G", "format": "standard", "version": 1, - "updated": "2026-07-31T00:00:00Z", + "updated": "2026-08-01T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/standard", "decks": [ { @@ -1132,67 +1132,51 @@ "main": [ { "count": 4, - "name": "Bloom Tender" + "name": "Hallowed Fountain" }, { "count": 4, - "name": "Starting Town" + "name": "Bloom Tender" }, { "count": 4, - "name": "Brightglass Gearhulk" + "name": "Boomerang Basics" }, { "count": 1, - "name": "Plains" + "name": "Willowrush Verge" }, { - "count": 1, - "name": "Hushwood Verge" + "count": 4, + "name": "Nature's Rhythm" }, { "count": 2, - "name": "Floodfarm Verge" + "name": "Cryogen Relic" }, { - "count": 2, + "count": 3, "name": "Breeding Pool" }, { "count": 1, - "name": "Willowrush Verge" + "name": "Mockingbird" }, { "count": 4, "name": "Temple Garden" }, - { - "count": 4, - "name": "Badgermole Cub" - }, - { - "count": 4, - "name": "Hallowed Fountain" - }, - { - "count": 4, - "name": "Botanical Sanctum" - }, { "count": 2, "name": "Nurturing Pixie" }, { "count": 1, - "name": "The Jolly Balloon Man" - }, - { - "count": 1, - "name": "Mockingbird" + "name": "Plains" }, { - "count": 4, - "name": "Stormchaser's Talent" + "count": 3, + "name": "Seam Rip" }, { "count": 4, @@ -1200,65 +1184,61 @@ }, { "count": 4, - "name": "Boomerang Basics" + "name": "Starting Town" }, { "count": 4, - "name": "Nature's Rhythm" + "name": "Stormchaser's Talent" }, { - "count": 3, - "name": "Seam Rip" + "count": 1, + "name": "The Jolly Balloon Man" }, { - "count": 1, - "name": "Shardmage's Rescue" + "count": 2, + "name": "Floodfarm Verge" }, { - "count": 1, - "name": "Cryogen Relic" - } - ], - "sideboard": [ + "count": 3, + "name": "Botanical Sanctum" + }, { - "count": 1, - "name": "Seam Rip" + "count": 4, + "name": "Badgermole Cub" }, { "count": 1, - "name": "Shardmage's Rescue" + "name": "Hushwood Verge" }, { - "count": 2, - "name": "Sage of the Skies" - }, + "count": 4, + "name": "Brightglass Gearhulk" + } + ], + "sideboard": [ { "count": 1, - "name": "Spell Pierce" + "name": "Nurturing Pixie" }, { "count": 1, - "name": "Meltstrider's Resolve" + "name": "Disdainful Stroke" }, { "count": 3, "name": "Erode" }, - { - "count": 2, - "name": "Disdainful Stroke" - }, { "count": 1, - "name": "Spell Pierce" + "name": "Seam Rip" }, { - "count": 2, - "name": "Aang, Swift Savior" + "count": 3, + "name": "Crystal Barricade" }, { - "count": 1, - "name": "Soul-Guide Lantern" + "count": 4, + "name": "Spell Pierce" } ] }, @@ -1266,139 +1246,127 @@ "name": "Mardu Discard", "author": "MTGGoldfish", "colors": [ - "W", + "R", "B", - "R" + "W" ], "tags": [ "metagame" ], "main": [ { - "count": 4, - "name": "Godless Shrine" - }, - { - "count": 4, - "name": "Hardened Academic" + "count": 1, + "name": "Mountain" }, { - "count": 2, - "name": "Practiced Offense" + "count": 1, + "name": "Swamp" }, { - "count": 1, - "name": "Erode" + "count": 3, + "name": "Godless Shrine" }, { "count": 4, - "name": "Moonshadow" + "name": "Blood Crypt" }, { "count": 4, - "name": "Cool but Rude" + "name": "Bloodghast" }, { - "count": 2, - "name": "Sacred Foundry" + "count": 3, + "name": "Concealed Courtyard" }, { "count": 2, - "name": "Requiting Hex" + "name": "Inspiring Vantage" }, { "count": 4, - "name": "Iron-Shield Elf" - }, - { - "count": 2, - "name": "Carnage, Crimson Chaos" + "name": "Sacred Foundry" }, { - "count": 3, - "name": "Sunset Saboteur" + "count": 1, + "name": "Inti, Seneschal of the Sun" }, { - "count": 2, - "name": "Cecil, Dark Knight" + "count": 4, + "name": "Marauding Mako" }, { - "count": 1, - "name": "Mountain" + "count": 4, + "name": "Starting Town" }, { "count": 4, - "name": "Blood Crypt" + "name": "Iron-Shield Elf" }, { - "count": 2, - "name": "Bloodghast" + "count": 3, + "name": "Requiting Hex" }, { - "count": 1, - "name": "Concealed Courtyard" + "count": 4, + "name": "Moonshadow" }, { "count": 4, - "name": "Inspiring Vantage" + "name": "Cool but Rude" }, { "count": 3, - "name": "Blazemire Verge" + "name": "Casey Jones, Vigilante" }, { - "count": 4, - "name": "Marauding Mako" + "count": 3, + "name": "Practiced Offense" }, { - "count": 1, - "name": "Tersa Lightshatter" + "count": 4, + "name": "Hardened Academic" }, { - "count": 4, - "name": "Starting Town" + "count": 2, + "name": "Erode" }, { "count": 2, - "name": "Casey Jones, Vigilante" + "name": "Carnage, Crimson Chaos" } ], "sideboard": [ { "count": 1, - "name": "Sunset Saboteur" - }, - { - "count": 1, - "name": "Get Lost" + "name": "Inti, Seneschal of the Sun" }, { "count": 1, - "name": "Case of the Crimson Pulse" + "name": "Shoot the Sheriff" }, { "count": 2, - "name": "Duress" + "name": "Pest Control" }, { "count": 2, - "name": "Magebane Lizard" + "name": "Monument to Endurance" }, { "count": 3, - "name": "Doorkeeper Thrull" + "name": "Strategic Betrayal" }, { - "count": 1, + "count": 3, "name": "Voice of Victory" }, { "count": 2, - "name": "Strategic Betrayal" + "name": "Avatar's Wrath" }, { - "count": 2, - "name": "Dawn's Truce" + "count": 1, + "name": "Erode" } ] } From 69d2748d1bae9a1f89ef7adabf0a8c0eb28a6e03 Mon Sep 17 00:00:00 2001 From: invalidCards <842080+invalidCards@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:07:57 +0200 Subject: [PATCH 62/63] Fix Diluvian Primordial (#6794) * Fix Diluvian Primordial * Fix Diluvian Primordial review findings * fix(engine): preserve Diluvian free-cast continuations --- crates/engine/src/game/ability_scan.rs | 2 +- crates/engine/src/game/ability_utils.rs | 84 +++ crates/engine/src/game/casting_costs.rs | 14 +- crates/engine/src/game/coverage.rs | 6 +- .../engine/src/game/effects/cast_from_zone.rs | 32 ++ .../src/game/effects/exile_from_top_until.rs | 4 +- .../src/game/effects/free_cast_from_zones.rs | 325 +++++++++--- crates/engine/src/game/effects/mod.rs | 66 ++- .../src/game/engine_resolution_choices.rs | 8 +- crates/engine/src/game/visibility.rs | 15 +- .../src/parser/oracle_effect/assembly.rs | 46 +- .../engine/src/parser/oracle_effect/lower.rs | 68 ++- crates/engine/src/parser/oracle_effect/mod.rs | 138 ++++- .../engine/src/parser/oracle_effect/tests.rs | 182 ++++++- crates/engine/src/parser/oracle_tests.rs | 12 +- crates/engine/src/types/ability.rs | 34 +- crates/engine/src/types/game_state.rs | 13 +- .../integration/diluvian_primordial_6754.rs | 497 ++++++++++++++++++ .../integration/invoke_calamity_free_cast.rs | 9 +- crates/engine/tests/integration/main.rs | 1 + docs/parser-misparse-backlog.md | 1 - 21 files changed, 1403 insertions(+), 154 deletions(-) create mode 100644 crates/engine/tests/integration/diluvian_primordial_6754.rs diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index deb416008d..38c901404c 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -1249,7 +1249,7 @@ fn scan_effect(x: &Effect, mode: ScanMode) -> Axes { count: _, max_total_mv: _, zones: _, - exile_instead_of_graveyard: _, + graveyard_replacement: _, } => { let mut acc = Axes::NONE; acc = acc.or(scan_target_filter(filter, target_ctx, mode)); diff --git a/crates/engine/src/game/ability_utils.rs b/crates/engine/src/game/ability_utils.rs index 8e3a77df51..1ea7dc6d99 100644 --- a/crates/engine/src/game/ability_utils.rs +++ b/crates/engine/src/game/ability_utils.rs @@ -11165,6 +11165,90 @@ mod tests { ); } + /// CR 115.1a + CR 108.3: The sole nonbattlefield per-opponent fanout class + /// binds each graveyard card to its immediately preceding opponent target. + /// The positive paired cards prove the path is reachable; a wrong-owner card + /// and a battlefield lookalike prove neither owner nor zone is widened. + #[test] + fn per_opponent_graveyard_fanout_pairs_only_each_opponents_typed_card() { + use crate::types::ability::{CardPlayMode, CastFromZoneDriver}; + + let mut state = GameState::new(FormatConfig::standard(), 3, 42); + let add_instant = |state: &mut GameState, owner, zone, card_id| { + let id = create_object( + state, + CardId(card_id), + owner, + format!("Instant {card_id}"), + zone, + ); + state + .objects + .get_mut(&id) + .unwrap() + .card_types + .core_types + .push(CoreType::Instant); + id + }; + let p1_graveyard = add_instant(&mut state, PlayerId(1), Zone::Graveyard, 1); + let p2_graveyard = add_instant(&mut state, PlayerId(2), Zone::Graveyard, 2); + let _wrong_owner = add_instant(&mut state, PlayerId(0), Zone::Graveyard, 3); + let _battlefield_lookalike = add_instant(&mut state, PlayerId(1), Zone::Battlefield, 4); + + let filter = TargetFilter::Typed( + TypedFilter::new(TypeFilter::Instant) + .controller(ControllerRef::TargetPlayer) + .properties(vec![ + FilterProp::Owned { + controller: ControllerRef::TargetPlayer, + }, + FilterProp::InZone { + zone: Zone::Graveyard, + }, + ]), + ); + let mut ability = ResolvedAbility::new( + Effect::CastFromZone { + target: filter, + without_paying_mana_cost: true, + mode: CardPlayMode::Cast, + cast_transformed: false, + alt_ability_cost: None, + constraint: None, + duration: None, + driver: CastFromZoneDriver::DuringResolution, + mana_spend_permission: None, + }, + vec![], + ObjectId(900), + PlayerId(0), + ); + ability.target_choice_timing = TargetChoiceTiming::Stack; + ability.multi_target = Some(MultiTargetSpec::bounded( + 0, + QuantityExpr::Ref { + qty: QuantityRef::PlayerCount { + filter: PlayerFilter::Opponent, + }, + }, + )); + + let slots = build_target_slots(&state, &ability).expect("paired graveyard slots"); + assert_eq!(slots.len(), 4, "one player/object pair per opponent"); + assert_eq!(slots[0].legal_targets, vec![TargetRef::Player(PlayerId(1))]); + assert_eq!( + slots[1].legal_targets, + vec![TargetRef::Object(p1_graveyard)], + "P1's object slot excludes the wrong owner and battlefield lookalike" + ); + assert_eq!(slots[2].legal_targets, vec![TargetRef::Player(PlayerId(2))]); + assert_eq!( + slots[3].legal_targets, + vec![TargetRef::Object(p2_graveyard)] + ); + } + #[test] fn per_opponent_gain_control_runtime_transfers_all_objects_and_preserves_tail() { let mut state = GameState::new(FormatConfig::standard(), 3, 42); diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index 3a7eaf1e3e..436a6d74d1 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -10003,17 +10003,13 @@ fn handle_resolution_cast_success( remaining_mv_budget, filter, zones, - exile_instead_of_graveyard, + graveyard_replacement, source, member_pool, } => { - if exile_instead_of_graveyard { - // CR 614.1a: Invoke Calamity's free-cast rider redirects to exile. - apply_spell_graveyard_replacement_rider( - state, - cast_object, - SpellStackToGraveyardReplacement::Exile, - ); + if let Some(destination) = graveyard_replacement.clone() { + // CR 614.1a: Carry the exact printed replacement destination. + apply_spell_graveyard_replacement_rider(state, cast_object, destination); } let casts_left = remaining_casts.saturating_sub(1); // CR 202.3: shrink the shared budget by what was actually spent on @@ -10048,7 +10044,7 @@ fn handle_resolution_cast_success( remaining_mv_budget: budget_left, filter, zones, - exile_instead_of_graveyard, + graveyard_replacement, source, member_pool, }, diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 285a24f6a9..05b6de0f75 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -3076,7 +3076,7 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> { max_total_mv, filter, zones, - exile_instead_of_graveyard, + graveyard_replacement, } => { d.push(("count".into(), count.to_string())); if let Some(mv) = max_total_mv { @@ -3091,8 +3091,8 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> { .collect::>() .join("/"), )); - if *exile_instead_of_graveyard { - d.push(("exile instead of graveyard".into(), "yes".into())); + if let Some(destination) = graveyard_replacement { + d.push(("graveyard replacement".into(), format!("{destination:?}"))); } } Effect::RollDie { diff --git a/crates/engine/src/game/effects/cast_from_zone.rs b/crates/engine/src/game/effects/cast_from_zone.rs index 800a6ab934..9c2f7c4517 100644 --- a/crates/engine/src/game/effects/cast_from_zone.rs +++ b/crates/engine/src/game/effects/cast_from_zone.rs @@ -601,6 +601,38 @@ pub fn resolve( return Ok(()); } + // CR 608.2g + CR 115.1a: A per-opponent fanout has already chosen its + // player/object pairs as the trigger went on the stack. After resolution + // revalidation only the surviving object ids remain, so hand them to the + // existing free-cast window as an exact pool: do not rescan graveyards and + // do not substitute another card from the same opponent. The window's + // re-offer pipeline casts selected spells one at a time without priority. + let is_per_opponent_fanout = crate::game::ability_utils::is_per_opponent_target_fanout(ability); + let graveyard_destination = cast_from_zone_graveyard_destination(ability); + if driver.is_during_resolution() + && without_paying + && alt_ability_cost.is_none() + && is_per_opponent_fanout + && !target_ids.is_empty() + { + let mut window = ability.clone(); + window.effect = Effect::FreeCastFromZones { + count: target_ids.len().try_into().unwrap_or(u8::MAX), + max_total_mv: None, + filter: target_filter.clone(), + zones: vec![Zone::Graveyard], + // The CastFromZone rider is stored as a sequential ParentTarget + // sub-ability; FreeCastWindow carries its exact destination as + // per-cast metadata instead of installing a source-global effect. + graveyard_replacement: graveyard_destination, + }; + // The rider has been translated into the window's per-cast metadata; + // retaining it would run a second destination move after the window. + window.sub_ability = None; + window.targets = target_ids.drain(..).map(TargetRef::Object).collect(); + return super::free_cast_from_zones::resolve(state, &window, events); + } + if driver_free_cast || immediate_graveyard_free_cast { // CR 608.2g: both gates require `alt_ability_cost.is_none()`, so the // pre-targeted free-cast path never carries a borrowed keyword cost — diff --git a/crates/engine/src/game/effects/exile_from_top_until.rs b/crates/engine/src/game/effects/exile_from_top_until.rs index da6f86d1a8..f51b8635ad 100644 --- a/crates/engine/src/game/effects/exile_from_top_until.rs +++ b/crates/engine/src/game/effects/exile_from_top_until.rs @@ -1006,7 +1006,7 @@ mod tests { ], }, zones: vec![Zone::Exile], - exile_instead_of_graveyard: false, + graveyard_replacement: None, }, vec![], source, @@ -1212,7 +1212,7 @@ mod tests { ], }, zones: vec![Zone::Exile], - exile_instead_of_graveyard: false, + graveyard_replacement: None, }, vec![], source, diff --git a/crates/engine/src/game/effects/free_cast_from_zones.rs b/crates/engine/src/game/effects/free_cast_from_zones.rs index 4c1396473d..687e3b43f1 100644 --- a/crates/engine/src/game/effects/free_cast_from_zones.rs +++ b/crates/engine/src/game/effects/free_cast_from_zones.rs @@ -22,27 +22,26 @@ use crate::types::zones::Zone; /// matching the Cascade/Discover/Ripple pattern. The "Exile ~" sub-ability is /// stashed as a `pending_continuation` and runs after the window finishes. /// -/// Invoke Calamity is the type specimen. The `exile_instead_of_graveyard` rider -/// (CR 614.1a — "if those spells would be put into your graveyard, exile them -/// instead") is carried on the offer so each cast spell is stamped with it. +/// Invoke Calamity is the type specimen. The optional CR 614.1a destination +/// rider is carried on the offer so each cast spell is stamped with it. pub fn resolve( state: &mut GameState, ability: &ResolvedAbility, events: &mut Vec, ) -> Result<(), EffectError> { - let (count, max_total_mv, filter, zones, exile_instead_of_graveyard) = match &ability.effect { + let (count, max_total_mv, filter, zones, graveyard_replacement) = match &ability.effect { Effect::FreeCastFromZones { count, max_total_mv, filter, zones, - exile_instead_of_graveyard, + graveyard_replacement, } => ( *count, *max_total_mv, filter.clone(), zones.clone(), - *exile_instead_of_graveyard, + graveyard_replacement.clone(), ), _ => return Err(EffectError::MissingParam("FreeCastFromZones".to_string())), }; @@ -105,7 +104,7 @@ pub fn resolve( remaining_mv_budget: max_total_mv, filter, zones, - exile_instead_of_graveyard, + graveyard_replacement, source: ability.source_id, member_pool, }, @@ -124,12 +123,14 @@ pub fn resolve( /// resolve their exile links against it, so the real id must be threaded /// rather than a sentinel. /// -/// `member_pool`, when non-empty, is THIS resolution's concrete "exiled this -/// way" batch (the pool the preceding `ChooseFromZone` offered): candidates -/// must belong to it, applied BEFORE the filter's `Not(InTrackedSet)` -/// chosen-card exclusion so a stale linked card from a previous resolution is -/// never offered (CR 607.2a — "the other cards exiled this way" is scoped to -/// this resolution's exile batch). Empty means no batch restriction. +/// `member_pool`, when non-empty, is THIS resolution's concrete approved pool: +/// candidates are enumerated from those exact ids, rather than from a zone scan. +/// It originally served "exiled this way" batches, and also carries paired +/// opponent-graveyard targets into the same window. In the latter case the +/// player target has intentionally been consumed before this resolver runs, so +/// `TargetPlayer`/`Owned(TargetPlayer)` are not evaluated again; zone, type, +/// cast legality, and every unrelated filter property remain authoritative. +/// Empty retains Invoke Calamity's existing controller-zone enumeration. pub(crate) fn eligible_candidates( state: &GameState, controller: PlayerId, @@ -144,77 +145,127 @@ pub(crate) fn eligible_candidates( }; let ctx = FilterContext::from_source_with_controller(source, controller); + let member_pool_filter = member_pool_filter(filter); let mut candidates = Vec::new(); - for &zone in zones { - let zone_ids = match zone { - Zone::Graveyard => &player.graveyard, - Zone::Hand => &player.hand, - // CR 400.10a + CR 608.2g: Exile is a shared zone — the whole pile - // is scanned and the `filter` (e.g. `ExiledBySource` + - // `Not(InTrackedSet)`) narrows it to this resolution's linked set - // regardless of who owns the exiled cards (Plargg and Nassari - // exiles from EVERY player's library). - Zone::Exile => &state.exile, - // CR 601.2a: Other zones would need a parser/effect change, so an - // unexpected zone contributes no candidates rather than silently - // scanning the wrong pile. - _ => continue, - }; - for &id in zone_ids { - // CR 607.2a: Confine the offer to THIS resolution's exile batch - // before any other narrowing — a card linked by a previous - // resolution is not among "the cards exiled this way" now. - if !member_pool.is_empty() && !member_pool.contains(&id) { - continue; - } - // CR 305.1: a land card can never be cast — this window is a cast - // grant, so lands are excluded from the offer outright (mirrors - // `cast_from_zone`'s cast-mode land guard). - if state.objects.get(&id).is_some_and(|obj| { - obj.card_types - .core_types - .contains(&crate::types::card_type::CoreType::Land) - }) { - continue; - } - if !matches_target_filter_in_owner_zone(state, id, filter, &ctx) { - continue; - } - // CR 601.2c + CR 608.2g: A spell cast during resolution still - // needs every required target to be legal before it can be offered. - let Some(obj) = state.objects.get(&id) else { - continue; + let candidate_ids: Vec = if member_pool.is_empty() { + let mut ids = Vec::new(); + for &zone in zones { + let zone_ids = match zone { + Zone::Graveyard => &player.graveyard, + Zone::Hand => &player.hand, + // CR 400.10a + CR 608.2g: Exile is a shared zone — the whole pile + // is scanned and the `filter` (e.g. `ExiledBySource` + + // `Not(InTrackedSet)`) narrows it to this resolution's linked set + // regardless of who owns the exiled cards (Plargg and Nassari + // exiles from EVERY player's library). + Zone::Exile => &state.exile, + // CR 601.2a: Other zones would need a parser/effect change, so an + // unexpected zone contributes no candidates rather than silently + // scanning the wrong pile. + _ => continue, }; - if !crate::game::casting::spell_has_legal_targets(state, obj, controller) { + ids.extend(zone_ids.iter().copied()); + } + ids + } else { + member_pool.to_vec() + }; + + for id in candidate_ids { + if !state + .objects + .get(&id) + .is_some_and(|object| zones.contains(&object.zone)) + { + continue; + } + // CR 305.1: a land card can never be cast — this window is a cast + // grant, so lands are excluded from the offer outright (mirrors + // `cast_from_zone`'s cast-mode land guard). + if state.objects.get(&id).is_some_and(|obj| { + obj.card_types + .core_types + .contains(&crate::types::card_type::CoreType::Land) + }) { + continue; + } + let candidate_filter = if member_pool.is_empty() { + filter + } else { + &member_pool_filter + }; + if !matches_target_filter_in_owner_zone(state, id, candidate_filter, &ctx) { + continue; + } + // CR 601.2c + CR 608.2g: A spell cast during resolution still + // needs every required target to be legal before it can be offered. + let Some(obj) = state.objects.get(&id) else { + continue; + }; + if !crate::game::casting::spell_has_legal_targets(state, obj, controller) { + continue; + } + // CR 202.3 + CR 107.3b + CR 601.2b: Respect the running MV budget. + // Because this window casts without paying a mana cost, X can only + // be announced as 0, so the card's printed mana_value() is the same + // value used when the choice is submitted. + if let Some(budget) = max_total_mv { + let mv = state + .objects + .get(&id) + // CR 202.3d + CR 709.4b: candidate cards are in a non-stack + // zone, so a split card's MV budget is its combined halves. + .map(|obj| obj.effective_mana_value()) + .unwrap_or(0); + if mv > budget { continue; } - // CR 202.3 + CR 107.3b + CR 601.2b: Respect the running MV budget. - // Because this window casts without paying a mana cost, X can only - // be announced as 0, so the card's printed mana_value() is the same - // value used when the choice is submitted. - if let Some(budget) = max_total_mv { - let mv = state - .objects - .get(&id) - // CR 202.3d + CR 709.4b: candidate cards are in a non-stack - // zone, so a split card's MV budget is its combined halves. - .map(|obj| obj.effective_mana_value()) - .unwrap_or(0); - if mv > budget { - continue; - } - } - candidates.push(id); } + candidates.push(id); } candidates } +/// CR 608.2g + CR 115.1a: A fixed member pool already proves which paired +/// player selected each object. Strip only that consumed player binding before +/// re-offering the exact ids; every type, zone, and cast-legality check remains. +fn member_pool_filter(filter: &TargetFilter) -> TargetFilter { + use crate::types::ability::{ControllerRef, FilterProp}; + + match filter { + TargetFilter::Typed(tf) => { + let mut tf = tf.clone(); + if tf.controller == Some(ControllerRef::TargetPlayer) { + tf.controller = None; + } + tf.properties.retain(|prop| { + !matches!( + prop, + FilterProp::Owned { + controller: ControllerRef::TargetPlayer + } + ) + }); + TargetFilter::Typed(tf) + } + TargetFilter::And { filters } => TargetFilter::And { + filters: filters.iter().map(member_pool_filter).collect(), + }, + TargetFilter::Or { filters } => TargetFilter::Or { + filters: filters.iter().map(member_pool_filter).collect(), + }, + TargetFilter::Not { filter } => TargetFilter::Not { + filter: Box::new(member_pool_filter(filter)), + }, + other => other.clone(), + } +} + #[cfg(test)] mod tests { use super::*; use crate::game::zones::create_object; - use crate::types::ability::{TypeFilter, TypedFilter}; + use crate::types::ability::{SpellStackToGraveyardReplacement, TypeFilter, TypedFilter}; use crate::types::card_type::CoreType; use crate::types::identifiers::CardId; use crate::types::mana::ManaCost; @@ -343,6 +394,127 @@ mod tests { assert_eq!(candidates, vec![mine]); } + /// CR 608.2g + CR 115.1a: A nonempty member pool is the enumeration + /// authority. It preserves exactly the paired opponent-graveyard targets, + /// skips their consumed TargetPlayer/Owned binding, and never substitutes + /// another matching card from either graveyard on the initial offer or a + /// re-offer after one pool member becomes illegal. + #[test] + fn member_pool_keeps_exact_opponent_graveyard_targets_without_substitutes() { + use crate::types::ability::{ControllerRef, FilterProp}; + use crate::types::FormatConfig; + + let mut state = GameState::new(FormatConfig::standard(), 3, 1); + let selected_p1 = add_card( + &mut state, + PlayerId(1), + Zone::Graveyard, + CoreType::Instant, + 1, + ); + let selected_p2 = add_card( + &mut state, + PlayerId(2), + Zone::Graveyard, + CoreType::Sorcery, + 1, + ); + let _p1_extra = add_card( + &mut state, + PlayerId(1), + Zone::Graveyard, + CoreType::Instant, + 1, + ); + let _p2_extra = add_card( + &mut state, + PlayerId(2), + Zone::Graveyard, + CoreType::Sorcery, + 1, + ); + let filter = TargetFilter::Typed( + TypedFilter::new(TypeFilter::AnyOf(vec![ + TypeFilter::Instant, + TypeFilter::Sorcery, + ])) + .controller(ControllerRef::TargetPlayer) + .properties(vec![ + FilterProp::Owned { + controller: ControllerRef::TargetPlayer, + }, + FilterProp::InZone { + zone: Zone::Graveyard, + }, + ]), + ); + + let pool = [selected_p1, selected_p2]; + let candidates = eligible_candidates( + &state, + PlayerId(0), + ObjectId(900), + &filter, + &[Zone::Graveyard], + None, + &pool, + ); + assert_eq!( + candidates, + vec![selected_p1, selected_p2], + "the type filter still applies, but only the approved paired targets may be offered" + ); + + state.objects.get_mut(&selected_p1).unwrap().zone = Zone::Exile; + let reoffered = eligible_candidates( + &state, + PlayerId(0), + ObjectId(900), + &filter, + &[Zone::Graveyard], + None, + &pool, + ); + assert!( + reoffered == vec![selected_p2], + "an illegal selected target must disappear rather than be replaced by a graveyard extra" + ); + } + + /// Invoke Calamity has no fixed pool, so its established own-zone scan must + /// remain the authority rather than attempting paired-target semantics. + #[test] + fn empty_member_pool_retains_invoke_calamity_controller_zone_scan() { + let mut state = GameState::new_two_player(1); + let mine = add_card( + &mut state, + PlayerId(0), + Zone::Graveyard, + CoreType::Instant, + 1, + ); + let _opponent = add_card( + &mut state, + PlayerId(1), + Zone::Graveyard, + CoreType::Instant, + 1, + ); + assert_eq!( + eligible_candidates( + &state, + PlayerId(0), + ObjectId(900), + &instant_sorcery_filter(), + &[Zone::Graveyard], + None, + &[], + ), + vec![mine], + "empty pool preserves Invoke Calamity's controller-graveyard scan" + ); + } + /// CR 608.2g: An empty candidate set sets no pause and emits EffectResolved, /// so the continuation (Exile ~) runs immediately. #[test] @@ -361,7 +533,7 @@ mod tests { max_total_mv: Some(6), filter: instant_sorcery_filter(), zones: vec![Zone::Graveyard, Zone::Hand], - exile_instead_of_graveyard: true, + graveyard_replacement: Some(SpellStackToGraveyardReplacement::Exile), }, vec![], source, @@ -407,7 +579,7 @@ mod tests { max_total_mv: Some(6), filter: instant_sorcery_filter(), zones: vec![Zone::Graveyard, Zone::Hand], - exile_instead_of_graveyard: true, + graveyard_replacement: Some(SpellStackToGraveyardReplacement::Exile), }, vec![], source, @@ -423,7 +595,7 @@ mod tests { candidates, remaining_casts, remaining_mv_budget, - exile_instead_of_graveyard, + graveyard_replacement, .. }, } => { @@ -431,7 +603,10 @@ mod tests { assert_eq!(candidates, &vec![instant]); assert_eq!(*remaining_casts, 2); assert_eq!(*remaining_mv_budget, Some(6)); - assert!(*exile_instead_of_graveyard); + assert_eq!( + graveyard_replacement.as_ref(), + Some(&SpellStackToGraveyardReplacement::Exile) + ); } other => panic!("expected FreeCastWindow, got {other:?}"), } diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 6272dcbcfd..d83f4a31b2 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -9809,15 +9809,63 @@ fn resolve_chain_body( // graveyard-redirect rider by stamping the granted casting permission. Do // not also execute the parser's structural rider (`ChangeZone` / // `PutAtLibraryPosition` targeting `ParentTarget`) as an immediate move, - // or the graveyard card leaves before the player can cast it. CastFromZone - // recognizes any redirect destination (exile / library / hand); Counter - // only ever carries the exile sub-ability rider (its library/hand redirect - // rides `countered_spell_zone`) and consumes it during `counter::resolve` - // (stack -> exile directly) — skip the follow-up move either way. - if (matches!(&ability.effect, Effect::CastFromZone { .. }) - && cast_from_zone::graveyard_destination_rider(sub).is_some()) - || (matches!(&ability.effect, Effect::Counter { .. }) - && cast_from_zone::is_graveyard_exile_rider_subability(sub)) + // or the graveyard card leaves before the player can cast it. + // + // Diluvian Primordial's canonical per-opponent fanout translates that + // CastFromZone into a FreeCastWindow. Consume its *direct* redirect + // rider rather than resolving it as an instruction: when a window opens, + // park the rider's direct SequentialSibling tail until that window + // closes; when none opens, resolve that tail immediately. This keeps an + // uncast selected card in its graveyard while preserving a later printed + // instruction such as "Then draw a card." + // Other CastFromZone shapes keep the established metadata-only rider + // handling. Counter only ever carries the exile sub-ability rider (its + // library/hand redirect rides `countered_spell_zone`) and consumes it + // during `counter::resolve` (stack -> exile directly). + let direct_cast_from_zone_graveyard_rider = + matches!(&ability.effect, Effect::CastFromZone { .. }) + && cast_from_zone::graveyard_destination_rider(sub).is_some(); + if direct_cast_from_zone_graveyard_rider { + let is_per_opponent_fanout = + crate::game::ability_utils::is_per_opponent_target_fanout(ability); + if is_per_opponent_fanout { + let mut direct_sequential_tail = sub + .sub_ability + .as_deref() + .filter(|tail| tail.sub_link == SubAbilityLink::SequentialSibling) + .cloned(); + if let Some(tail) = direct_sequential_tail.as_mut() { + if should_propagate_parent_targets(ability, tail) { + tail.targets = ability.targets.clone(); + } + apply_parent_chain_context( + tail, + ability, + effect_context_object.as_ref(), + state, + ); + } + if matches!( + state.waiting_for, + WaitingFor::CastOffer { + kind: CastOfferKind::FreeCastWindow { .. }, + .. + } + ) { + if let Some(tail) = direct_sequential_tail { + prepend_to_pending_continuation(state, tail); + } + } else if let Some(tail) = direct_sequential_tail { + resolve_ability_chain(state, &tail, events, depth + 1)?; + } + return Ok(()); + } + // Generic CastFromZone riders remain metadata consumed by the + // granting effect. Only the canonical fanout needs tail handling. + return Ok(()); + } + if matches!(&ability.effect, Effect::Counter { .. }) + && cast_from_zone::is_graveyard_exile_rider_subability(sub) { return Ok(()); } diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 053c6931eb..b3475929b7 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -1991,7 +1991,7 @@ pub(super) fn handle_resolution_choice( remaining_mv_budget, filter, zones, - exile_instead_of_graveyard, + graveyard_replacement, source, member_pool, }, @@ -2046,7 +2046,7 @@ pub(super) fn handle_resolution_choice( remaining_mv_budget, filter, zones, - exile_instead_of_graveyard, + graveyard_replacement: graveyard_replacement.clone(), source, member_pool, }, @@ -2059,6 +2059,10 @@ pub(super) fn handle_resolution_choice( constraint: None, cast_transformed: false, cleanup, + // The window's success action installs this rider exactly + // once after the cast finalizes. Passing it through this + // request would make `initiate_cast_during_resolution` + // install a duplicate synthetic replacement. graveyard_replacement: None, cost: crate::types::ability::ResolutionCastCost::Free, }, diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index ee14d8c442..422e14b3d1 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -1123,7 +1123,7 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState remaining_mv_budget, ref filter, ref zones, - exile_instead_of_graveyard, + ref graveyard_replacement, source, ref member_pool, }, @@ -1138,7 +1138,7 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState remaining_mv_budget, filter: filter.clone(), zones: zones.clone(), - exile_instead_of_graveyard, + graveyard_replacement: graveyard_replacement.clone(), source, // CR 400.2: the member pool can reference the same private // candidates (a hand/graveyard window would leak eligible @@ -4870,7 +4870,9 @@ mod tests { remaining_mv_budget: Some(6), filter: crate::types::ability::TargetFilter::Any, zones: vec![Zone::Graveyard, Zone::Hand], - exile_instead_of_graveyard: true, + graveyard_replacement: Some( + crate::types::ability::SpellStackToGraveyardReplacement::Exile, + ), source: crate::types::game_state::zero_object_id(), member_pool: vec![hand_candidate], }, @@ -4906,7 +4908,7 @@ mod tests { candidates, remaining_casts, remaining_mv_budget, - exile_instead_of_graveyard, + graveyard_replacement, member_pool, .. }, @@ -4926,7 +4928,10 @@ mod tests { assert_eq!(member_pool, vec![ObjectId(0)]); assert_eq!(remaining_casts, 2); assert_eq!(remaining_mv_budget, Some(6)); - assert!(exile_instead_of_graveyard); + assert_eq!( + graveyard_replacement.as_ref(), + Some(&crate::types::ability::SpellStackToGraveyardReplacement::Exile) + ); } other => panic!("expected FreeCastWindow for opponent, got {other:?}"), } diff --git a/crates/engine/src/parser/oracle_effect/assembly.rs b/crates/engine/src/parser/oracle_effect/assembly.rs index 1e62c06d19..7acf25dca3 100644 --- a/crates/engine/src/parser/oracle_effect/assembly.rs +++ b/crates/engine/src/parser/oracle_effect/assembly.rs @@ -37,6 +37,7 @@ use super::lower::{ apply_where_x_to_latest_def, attach_any_color_mana_rider_to_previous_play_from_exile, attach_cast_cost_raise_to_previous_play_from_exile, attach_graveyard_redirect_rider_to_prior_cast_from_zone, + attach_graveyard_redirect_rider_to_prior_free_cast_from_zones, attach_land_enters_tapped_to_previous_play_from_exile, cast_cost_raise_rider, clone_would_transplant_gated_referent, consolidate_die_and_coin_defs, definition_targets_self_source, effect_publishes_revealed_subject, @@ -73,11 +74,13 @@ use super::{ def_is_keyword_counter_placement, def_is_perpetual_keyword_grant, demote_unbindable_batch_aggregate, draw_object_count_filter, fold_cast_copy_of_card_defs, has_explicit_player_target, inject_chosen_color_choice_grant, mark_uses_tracked_set, - parse_spell_graveyard_replacement_rider, publishes_aggregate_set_from_resolution, - publishes_tracked_set_from_resolution, rebind_tracked_aggregate_to_chain_set, - retarget_counter_additional_cost_to_target, rewrite_grant_parent_to_filter, - rewrite_parent_targets_to_tracked_set, rewrite_rounding_mode, rewrite_that_type_mana_instead, - stamp_delayed_returns, try_fold_token_repeat_into_count, wire_optional_cast_decline_fallback, + parse_spell_graveyard_replacement_rider, + parse_spells_cast_this_way_graveyard_replacement_rider, + publishes_aggregate_set_from_resolution, publishes_tracked_set_from_resolution, + rebind_tracked_aggregate_to_chain_set, retarget_counter_additional_cost_to_target, + rewrite_grant_parent_to_filter, rewrite_parent_targets_to_tracked_set, rewrite_rounding_mode, + rewrite_that_type_mana_instead, stamp_delayed_returns, try_fold_token_repeat_into_count, + wire_optional_cast_decline_fallback, }; /// CR 601.2c: True when the assembled head chose one or more players at @@ -1848,14 +1851,43 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { continue; } + // CR 614.1a + CR 608.2g: An exact "if a spell cast this way would be + // put into a graveyard" rider scopes each cast in the immediately prior + // free-cast window. Absorb it before the legacy singular-spell route; + // this avoids converting a multi-cast destination into a sequential + // one-shot `ParentTarget` rider. + if let Some(dest) = parse_spells_cast_this_way_graveyard_replacement_rider( + &clause_ir + .source + .fragment() + .unwrap_or_default() + .to_lowercase(), + ) { + if attach_graveyard_redirect_rider_to_prior_free_cast_from_zones( + &mut defs, + dest.clone(), + ) { + prev_boundary = clause_ir.boundary; + continue; + } + // Diluvian Primordial's paired target fanout remains a + // `CastFromZone` until its resolver opens the free-cast window. + // Preserve the canonical ParentTarget representation here; the + // direct resolver translates it to the same window metadata. + if attach_graveyard_redirect_rider_to_prior_cast_from_zone(&mut defs, dest) { + prev_boundary = clause_ir.boundary; + continue; + } + } + // CR 614.1a + CR 608.2n: a "if that spell would be put into a graveyard, // [put on library / return to hand] instead" rider that trails an // optional `CastFromZone` (Kylox's Voltstrider) is a CR 608.2n // destination-replacement on the cast spell. Fold the canonical rider // onto the prior cast so the runtime stamps the redirect, intercepting it // before the generic chain assembly mistakes a `PutAtLibraryPosition{ - // Bottom}` for the Sanwell free-cast bottom-cleanup. Exile is left to its - // existing clean path (the helper declines it). + // Bottom}` for the Sanwell free-cast bottom-cleanup. Every destination, + // including exile, becomes the same ParentTarget rider shape. if let Some(dest) = parse_spell_graveyard_replacement_rider( &clause_ir .source diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index 25b8e5b1af..9c19601ebf 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -1028,11 +1028,11 @@ pub(super) fn attach_any_color_mana_rider_to_previous_play_from_exile( /// count 0, and duplicating it into a bogus `else_ability`). Building the rider /// directly here bypasses that generic mis-route. /// -/// The exile destination is intentionally NOT handled here: it already lowers to -/// the clean `ChangeZone{Exile, ParentTarget}` sub-ability via the general -/// anaphor rebind (Torrential Gearhulk), and its effect shape never trips the -/// bottom-cleanup detector. Returns `false` (no fold) for exile so that path is -/// left undisturbed. +/// The generic singular-spell route intentionally leaves exile to the existing +/// `ChangeZone{Exile, ParentTarget}` anaphor path. The exact plural "a spell +/// cast this way" form is handled separately by +/// `attach_graveyard_redirect_rider_to_prior_free_cast_from_zones`, which is +/// the only route that may attach stack/ParentTarget metadata for that wording. pub(super) fn attach_graveyard_redirect_rider_to_prior_cast_from_zone( defs: &mut [AbilityDefinition], dest: SpellStackToGraveyardReplacement, @@ -1058,7 +1058,6 @@ pub(super) fn attach_graveyard_redirect_rider_to_prior_cast_from_zone( face_down_profile: None, enters_modified_if: None, }, - // Exile keeps its existing clean `ChangeZone{Exile, ParentTarget}` path. SpellStackToGraveyardReplacement::Exile => return false, }; let Some(prev) = defs.last_mut() else { @@ -1073,6 +1072,29 @@ pub(super) fn attach_graveyard_redirect_rider_to_prior_cast_from_zone( true } +/// CR 614.1a + CR 608.2g: absorb an exact "a spell cast this way" destination +/// rider into the immediately preceding free-cast window. Unlike the legacy +/// "that spell" form, this rider applies independently to every spell the +/// window casts, so it belongs on `FreeCastFromZones` metadata rather than as a +/// sequential `ParentTarget` sub-ability. +pub(super) fn attach_graveyard_redirect_rider_to_prior_free_cast_from_zones( + defs: &mut [AbilityDefinition], + dest: SpellStackToGraveyardReplacement, +) -> bool { + let Some(prev) = defs.last_mut() else { + return false; + }; + let Effect::FreeCastFromZones { + graveyard_replacement, + .. + } = &mut *prev.effect + else { + return false; + }; + *graveyard_replacement = Some(dest); + true +} + /// CR 601.2f: Detect an "each/a spell cast this way costs {N} more to cast" /// rider sentence (Lightstall Inquisitor, Invasion of Gobakhan) and return the cost increase. This is /// a cost-raise scoped to spells cast via the immediately-preceding @@ -3971,10 +3993,42 @@ fn is_per_opponent_target_fanout_clause(clause: &ParsedEffectClause) -> bool { } clause.effect.target_filter().is_some_and(|filter| { target_filter_controller_ref(filter) == Some(ControllerRef::TargetPlayer) - && target_filter_is_single_object_target(filter) + && (target_filter_is_single_object_target(filter) + || target_filter_is_explicit_target_player_graveyard_card(filter)) }) } +/// CR 115.1a + CR 108.3: The per-opponent fanout normally targets battlefield +/// objects. This is the sole nonbattlefield exception: an explicit typed card +/// target in a paired opponent's graveyard. An `Or` is allowed only when every +/// branch independently carries that complete binding; it must not inherit the +/// controller, ownership, or zone restriction from a sibling. This keeps "that +/// player's graveyard" tied to the immediately preceding player target instead +/// of broadly enabling all nonbattlefield fanout filters. +pub(super) fn target_filter_is_explicit_target_player_graveyard_card( + filter: &TargetFilter, +) -> bool { + match filter { + TargetFilter::Typed(tf) => { + tf.controller == Some(ControllerRef::TargetPlayer) + && !tf.type_filters.is_empty() + && tf.properties.contains(&FilterProp::Owned { + controller: ControllerRef::TargetPlayer, + }) + && tf.properties.contains(&FilterProp::InZone { + zone: Zone::Graveyard, + }) + } + TargetFilter::Or { filters } => { + !filters.is_empty() + && filters + .iter() + .all(target_filter_is_explicit_target_player_graveyard_card) + } + _ => false, + } +} + pub(crate) fn target_filter_is_single_object_target(filter: &TargetFilter) -> bool { let zones = filter.extract_zones(); if !zones.is_empty() && zones.iter().any(|zone| *zone != Zone::Battlefield) { diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 04d01a209f..bf4e2b174c 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -18311,8 +18311,7 @@ fn parse_spell_graveyard_replacement_rider( use nom::bytes::complete::tag; use nom::combinator::{all_consuming, value}; use nom::Parser; - let trimmed = lower.trim().trim_end_matches('.').trim(); - let (_, (_, _, _, dest)) = all_consuming(( + let (_, (_, _, _, dest, _, _)) = all_consuming(( tag::<_, _, OracleError<'_>>("if that spell would be put into "), alt(( tag("a graveyard"), @@ -18351,12 +18350,133 @@ fn parse_spell_graveyard_replacement_rider( )), ), )), + opt(tag(".")), + multispace0::<_, OracleError<'_>>, )) - .parse(trimmed) + .parse(lower.trim()) + .ok()?; + Some(dest) +} + +/// CR 614.1a + CR 608.2g: Parse the exact plural-free-cast rider, "if a spell +/// cast this way would be put into a graveyard, [destination] instead." This +/// route is deliberately typed and all-consuming: it must not swallow a +/// merely similar conditional before the assembly pass verifies that the prior +/// definition is a `FreeCastFromZones` window. +fn parse_spells_cast_this_way_graveyard_replacement_rider( + lower: &str, +) -> Option { + use nom::branch::alt; + use nom::bytes::complete::tag; + use nom::character::complete::multispace0; + use nom::combinator::{all_consuming, opt, value}; + use nom::Parser; + + let (_, (_, _, _, dest, _, _)) = all_consuming(( + tag::<_, _, OracleError<'_>>("if a spell cast this way would be put into "), + alt(( + tag("a graveyard"), + tag("the graveyard"), + tag("its owner's graveyard"), + tag("your graveyard"), + tag("an opponent's graveyard"), + )), + tag(", "), + alt(( + value( + SpellStackToGraveyardReplacement::Exile, + alt(( + tag("exile it instead"), + tag("exile that card instead"), + tag("exile that spell instead"), + )), + ), + value( + SpellStackToGraveyardReplacement::Library { + position: LibraryPosition::Bottom, + }, + tag("put it on the bottom of its owner's library instead"), + ), + value( + SpellStackToGraveyardReplacement::Library { + position: LibraryPosition::Top, + }, + tag("put it on top of its owner's library instead"), + ), + value( + SpellStackToGraveyardReplacement::Hand, + alt(( + tag("put it into its owner's hand instead"), + tag("return it to its owner's hand instead"), + )), + ), + )), + opt(tag(".")), + multispace0::<_, OracleError<'_>>, + )) + .parse(lower.trim()) .ok()?; Some(dest) } +/// CR 115.1a + CR 601.2a + CR 608.2g: Parse a per-opponent graveyard free +/// cast target. The enclosing `for each opponent` fanout binds the paired +/// player/object targets on the stack; this clause only provides the typed +/// target filter and the during-resolution casting method. +/// +/// The player binding is deliberately represented on both axes: `controller` +/// makes the paired-target enumerator bind the object slot to that opponent, +/// while `Owned` captures "that player's/their graveyard" for target legality +/// and post-selection revalidation (CR 108.3). +fn try_parse_per_opponent_graveyard_free_cast(lower: &str) -> Option { + type E<'a> = OracleError<'a>; + + let (rest, _) = opt(tag::<_, _, E>("you may ")).parse(lower).ok()?; + let (rest, _) = tag::<_, _, E>("cast up to ").parse(rest).ok()?; + let (rest, count) = nom_primitives::parse_number.parse(rest).ok()?; + if count != 1 { + return None; + } + let (rest, _) = tag::<_, _, E>(" target ").parse(rest).ok()?; + let (rest, mut filter) = parse_free_cast_candidate_filter(rest)?; + let (rest, _) = tag::<_, _, E>(" card").parse(rest).ok()?; + let (rest, _) = tag::<_, _, E>(" from ").parse(rest).ok()?; + let (rest, _) = alt((tag::<_, _, E>("that player's "), tag("their "))) + .parse(rest) + .ok()?; + let (rest, _) = tag::<_, _, E>("graveyard without paying its mana cost") + .parse(rest) + .ok()?; + let (_, _) = all_consuming((opt(tag(".")), multispace0::<_, E>)) + .parse(rest) + .ok()?; + + add_cast_target_props( + &mut filter, + &[ + FilterProp::Owned { + controller: ControllerRef::TargetPlayer, + }, + FilterProp::InZone { + zone: Zone::Graveyard, + }, + ], + Some(ControllerRef::TargetPlayer), + ); + + Some(Effect::CastFromZone { + target: filter, + without_paying_mana_cost: true, + mode: CardPlayMode::Cast, + cast_transformed: false, + alt_ability_cost: None, + constraint: None, + duration: None, + driver: CastFromZoneDriver::DuringResolution, + mana_spend_permission: None, + }) +} + /// CR 614.1a + CR 608.2n + CR 607.2b: Detect the resolving-spell exile rider — /// "exile it instead of putting it into a graveyard as it resolves". This is the /// trigger BODY of a `WhenAPlayerCasts` trigger (Rod of Absorption) or a @@ -21771,7 +21891,7 @@ fn try_parse_counted_free_cast_from_exiled_this_way(rest: &str) -> Option Option { max_total_mv, filter, zones, - exile_instead_of_graveyard: false, + graveyard_replacement: None, }) } @@ -22172,6 +22292,10 @@ fn try_parse_cast_effect(lower: &str, ctx: &ParseContext) -> Option { .map(|(rest, _)| rest) .unwrap_or(lower); + if let Some(effect) = try_parse_per_opponent_graveyard_free_cast(lower) { + return Some(effect); + } + // CR 401.5 + CR 701.25: Eye of Duskmantle's permission is scoped to // cards in your graveyard that you surveilled this turn. The current cast // permission targets can model graveyard, exile-linked, top-library, and @@ -29486,11 +29610,11 @@ pub(crate) fn parse_effect_chain_ir( && matches!(&clause.parsed.effect, Effect::FreeCastFromZones { .. }) }) { if let Effect::FreeCastFromZones { - exile_instead_of_graveyard, + graveyard_replacement, .. } = &mut previous.parsed.effect { - *exile_instead_of_graveyard = true; + *graveyard_replacement = Some(SpellStackToGraveyardReplacement::Exile); continue; } } diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index e208998790..53e3af4358 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -1,4 +1,6 @@ -use super::lower::rewrite_parent_target_to_last_created; +use super::lower::{ + rewrite_parent_target_to_last_created, target_filter_is_explicit_target_player_graveyard_card, +}; use super::*; use crate::parser::parse_oracle_text; use crate::types::ability::CardPlayMode::{Cast, Play}; @@ -33190,6 +33192,57 @@ fn per_opponent_fanout_excludes_non_battlefield_zone_targets() { ); } +/// The graveyard exception to per-opponent target fanout must admit an +/// instant-or-sorcery disjunction only when every alternative is independently +/// scoped to the paired target player's graveyard. A permissive `Or` would let +/// one broad branch inherit the other branch's safety-critical zone binding. +#[test] +fn per_opponent_graveyard_fanout_requires_every_or_branch_to_be_fully_scoped() { + let scoped = |kind| { + TargetFilter::Typed( + TypedFilter::new(kind) + .controller(ControllerRef::TargetPlayer) + .properties(vec![ + FilterProp::Owned { + controller: ControllerRef::TargetPlayer, + }, + FilterProp::InZone { + zone: Zone::Graveyard, + }, + ]), + ) + }; + let safely_scoped = TargetFilter::Or { + filters: vec![scoped(TypeFilter::Instant), scoped(TypeFilter::Sorcery)], + }; + assert!(target_filter_is_explicit_target_player_graveyard_card( + &safely_scoped + )); + + let unscoped_sorcery = TargetFilter::Typed( + TypedFilter::new(TypeFilter::Sorcery) + .controller(ControllerRef::TargetPlayer) + .properties(vec![FilterProp::InZone { + zone: Zone::Graveyard, + }]), + ); + let mixed_scope = TargetFilter::Or { + filters: vec![scoped(TypeFilter::Instant), unscoped_sorcery], + }; + assert!( + !target_filter_is_explicit_target_player_graveyard_card(&mixed_scope), + "every disjunct must carry the TargetPlayer ownership and graveyard restrictions" + ); + + let non_typed_branch = TargetFilter::Or { + filters: vec![scoped(TypeFilter::Instant), TargetFilter::ParentTarget], + }; + assert!( + !target_filter_is_explicit_target_player_graveyard_card(&non_typed_branch), + "non-typed alternatives must not qualify for the nonbattlefield fanout exception" + ); +} + #[test] fn effect_for_each_opponent_exile_up_to_one_preserves_optional_fanout_slots() { let def = parse_effect_chain( @@ -40710,7 +40763,7 @@ fn plargg_and_nassari_full_trigger_chain_choose_then_cast_others() { ref filter, ref zones, max_total_mv, - exile_instead_of_graveyard, + ref graveyard_replacement, } = *cast.effect else { panic!("expected FreeCastFromZones tail, got {:?}", cast.effect); @@ -40721,7 +40774,7 @@ fn plargg_and_nassari_full_trigger_chain_choose_then_cast_others() { ); assert_eq!(*zones, vec![Zone::Exile]); assert_eq!(max_total_mv, None); - assert!(!exile_instead_of_graveyard); + assert!(graveyard_replacement.is_none()); let TargetFilter::And { filters: ref cf } = *filter else { panic!("expected And cast filter, got {filter:?}"); }; @@ -41699,6 +41752,129 @@ fn spell_graveyard_replacement_rider_recognises_determiner_and_destination_varia ); } +/// CR 614.1a + CR 608.2g: "a spell cast this way" is the multi-cast form, +/// not the legacy singular `that spell` rider. It must parse all destinations +/// through its own all-consuming route before assembly decides whether a prior +/// free-cast window can absorb it. +#[test] +fn spells_cast_this_way_graveyard_replacement_rider_recognises_destinations() { + use crate::types::ability::{LibraryPosition, SpellStackToGraveyardReplacement}; + for (clause, expected) in [ + ( + "if a spell cast this way would be put into a graveyard, exile it instead.", + SpellStackToGraveyardReplacement::Exile, + ), + ( + "if a spell cast this way would be put into a graveyard, put it on the bottom of its owner's library instead", + SpellStackToGraveyardReplacement::Library { + position: LibraryPosition::Bottom, + }, + ), + ( + "if a spell cast this way would be put into a graveyard, return it to its owner's hand instead", + SpellStackToGraveyardReplacement::Hand, + ), + ] { + assert_eq!( + parse_spells_cast_this_way_graveyard_replacement_rider(clause), + Some(expected), + "should recognise multi-cast rider clause: {clause:?}" + ); + } + assert_eq!( + parse_spells_cast_this_way_graveyard_replacement_rider( + "if a spell cast this way would be put into a graveyard, exile it instead, then draw a card", + ), + None, + "the exact multi-cast rider must not partially consume a larger clause" + ); +} + +/// Diluvian Primordial's exact targeted per-opponent graveyard-cast grammar +/// must lower to the existing paired fanout and a free during-resolution cast, +/// with the cast-this-way destination rider attached to that cast. +#[test] +fn per_opponent_graveyard_free_cast_uses_paired_fanout_and_exile_rider() { + let parsed = parse_oracle_text( + "Flying\nWhen this creature enters, for each opponent, you may cast up to one target instant or sorcery card from that player's graveyard without paying its mana cost. If a spell cast this way would be put into a graveyard, exile it instead.", + "Diluvian Primordial", + &[], + &["Creature".to_string()], + &[], + ); + let def = parsed + .triggers + .first() + .and_then(|trigger| trigger.execute.as_deref()) + .expect("Diluvian Primordial ETB must lower to a typed trigger body"); + assert!( + !matches!(def.effect.as_ref(), Effect::Unimplemented { .. }), + "the exact Oracle text must not degrade to Unimplemented: {def:?}" + ); + + assert_eq!( + def.multi_target, + Some(MultiTargetSpec::bounded( + 0, + QuantityExpr::Ref { + qty: QuantityRef::PlayerCount { + filter: PlayerFilter::Opponent, + }, + }, + )), + "one optional object target must be paired with each opponent" + ); + assert_eq!(def.target_choice_timing, TargetChoiceTiming::Stack); + let Effect::CastFromZone { + target, + without_paying_mana_cost, + driver, + .. + } = def.effect.as_ref() + else { + panic!("expected typed CastFromZone, got {:?}", def.effect); + }; + assert!(*without_paying_mana_cost); + assert_eq!(*driver, CastFromZoneDriver::DuringResolution); + let TargetFilter::Or { filters } = target else { + panic!("expected instant-or-sorcery graveyard target, got {target:?}"); + }; + assert_eq!(filters.len(), 2); + for filter in filters { + let TargetFilter::Typed(filter) = filter else { + panic!("expected typed instant-or-sorcery branch, got {filter:?}"); + }; + assert_eq!(filter.controller, Some(ControllerRef::TargetPlayer)); + assert!(filter.properties.contains(&FilterProp::Owned { + controller: ControllerRef::TargetPlayer, + })); + assert!(filter.properties.contains(&FilterProp::InZone { + zone: Zone::Graveyard, + })); + } + assert!(filters.iter().any(|filter| matches!( + filter, + TargetFilter::Typed(TypedFilter { type_filters, .. }) + if type_filters.contains(&TypeFilter::Instant) + ))); + assert!(filters.iter().any(|filter| matches!( + filter, + TargetFilter::Typed(TypedFilter { type_filters, .. }) + if type_filters.contains(&TypeFilter::Sorcery) + ))); + assert!(matches!( + def.sub_ability + .as_deref() + .map(|rider| rider.effect.as_ref()), + Some(Effect::ChangeZone { + origin: None, + destination: Zone::Exile, + target: TargetFilter::ParentTarget, + .. + }) + )); +} + #[test] fn each_player_for_each_attacking_creature_they_control_binds_scoped_player() { let def = parse_effect_chain( diff --git a/crates/engine/src/parser/oracle_tests.rs b/crates/engine/src/parser/oracle_tests.rs index 0001e16f9a..697de4f289 100644 --- a/crates/engine/src/parser/oracle_tests.rs +++ b/crates/engine/src/parser/oracle_tests.rs @@ -7,6 +7,7 @@ use crate::parser::oracle_ir::doc::{ use crate::parser::oracle_ir::static_ir::StaticIr; use crate::types::ability::{ AdditionalCostOrigin, AdditionalCostPaymentSource, CountScope, CounterAdjustment, DoorLockOp, + SpellStackToGraveyardReplacement, }; use crate::types::counter::{CounterMatch, CounterType}; @@ -3294,11 +3295,14 @@ fn free_cast_window_clause_chains_rider_and_self_exile() { max_total_mv, filter, zones, - exile_instead_of_graveyard, + graveyard_replacement, } => { assert_eq!(*count, 2); assert_eq!(*max_total_mv, Some(6)); - assert!(*exile_instead_of_graveyard); + assert_eq!( + graveyard_replacement.as_ref(), + Some(&SpellStackToGraveyardReplacement::Exile) + ); assert_eq!(zones, &vec![Zone::Graveyard, Zone::Hand]); assert_eq!( *filter, @@ -3352,7 +3356,7 @@ fn free_cast_window_parses_single_zone_non_invoke_variant() { max_total_mv, filter, zones, - exile_instead_of_graveyard, + graveyard_replacement, } = &*result.abilities[0].effect else { panic!( @@ -3368,7 +3372,7 @@ fn free_cast_window_parses_single_zone_non_invoke_variant() { TargetFilter::Typed(TypedFilter::new(TypeFilter::Instant)) ); assert_eq!(zones, &vec![Zone::Graveyard]); - assert!(!*exile_instead_of_graveyard); + assert!(graveyard_replacement.is_none()); } /// Issue #2385 MED — `Effect::FreeCastFromZones` is a *free* cast. A diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index dcc1bedae3..6234487d0b 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -3316,8 +3316,13 @@ pub enum ResolutionCastSuccessAction { remaining_mv_budget: Option, filter: TargetFilter, zones: Vec, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - exile_instead_of_graveyard: bool, + #[serde( + default, + alias = "exile_instead_of_graveyard", + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_graveyard_replacement_compat" + )] + graveyard_replacement: Option, /// CR 406.6: Source object of the granting ability, threaded so /// `ExiledBySource`-style filters (Plargg and Nassari) can rebuild the /// re-offer candidate set against the right exile links. Zero sentinel @@ -3673,7 +3678,7 @@ where /// → `None`), so an in-flight saved / reconnected permission serialized before /// the migration still reloads with its exile-on-resolution rider. Paired with /// `#[serde(alias = "exile_instead_of_graveyard_on_resolve")]` on the field. -fn deserialize_graveyard_replacement_compat<'de, D>( +pub(crate) fn deserialize_graveyard_replacement_compat<'de, D>( d: D, ) -> Result, D::Error> where @@ -12095,10 +12100,9 @@ pub enum Effect { /// cross-selection budget that shrinks as each spell is cast. "Up to N" /// makes every cast optional (the controller may stop early or cast none). /// - /// When `exile_instead_of_graveyard` is true, each spell cast this way - /// carries the rider "if those spells would be put into your graveyard, - /// exile them instead" (CR 614.1a) for the rest of the cast — applied as a - /// duration-scoped replacement on the cast spell. + /// When `graveyard_replacement` is `Some`, each spell cast this way carries + /// its CR 614.1a stack-to-graveyard destination rider for the rest of the + /// cast — applied as a duration-scoped replacement on the cast spell. /// /// Distinct from `CastFromZone`, which grants a casting *permission* on a /// targeted object (lingering or a single self-cast). This effect owns the @@ -12118,10 +12122,15 @@ pub enum Effect { /// CR 601.2a: Zones searched for candidates (the controller's own /// graveyard and/or hand). zones: Vec, - /// CR 614.1a: When true, spells cast this way are exiled instead of - /// being put into their owner's graveyard ("exile them instead"). - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - exile_instead_of_graveyard: bool, + /// CR 614.1a: Optional destination for spells cast this way instead of + /// their owner's graveyard (for example, "exile them instead"). + #[serde( + default, + alias = "exile_instead_of_graveyard", + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_graveyard_replacement_compat" + )] + graveyard_replacement: Option, }, /// CR 614.1a + CR 608.2n + CR 607.2b: "Exile it instead of putting it into a /// graveyard as it resolves" — the self-replacement rider applied by a @@ -12136,7 +12145,8 @@ pub enum Effect { /// can later refer to the accumulating set. /// /// Distinct from `ChangeZone { destination: Exile }` (which moves a card that - /// is already in a zone) and from `FreeCastFromZones { exile_instead… }` + /// is already in a zone) and from `FreeCastFromZones { + /// graveyard_replacement: Some(..) }` /// (which stamps the same rider on a spell *cast during resolution*, with no /// linked-exile payoff). This effect is the trigger-driven, link-establishing /// form for the resolving spell itself. diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index e562d0b16e..c1c7c56904 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -7325,10 +7325,15 @@ pub enum CastOfferKind { /// CR 601.2a: Zones searched for candidates (controller's graveyard /// and/or hand). zones: Vec, - /// CR 614.1a: Whether spells cast this way are exiled instead of going - /// to their owner's graveyard. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - exile_instead_of_graveyard: bool, + /// CR 614.1a: Optional destination for spells cast this way instead of + /// their owner's graveyard. + #[serde( + default, + alias = "exile_instead_of_graveyard", + skip_serializing_if = "Option::is_none", + deserialize_with = "crate::types::ability::deserialize_graveyard_replacement_compat" + )] + graveyard_replacement: Option, /// CR 406.6: Source object of the granting ability. `filter`s such as /// `ExiledBySource` (Plargg and Nassari's "the other cards exiled this /// way") resolve their exile links against this id, so the re-offer diff --git a/crates/engine/tests/integration/diluvian_primordial_6754.rs b/crates/engine/tests/integration/diluvian_primordial_6754.rs new file mode 100644 index 0000000000..25af726d46 --- /dev/null +++ b/crates/engine/tests/integration/diluvian_primordial_6754.rs @@ -0,0 +1,497 @@ +//! Diluvian Primordial: paired opponent-graveyard targets become one fixed +//! during-resolution free-cast pool, with no graveyard substitution. + +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::ability::{ + AbilityDefinition, Effect, SpellStackToGraveyardReplacement, TargetRef, +}; +use engine::types::actions::GameAction; +use engine::types::game_state::{CastOfferKind, CastPaymentMode, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaCost, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; +use engine::types::PlayerId; + +const P2: PlayerId = PlayerId(2); +const DILUVIAN_ORACLE: &str = "Flying\nWhen this creature enters, for each opponent, you may cast up to one target instant or sorcery card from that player's graveyard without paying its mana cost. If a spell cast this way would be put into a graveyard, exile it instead."; +const DILUVIAN_WITH_DRAW_ORACLE: &str = "Flying\nWhen this creature enters, for each opponent, you may cast up to one target instant or sorcery card from that player's graveyard without paying its mana cost. If a spell cast this way would be put into a graveyard, exile it instead. Then draw a card."; + +fn advance_to_trigger_target_selection(runner: &mut GameRunner) { + for _ in 0..32 { + match runner.state().waiting_for { + WaitingFor::TriggerTargetSelection { .. } => return, + WaitingFor::Priority { .. } => { + runner + .act(GameAction::PassPriority) + .expect("priority pass while reaching Diluvian trigger"); + } + ref other => panic!("unexpected state while reaching Diluvian trigger: {other:?}"), + } + } + panic!("Diluvian Primordial ETB never reached its target prompt"); +} + +fn advance_to_free_cast_window(runner: &mut GameRunner) { + for _ in 0..32 { + match runner.state().waiting_for { + WaitingFor::CastOffer { + kind: CastOfferKind::FreeCastWindow { .. }, + .. + } => return, + WaitingFor::Priority { .. } => { + runner + .act(GameAction::PassPriority) + .expect("priority pass while resolving Diluvian trigger"); + } + ref other => panic!("unexpected state while reaching Diluvian window: {other:?}"), + } + } + panic!("Diluvian Primordial never opened its free-cast window"); +} + +fn choose_paired_targets(runner: &mut GameRunner, p1_card: ObjectId, p2_card: ObjectId) { + for target in [ + TargetRef::Player(P1), + TargetRef::Object(p1_card), + TargetRef::Player(P2), + TargetRef::Object(p2_card), + ] { + runner + .act(GameAction::ChooseTarget { + target: Some(target), + }) + .expect("paired Diluvian target must be legal"); + } +} + +fn assert_no_unimplemented_effects(ability: &AbilityDefinition) { + assert!( + !matches!(*ability.effect, Effect::Unimplemented { .. }), + "test spell must use a supported effect, not Unimplemented" + ); + if let Some(sub) = &ability.sub_ability { + assert_no_unimplemented_effects(sub); + } + if let Some(otherwise) = &ability.else_ability { + assert_no_unimplemented_effects(otherwise); + } +} + +/// CR 115.1a + CR 608.2g + CR 614.1a: The real cast/trigger pipeline chooses +/// one card per opponent, snapshots exactly those cards into FreeCastWindow, +/// casts one at zero mana, re-offers only the remaining approved card, then +/// exiles the successfully cast spell while declined and unselected cards stay +/// in their owners' graveyards. Each negative assertion has the selected-card +/// reach guard immediately above it. +#[test] +fn diluvian_primordial_uses_selected_pairs_as_its_fixed_free_cast_pool() { + let mut scenario = GameScenario::new_n_player(3, 42); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool( + P0, + vec![ManaUnit::new( + ManaType::Colorless, + ObjectId(0), + false, + vec![], + )], + ); + // The cast spell is a cantrip. Seed P0's library so resolving it proves + // the full cast path deterministically rather than relying on an empty + // library draw-loss implementation detail. + let p0_draw_filler = scenario.add_card_to_library_top(P0, "P0 Draw Filler"); + let primordial = scenario + .add_creature_to_hand_from_oracle(P0, "Diluvian Primordial", 5, 5, DILUVIAN_ORACLE) + .with_mana_cost(ManaCost::generic(1)) + .id(); + let p1_selected = scenario + .add_spell_to_graveyard(P1, "P1 Selected", true) + .with_mana_cost(ManaCost::generic(2)) + .from_oracle_text("Draw a card.") + .id(); + let p2_selected = scenario + .add_spell_to_graveyard(P2, "P2 Selected", false) + .with_mana_cost(ManaCost::generic(3)) + .from_oracle_text("Draw a card.") + .id(); + let p1_extra = scenario + .add_spell_to_graveyard(P1, "P1 Unselected", true) + .from_oracle_text("Draw a card.") + .id(); + let p2_extra = scenario + .add_spell_to_graveyard(P2, "P2 Unselected", false) + .from_oracle_text("Draw a card.") + .id(); + + let mut runner = scenario.build(); + let card_id = runner.state().objects[&primordial].card_id; + runner + .act(GameAction::CastSpell { + object_id: primordial, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("casting Diluvian Primordial must succeed"); + advance_to_trigger_target_selection(&mut runner); + choose_paired_targets(&mut runner, p1_selected, p2_selected); + advance_to_free_cast_window(&mut runner); + + match runner.state().waiting_for.clone() { + WaitingFor::CastOffer { + player: P0, + kind: + CastOfferKind::FreeCastWindow { + candidates, + remaining_casts, + member_pool, + graveyard_replacement, + .. + }, + } => { + assert_eq!(member_pool, vec![p1_selected, p2_selected]); + assert_eq!(candidates, vec![p1_selected, p2_selected]); + assert_eq!(remaining_casts, 2); + assert_eq!( + graveyard_replacement, + Some(SpellStackToGraveyardReplacement::Exile) + ); + } + other => panic!("expected Diluvian FreeCastWindow, got {other:?}"), + } + + runner + .act(GameAction::FreeCastWindowChoice { + selection: Some(p1_selected), + }) + .expect("selected P1 spell must cast for free"); + assert_eq!(runner.state().objects[&p1_selected].zone, Zone::Stack); + match runner.state().waiting_for.clone() { + WaitingFor::CastOffer { + kind: + CastOfferKind::FreeCastWindow { + candidates, + member_pool, + .. + }, + .. + } => { + assert_eq!(member_pool, vec![p1_selected, p2_selected]); + assert_eq!(candidates, vec![p2_selected]); + } + other => panic!("expected fixed-pool re-offer, got {other:?}"), + } + runner + .act(GameAction::FreeCastWindowChoice { selection: None }) + .expect("declining the remaining approved card must finish the window"); + runner.advance_until_stack_empty(); + + assert_eq!(runner.state().objects[&p1_selected].zone, Zone::Exile); + assert_eq!(runner.state().objects[&p0_draw_filler].zone, Zone::Hand); + assert_eq!(runner.state().objects[&p2_selected].zone, Zone::Graveyard); + assert_eq!(runner.state().objects[&p1_extra].zone, Zone::Graveyard); + assert_eq!(runner.state().objects[&p2_extra].zone, Zone::Graveyard); +} + +/// CR 608.2b + CR 608.2g: A paired target that leaves its graveyard before +/// resolution is removed from the fixed pool. The still-legal paired target +/// positively reaches the window; its same-owner extra proves the resolver did +/// not substitute after revalidation. +#[test] +fn diluvian_primordial_drops_removed_pair_without_graveyard_substitution() { + let mut scenario = GameScenario::new_n_player(3, 42); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool( + P0, + vec![ManaUnit::new( + ManaType::Colorless, + ObjectId(0), + false, + vec![], + )], + ); + let primordial = scenario + .add_creature_to_hand_from_oracle(P0, "Diluvian Primordial", 5, 5, DILUVIAN_ORACLE) + .with_mana_cost(ManaCost::generic(1)) + .id(); + let p1_selected = scenario + .add_spell_to_graveyard(P1, "P1 Selected", true) + .from_oracle_text("Draw a card.") + .id(); + let p2_removed = scenario + .add_spell_to_graveyard(P2, "P2 Removed", false) + .from_oracle_text("Draw a card.") + .id(); + let p2_extra = scenario + .add_spell_to_graveyard(P2, "P2 Extra", false) + .from_oracle_text("Draw a card.") + .id(); + + let mut runner = scenario.build(); + let card_id = runner.state().objects[&primordial].card_id; + runner + .act(GameAction::CastSpell { + object_id: primordial, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("casting Diluvian Primordial must succeed"); + advance_to_trigger_target_selection(&mut runner); + choose_paired_targets(&mut runner, p1_selected, p2_removed); + + let mut events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), p2_removed, Zone::Exile, &mut events); + advance_to_free_cast_window(&mut runner); + match runner.state().waiting_for.clone() { + WaitingFor::CastOffer { + kind: + CastOfferKind::FreeCastWindow { + candidates, + member_pool, + .. + }, + .. + } => { + assert_eq!(member_pool, vec![p1_selected]); + assert_eq!(candidates, vec![p1_selected]); + assert!(!candidates.contains(&p2_extra)); + } + other => panic!("expected surviving paired target window, got {other:?}"), + } +} + +/// CR 115.1a + CR 603.3d + CR 608.2g: An opponent with no legal instant or +/// sorcery target contributes no paired slots, but that omission must not +/// suppress another opponent's independently selected target or its normal +/// cast pipeline. +#[test] +fn diluvian_primordial_skips_targetless_opponent_and_casts_other_selected_spell() { + let mut scenario = GameScenario::new_n_player(3, 42); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool( + P0, + vec![ManaUnit::new( + ManaType::Colorless, + ObjectId(0), + false, + vec![], + )], + ); + // As above, make the selected cantrip's resolution observable without an + // empty-library draw-loss side effect. + let p0_draw_filler = scenario.add_card_to_library_top(P0, "P0 Draw Filler"); + let primordial = scenario + .add_creature_to_hand_from_oracle(P0, "Diluvian Primordial", 5, 5, DILUVIAN_ORACLE) + .with_mana_cost(ManaCost::generic(1)) + .id(); + let p2_selected = scenario + .add_spell_to_graveyard(P2, "P2 Selected", true) + .from_oracle_text("Draw a card.") + .id(); + let p2_extra = scenario + .add_spell_to_graveyard(P2, "P2 Unselected", false) + .from_oracle_text("Draw a card.") + .id(); + + let mut runner = scenario.build(); + let card_id = runner.state().objects[&primordial].card_id; + runner + .act(GameAction::CastSpell { + object_id: primordial, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("casting Diluvian Primordial must succeed"); + advance_to_trigger_target_selection(&mut runner); + + match runner.state().waiting_for.clone() { + WaitingFor::TriggerTargetSelection { target_slots, .. } => { + assert_eq!( + target_slots.len(), + 2, + "the targetless P1 must contribute no unusable player/object pair" + ); + assert_eq!(target_slots[0].legal_targets, vec![TargetRef::Player(P2)]); + assert_eq!( + target_slots[1].legal_targets, + vec![TargetRef::Object(p2_selected), TargetRef::Object(p2_extra)] + ); + } + other => panic!("expected Diluvian target selection, got {other:?}"), + } + for target in [TargetRef::Player(P2), TargetRef::Object(p2_selected)] { + runner + .act(GameAction::ChooseTarget { + target: Some(target), + }) + .expect("P2's paired target must remain selectable when P1 has none"); + } + advance_to_free_cast_window(&mut runner); + match runner.state().waiting_for.clone() { + WaitingFor::CastOffer { + kind: + CastOfferKind::FreeCastWindow { + candidates, + member_pool, + remaining_casts, + .. + }, + .. + } => { + assert_eq!(member_pool, vec![p2_selected]); + assert_eq!(candidates, vec![p2_selected]); + assert_eq!(remaining_casts, 1); + } + other => panic!("expected P2-only Diluvian FreeCastWindow, got {other:?}"), + } + runner + .act(GameAction::FreeCastWindowChoice { + selection: Some(p2_selected), + }) + .expect("P2's selected spell must cast through the free-cast window"); + assert_eq!(runner.state().objects[&p2_selected].zone, Zone::Stack); + runner.advance_until_stack_empty(); + assert_eq!(runner.state().objects[&p2_selected].zone, Zone::Exile); + assert_eq!(runner.state().objects[&p0_draw_filler].zone, Zone::Hand); + assert_eq!(runner.state().objects[&p2_extra].zone, Zone::Graveyard); +} + +/// CR 608.2c + CR 608.2g: the destination-replacement rider is metadata for +/// the free-cast window, but a direct sibling after it remains a later printed +/// instruction. It must wait until the controller closes the window rather +/// than resolving while the cast offer is still active. +#[test] +fn diluvian_primordial_defers_sibling_after_open_free_cast_window() { + let mut scenario = GameScenario::new_n_player(2, 42); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool( + P0, + vec![ManaUnit::new( + ManaType::Colorless, + ObjectId(0), + false, + vec![], + )], + ); + let draw_filler = scenario.add_card_to_library_top(P0, "Deferred Draw Filler"); + let primordial = scenario + .add_creature_to_hand_from_oracle( + P0, + "Diluvian Primordial", + 5, + 5, + DILUVIAN_WITH_DRAW_ORACLE, + ) + .with_mana_cost(ManaCost::generic(1)) + .id(); + let selected = scenario + .add_spell_to_graveyard(P1, "Selected Spell", true) + .from_oracle_text("Draw a card.") + .id(); + + let mut runner = scenario.build(); + let card_id = runner.state().objects[&primordial].card_id; + runner + .act(GameAction::CastSpell { + object_id: primordial, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("casting Diluvian Primordial must succeed"); + advance_to_trigger_target_selection(&mut runner); + for target in [TargetRef::Player(P1), TargetRef::Object(selected)] { + runner + .act(GameAction::ChooseTarget { + target: Some(target), + }) + .expect("Diluvian's paired target must be legal"); + } + advance_to_free_cast_window(&mut runner); + + assert_eq!( + runner.state().objects[&draw_filler].zone, + Zone::Library, + "the sibling draw must remain deferred until the free-cast window closes" + ); + runner + .act(GameAction::FreeCastWindowChoice { selection: None }) + .expect("declining the window must close it"); + runner.advance_until_stack_empty(); + assert_eq!(runner.state().objects[&draw_filler].zone, Zone::Hand); +} + +/// CR 601.2c + CR 608.2c: a selected spell can be a legal Diluvian target even +/// when it has no legal targets to cast. That suppresses the free-cast window, +/// but must neither move the selected card nor suppress the printed tail. +#[test] +fn diluvian_primordial_uncastable_selected_spell_stays_in_graveyard_and_runs_tail() { + let mut scenario = GameScenario::new_n_player(2, 42); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool( + P0, + vec![ManaUnit::new( + ManaType::Colorless, + ObjectId(0), + false, + vec![], + )], + ); + let draw_filler = scenario.add_card_to_library_top(P0, "Immediate Draw Filler"); + let primordial = scenario + .add_creature_to_hand_from_oracle( + P0, + "Diluvian Primordial", + 5, + 5, + DILUVIAN_WITH_DRAW_ORACLE, + ) + .with_mana_cost(ManaCost::generic(1)) + .id(); + let selected = scenario + .add_spell_to_graveyard(P1, "No Artifact Target", true) + .from_oracle_text("Destroy target artifact.") + .id(); + + let mut runner = scenario.build(); + let selected_object = &runner.state().objects[&selected]; + assert_no_unimplemented_effects(&selected_object.abilities[0]); + assert!( + !engine::game::casting::spell_has_legal_targets(runner.state(), selected_object, P0), + "Destroy target artifact must be uncastable before the ETB resolves when no artifact exists" + ); + let card_id = runner.state().objects[&primordial].card_id; + runner + .act(GameAction::CastSpell { + object_id: primordial, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("casting Diluvian Primordial must succeed"); + advance_to_trigger_target_selection(&mut runner); + for target in [TargetRef::Player(P1), TargetRef::Object(selected)] { + runner + .act(GameAction::ChooseTarget { + target: Some(target), + }) + .expect("the selected uncastable spell must still be a legal Diluvian pair"); + } + runner.advance_until_stack_empty(); + + assert!( + !matches!( + runner.state().waiting_for, + WaitingFor::CastOffer { + kind: CastOfferKind::FreeCastWindow { .. }, + .. + } + ), + "an uncastable selected spell must not open a FreeCastWindow" + ); + assert_eq!(runner.state().objects[&selected].zone, Zone::Graveyard); + assert_eq!(runner.state().objects[&draw_filler].zone, Zone::Hand); +} diff --git a/crates/engine/tests/integration/invoke_calamity_free_cast.rs b/crates/engine/tests/integration/invoke_calamity_free_cast.rs index e458127ee9..b90305bd62 100644 --- a/crates/engine/tests/integration/invoke_calamity_free_cast.rs +++ b/crates/engine/tests/integration/invoke_calamity_free_cast.rs @@ -20,7 +20,7 @@ //! CR 614.1a: spells cast this way are exiled instead of going to the graveyard. use engine::game::scenario::{GameScenario, P0, P1}; -use engine::types::ability::TargetRef; +use engine::types::ability::{SpellStackToGraveyardReplacement, TargetRef}; use engine::types::actions::GameAction; use engine::types::game_state::{CastOfferKind, CastPaymentMode, StackEntryKind, WaitingFor}; use engine::types::identifiers::ObjectId; @@ -115,14 +115,17 @@ fn invoke_calamity_opens_free_cast_window_and_exiles_cast_spells() { candidates, remaining_casts, remaining_mv_budget, - exile_instead_of_graveyard, + graveyard_replacement, .. }, } => { assert_eq!(player, P0); assert_eq!(remaining_casts, 2, "up to two casts"); assert_eq!(remaining_mv_budget, Some(6)); - assert!(exile_instead_of_graveyard); + assert_eq!( + graveyard_replacement, + Some(SpellStackToGraveyardReplacement::Exile) + ); assert!( candidates.contains(&gy_instant), "the graveyard instant must be a free-cast candidate" diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 957e2d4319..31bf648b24 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -147,6 +147,7 @@ mod devour_co_entry_regression; mod devour_intellect_treasure_rider; mod dig_rest_pile_stranding_on_etb_pause; mod diligent_farmhand_counts_as_named; +mod diluvian_primordial_6754; mod disorder_in_the_court_5955; mod divine_visitation_token_substitution; mod doran_attack_block_pump; diff --git a/docs/parser-misparse-backlog.md b/docs/parser-misparse-backlog.md index fc708654b6..5b5121675c 100644 --- a/docs/parser-misparse-backlog.md +++ b/docs/parser-misparse-backlog.md @@ -211,7 +211,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Devoted Sultai - Devout Harpist - Dewdrop Cure -- Diluvian Primordial - Dimension X Pizzasaur - Diplomatic Escort - Dire Fleet Warmonger From f229ceda2958929d09edb2586067d6103184eff6 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 1 Aug 2026 09:36:00 -0700 Subject: [PATCH 63/63] test(PR-6780): complete Adamant gate coverage --- .../adamant_enters_with_leading_if_gate.rs | 224 +++++++++++++++++- docs/parser-misparse-backlog.md | 27 +-- 2 files changed, 225 insertions(+), 26 deletions(-) diff --git a/crates/engine/tests/integration/adamant_enters_with_leading_if_gate.rs b/crates/engine/tests/integration/adamant_enters_with_leading_if_gate.rs index 3dc89acdc9..a588dd8fc6 100644 --- a/crates/engine/tests/integration/adamant_enters_with_leading_if_gate.rs +++ b/crates/engine/tests/integration/adamant_enters_with_leading_if_gate.rs @@ -20,7 +20,8 @@ //! unconditionally and R2's `assert_counters(.., 0)` fails. use engine::game::scenario::{CastOutcome, GameRunner, GameScenario, P0, P1}; -use engine::types::ability::Effect; +use engine::parser::parse_oracle_text; +use engine::types::ability::{AbilityDefinition, Effect}; use engine::types::counter::CounterType; use engine::types::identifiers::ObjectId; use engine::types::keywords::{Keyword, KeywordKind}; @@ -36,6 +37,30 @@ const ARDENVALE_PALADIN: &str = "Adamant — If at least three white mana was sp const EMBERETH_PALADIN: &str = "Haste\nAdamant — If at least three red mana was spent to cast \ this spell, this creature enters with a +1/+1 counter on it."; +/// Vantress Paladin {3}{U} 2/2 — full verbatim Oracle text. +const VANTRESS_PALADIN: &str = "Flying\nAdamant — If at least three blue mana was spent to cast \ + this spell, this creature enters with a +1/+1 counter on it."; + +/// Locthwain Paladin {3}{B} 3/2 — full verbatim Oracle text. +const LOCTHWAIN_PALADIN: &str = "Menace (This creature can't be blocked except by two or more \ + creatures.)\nAdamant — If at least three black mana was spent to \ + cast this spell, this creature enters with a +1/+1 counter on it."; + +/// Garenbrig Paladin {4}{G} 4/4 — full verbatim Oracle text. +const GARENBRIG_PALADIN: &str = "Adamant — If at least three green mana was spent to cast this \ + spell, this creature enters with a +1/+1 counter on it.\nThis \ + creature can't be blocked by creatures with power 2 or less."; + +const HENGE_WALKER: &str = "Adamant — If at least three mana of the same color was spent to cast \ + this spell, this creature enters with a +1/+1 counter on it."; + +const RED_AND_BLACK_LEGACY: &str = "If you spent black mana on this creature, it enters with a \ + deathtouch counter. If you spent red mana on this creature, it \ + enters with a first strike counter. If you spent both, you choose \ + which one counter it enters with.\nAt the beginning of your upkeep, \ + flip a coin. If it's heads and this creature has deathtouch, or \ + it's tails and this creature has haste, create a treasure token."; + /// Dust Animus {1}{W} 1/1 — verbatim Oracle text. The Plot line is dropped /// because plotting is irrelevant here and its reminder text would only add /// unrelated parse surface; the leading-if line under test is verbatim. @@ -62,16 +87,17 @@ fn pool(colored: &[(ManaType, usize)]) -> Vec { .collect() } -/// Cast a 4-mana Paladin ({3}{}) out of an exactly-sized pool and return -/// the outcome plus its object id. +/// Cast a Paladin out of an exactly-sized pool and return the outcome plus its +/// object id. /// -/// The pool always holds EXACTLY the four mana the cost needs, so the auto-payer +/// The pool always holds EXACTLY the mana the cost needs, so the auto-payer /// has no discretion: every unit in the pool is spent and `colors_spent_to_cast` /// (CR 601.2h) is fully determined by the pool contents. That removes payment /// nondeterminism from every assertion below. fn cast_paladin( name: &str, oracle: &str, + generic: u32, shard: ManaCostShard, power: i32, toughness: i32, @@ -82,7 +108,7 @@ fn cast_paladin( let paladin = scenario .add_creature_to_hand_from_oracle(P0, name, power, toughness, oracle) .with_mana_cost(ManaCost::Cost { - generic: 3, + generic, shards: vec![shard], }) .id(); @@ -125,6 +151,58 @@ fn assert_gate_is_attached(runner: &GameRunner, obj: ObjectId, name: &str) { ); } +fn ability_contains_unimplemented(definition: &AbilityDefinition) -> bool { + matches!(definition.effect.as_ref(), Effect::Unimplemented { .. }) + || definition + .sub_ability + .as_deref() + .is_some_and(ability_contains_unimplemented) +} + +/// Assert that an unsupported enters-with condition stays honest: it must not +/// manufacture a replacement while leaving an explicit parser residual. +fn assert_enters_with_condition_fails_closed( + name: &str, + oracle: &str, + types: &[&str], + power: i32, + toughness: i32, +) { + let types: Vec = types.iter().map(|ty| (*ty).to_owned()).collect(); + let parsed = parse_oracle_text(oracle, name, &[], &types, &[]); + assert!( + parsed.replacements.is_empty(), + "{name} must not publish a replacement for an unsupported condition: {parsed:#?}" + ); + assert!( + parsed.abilities.iter().any(ability_contains_unimplemented) + || parsed + .triggers + .iter() + .filter_map(|trigger| trigger.execute.as_deref()) + .any(ability_contains_unimplemented), + "{name} must retain at least one Effect::Unimplemented residual: {parsed:#?}" + ); + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature_to_hand_from_oracle(P0, name, power, toughness, oracle) + .with_mana_cost(ManaCost::generic(0)) + .id(); + let mut runner = scenario.build(); + let outcome = runner.cast(creature).resolve(); + assert_eq!( + outcome.zone_of(creature), + Zone::Battlefield, + "{name} must resolve onto the battlefield" + ); + assert!( + outcome.state().objects[&creature].counters.is_empty(), + "{name} must not receive any counters from an unsupported enters-with condition" + ); +} + // --------------------------------------------------------------------------- // R1-R6 — the Adamant per-color threshold, CR 106.3 + CR 601.2h. // --------------------------------------------------------------------------- @@ -137,6 +215,7 @@ fn ardenvale_paladin_four_white_applies_counter() { let (outcome, paladin) = cast_paladin( "Ardenvale Paladin", ARDENVALE_PALADIN, + 3, ManaCostShard::White, 2, 3, @@ -156,6 +235,7 @@ fn ardenvale_paladin_white_below_threshold_no_counter() { let (outcome, paladin) = cast_paladin( "Ardenvale Paladin", ARDENVALE_PALADIN, + 3, ManaCostShard::White, 2, 3, @@ -171,6 +251,7 @@ fn ardenvale_paladin_threshold_is_greater_or_equal_three() { let (at_threshold, paladin) = cast_paladin( "Ardenvale Paladin", ARDENVALE_PALADIN, + 3, ManaCostShard::White, 2, 3, @@ -181,6 +262,7 @@ fn ardenvale_paladin_threshold_is_greater_or_equal_three() { let (below, paladin) = cast_paladin( "Ardenvale Paladin", ARDENVALE_PALADIN, + 3, ManaCostShard::White, 2, 3, @@ -197,6 +279,7 @@ fn embereth_paladin_three_red_applies_counter() { let (outcome, paladin) = cast_paladin( "Embereth Paladin", EMBERETH_PALADIN, + 3, ManaCostShard::Red, 3, 1, @@ -214,6 +297,7 @@ fn embereth_paladin_reads_red_not_white() { let (outcome, paladin) = cast_paladin( "Embereth Paladin", EMBERETH_PALADIN, + 3, ManaCostShard::Red, 3, 1, @@ -231,6 +315,7 @@ fn embereth_paladin_four_distinct_colors_still_no_counter() { let (outcome, paladin) = cast_paladin( "Embereth Paladin", EMBERETH_PALADIN, + 3, ManaCostShard::Red, 3, 1, @@ -244,6 +329,114 @@ fn embereth_paladin_four_distinct_colors_still_no_counter() { outcome.assert_counters(paladin, CounterType::Plus1Plus1, 0); } +/// Three blue mana satisfies Vantress Paladin's Adamant gate; one blue mana +/// does not. Both pools are exactly {3}{U}, so every staged unit is spent. +#[test] +fn vantress_paladin_blue_threshold() { + let (at_threshold, paladin) = cast_paladin( + "Vantress Paladin", + VANTRESS_PALADIN, + 3, + ManaCostShard::Blue, + 2, + 2, + &[(ManaType::Blue, 3), (ManaType::Colorless, 1)], + ); + at_threshold.assert_counters(paladin, CounterType::Plus1Plus1, 1); + + let (below_threshold, paladin) = cast_paladin( + "Vantress Paladin", + VANTRESS_PALADIN, + 3, + ManaCostShard::Blue, + 2, + 2, + &[(ManaType::Blue, 1), (ManaType::Colorless, 3)], + ); + below_threshold.assert_counters(paladin, CounterType::Plus1Plus1, 0); +} + +/// Three black mana satisfies Locthwain Paladin's Adamant gate; one black mana +/// does not. Both pools are exactly {3}{B}, so every staged unit is spent. +#[test] +fn locthwain_paladin_black_threshold() { + let (at_threshold, paladin) = cast_paladin( + "Locthwain Paladin", + LOCTHWAIN_PALADIN, + 3, + ManaCostShard::Black, + 3, + 2, + &[(ManaType::Black, 3), (ManaType::Colorless, 1)], + ); + at_threshold.assert_counters(paladin, CounterType::Plus1Plus1, 1); + + let (below_threshold, paladin) = cast_paladin( + "Locthwain Paladin", + LOCTHWAIN_PALADIN, + 3, + ManaCostShard::Black, + 3, + 2, + &[(ManaType::Black, 1), (ManaType::Colorless, 3)], + ); + below_threshold.assert_counters(paladin, CounterType::Plus1Plus1, 0); +} + +/// Three green mana satisfies Garenbrig Paladin's Adamant gate; one green mana +/// does not. Both pools are exactly {4}{G}, so every staged unit is spent. +#[test] +fn garenbrig_paladin_green_threshold() { + let (at_threshold, paladin) = cast_paladin( + "Garenbrig Paladin", + GARENBRIG_PALADIN, + 4, + ManaCostShard::Green, + 4, + 4, + &[(ManaType::Green, 3), (ManaType::Colorless, 2)], + ); + at_threshold.assert_counters(paladin, CounterType::Plus1Plus1, 1); + + let (below_threshold, paladin) = cast_paladin( + "Garenbrig Paladin", + GARENBRIG_PALADIN, + 4, + ManaCostShard::Green, + 4, + 4, + &[(ManaType::Green, 1), (ManaType::Colorless, 4)], + ); + below_threshold.assert_counters(paladin, CounterType::Plus1Plus1, 0); +} + +/// Henge Walker's "same color" condition is a distinct max-over-colors metric, +/// not the supported Adamant per-color condition. It must fail closed. +#[test] +fn henge_walker_same_color_adamant_fails_closed() { + assert_enters_with_condition_fails_closed( + "Henge Walker", + HENGE_WALKER, + &["Artifact", "Creature"], + 2, + 2, + ); +} + +/// Red and Black Legacy combines spent-color predicates and a choice between +/// counter payloads; unsupported text must remain visible rather than becoming +/// an unconditional enters-with replacement. +#[test] +fn red_and_black_legacy_enters_with_conditions_fail_closed() { + assert_enters_with_condition_fails_closed( + "Red and Black Legacy", + RED_AND_BLACK_LEGACY, + &["Creature"], + 2, + 2, + ); +} + // --------------------------------------------------------------------------- // R7 — Dust Animus: a leading-if gate that is NOT a mana-spent threshold. // Proves the peel makes the WHOLE `parse_inner_condition` grammar reachable @@ -464,3 +657,24 @@ fn silverflame_ritual_one_white_does_not_grant_vigilance() { "only one white mana was spent — the Adamant static grant must stay gated off" ); } + +/// A mixed-color exact payment still resolves Silverflame Ritual's ungated +/// counter line, but one white mana is insufficient for its white-Adamant +/// vigilance rider. +#[test] +fn silverflame_ritual_one_white_three_blue_does_not_grant_vigilance() { + let (runner, bear) = cast_silverflame_ritual(&[(ManaType::White, 1), (ManaType::Blue, 3)]); + let obj = &runner.state().objects[&bear]; + assert_eq!( + obj.counters + .get(&CounterType::Plus1Plus1) + .copied() + .unwrap_or(0), + 1, + "the ungated first line must resolve when the generic cost is paid with blue mana" + ); + assert!( + !obj.has_keyword(&Keyword::Vigilance), + "only one white mana was spent — blue mana cannot satisfy the white-Adamant rider" + ); +} diff --git a/docs/parser-misparse-backlog.md b/docs/parser-misparse-backlog.md index ff2f3601b9..5b5121675c 100644 --- a/docs/parser-misparse-backlog.md +++ b/docs/parser-misparse-backlog.md @@ -13,7 +13,7 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top | # | Root cause | # cards | Fix hint (where it likely lives) | |---|------------|--------:|----------------------------------| | 1 | Relative-clause / filter restriction on target dropped | 746 | oracle_target.rs / game/filter.rs — extend TargetFilter property extraction for trailing relative clauses | -| 2 | Dropped intervening-if / gating condition (condition: null) | 586 | oracle_nom/condition.rs parse_inner_condition — trigger/static parsers must delegate condition extraction here | +| 2 | Dropped intervening-if / gating condition (condition: null) | 590 | oracle_nom/condition.rs parse_inner_condition — trigger/static parsers must delegate condition extraction here | | 3 | Anaphor bound to wrong referent | 404 | oracle_quantity.rs context-ref resolution + game/ability_utils.rs forward_result wiring | | 4 | Conjoined / chained second effect clause dropped | 387 | oracle.rs effect-chain composition — split on 'and'/'then'/sentence boundaries and build sub_ability chain | | 5 | Dropped 'for each' / dynamic count collapsed to Fixed | 330 | oracle_quantity.rs parse_for_each_clause / parse_quantity_ref — thread ForEach/ObjectCount into the effect count field | @@ -804,31 +804,12 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top
-### 2. Dropped intervening-if / gating condition (condition: null) (586 cards) +### 2. Dropped intervening-if / gating condition (condition: null) (590 cards) **Signature.** Trigger/static/replacement/spell condition left null though Oracle has an 'if/while/as long as/unless' game-state gate; the effect resolves unconditionally (CR 603.4 / 608.2c). **Fix hint.** oracle_nom/condition.rs parse_inner_condition — trigger/static parsers must delegate condition extraction here -**Status note (sentence-initial "If <condition>, ~ enters with …" sub-class, CR 614.1c).** -`parse_enters_with_counters` now peels an optional CR 207.2c ability-word label plus a -sentence-initial `if , ` gate and attaches it as the replacement's condition, -making the whole `parse_inner_condition` grammar reachable from that position (previously -NO shape was). A new `CastManaSpentMetric::OfColor` leaf covers the Adamant per-color -threshold. Removed from the list below: Ardenvale Paladin, Locthwain Paladin, -Vantress Paladin (Adamant, now gated and `supported`), and Dust Animus (its -"five or more untapped lands" gate was silently dropped; now attached). Embereth Paladin -and Garenbrig Paladin are fixed by the same change but appear nowhere in this file, so no -line was removed for them. **Deliberately KEPT:** Henge Walker — its -"at least three mana of the same color" is a max-over-colors metric with no grammar and -still fails closed; and Necromantic Summons (listed under §11, not here) — §11's root cause -remains true because its replacement carries `valid_card: SelfRef` on a sorcery, so -`object_replacement_candidate_applies` can never match it; the condition now parses but the -replacement stays dead code. -Observed, not introduced: this section's committed header count (590 before this edit) did -not match its own bullet list (597 entries). The counts here were decremented by the four -removals; the pre-existing 7-entry drift is untouched. -
Cards - A-Paragon of Modernity @@ -852,6 +833,7 @@ removals; the pre-existing 7-entry drift is untouched. - Antiquities on the Loose - Arcbound Tracker - Arcum's Sleigh +- Ardenvale Paladin - Arid Archway - Ascendant Packleader - Ashen Ghoul @@ -971,6 +953,7 @@ removals; the pre-existing 7-entry drift is untouched. - Drop of Honey - Drowner of Truth - Drownyard Behemoth +- Dust Animus - Dúnedain Rangers - Earwig Squad - Ego Drain @@ -1134,6 +1117,7 @@ removals; the pre-existing 7-entry drift is untouched. - Lighthouse Chronologist - Lightning Dart - Liliana's Defeat +- Locthwain Paladin - Lord Skitter's Blessing - Loxodon Surveyor - Ludevic, Necro-Alchemist @@ -1379,6 +1363,7 @@ removals; the pre-existing 7-entry drift is untouched. - Valiant Emberkin - Vampire Scrivener - Vampire Socialite +- Vantress Paladin - Venom's Hunger - Venom, Eddie Brock - Verity Circle