From b36db57d5d58068c2b0efd434d82e314c0454f40 Mon Sep 17 00:00:00 2001 From: real-venus Date: Wed, 15 Jul 2026 07:55:26 -0700 Subject: [PATCH 1/9] feat(engine): implement CR 612 text-changing effects (word replacement) Add a reusable Layer-3 token-substitution primitive that replaces one color word / basic land type / creature type with another on a target spell or permanent, mirroring the existing SetChosenName name-change. - New Effect::ChangeTextWords (interactive from/to choice at resolution) and ContinuousModification::ReplaceTextWord (latched operands), applied at Layer 3 per CR 612.1 / 612.2 / 613.1c. - New game/text_substitution.rs walker with an exhaustive, CR-612.2-correct carrier/no-op classification across every color/land/creature-type-bearing enum leaf (keywords, protection/hexproof, target filters, devotion, type lines, landwalk, and ability effect target filters). Mana symbols, color-set-size predicates, chosen-refs, card names, and the Layer-5 color characteristic are correctly never changed. - Interactive WaitingFor::TextWordReplacement + GameAction round-trip with AI legal-action enumeration, multiplayer routing, and render-only choice UI. - Effect::target_filter_mut accessor (paired with target_filter) so the walker reaches words inside ability effect target filters. Covers Mind Bend, Sleight of Mind, Glamerdye, Alter Reality, Magical Hack, Artificial Evolution (with the "can't be Wall" excluded_to rider), Crystal Spray, Trait Doctoring, Whim of Volrath, and Spectral Shift (modal). New Blood, Balduvian Shaman, Deceptive Divination, Magical Hacker, and mass-population / on-stack-instant text-changes remain honest coverage gaps. 12 discriminating runtime tests (duration expiry, filter-position color recursion, landwalk, multi-authority timestamp ordering, Crystal Spray continuation, CR-612.2 name exclusion, serde round-trip, effect-target-filter recursion) plus a 10-card parser snapshot. --- client/src/adapter/types.ts | 13 + .../src/components/modal/CardChoiceModal.tsx | 46 + client/src/game/waitingForRegistry.ts | 1 + client/src/i18n/locales/de/game.json | 4 + client/src/i18n/locales/en/game.json | 4 + client/src/i18n/locales/es/game.json | 4 + client/src/i18n/locales/fr/game.json | 4 + client/src/i18n/locales/it/game.json | 4 + client/src/i18n/locales/pl/game.json | 4 + client/src/i18n/locales/pt/game.json | 4 + crates/engine/src/ai_support/candidates.rs | 13 + crates/engine/src/analysis/ability_graph.rs | 3 +- crates/engine/src/game/ability_rw.rs | 10 +- crates/engine/src/game/ability_scan.rs | 13 + crates/engine/src/game/coverage.rs | 7 + crates/engine/src/game/effects/mod.rs | 6 + crates/engine/src/game/effects/text_change.rs | 142 ++ .../src/game/engine_resolution_choices.rs | 36 + crates/engine/src/game/layers.rs | 12 + crates/engine/src/game/mod.rs | 1 + crates/engine/src/game/printed_cards.rs | 6 +- crates/engine/src/game/quantity.rs | 2 + crates/engine/src/game/scenario.rs | 1 + crates/engine/src/game/text_substitution.rs | 1339 +++++++++++++++++ crates/engine/src/game/trigger_index.rs | 3 + crates/engine/src/parser/clause_shell.rs | 4 + .../engine/src/parser/oracle_effect/lower.rs | 4 + crates/engine/src/parser/oracle_effect/mod.rs | 83 +- .../src/parser/oracle_effect/sequence.rs | 80 +- crates/engine/src/parser/oracle_ir/ast.rs | 9 +- crates/engine/src/parser/oracle_ir/doc.rs | 2 + crates/engine/src/types/ability.rs | 849 +++++++++-- crates/engine/src/types/actions.rs | 8 + crates/engine/src/types/game_state.rs | 38 +- crates/engine/src/types/layers.rs | 12 + crates/engine/src/types/mana.rs | 2 +- crates/engine/tests/integration/main.rs | 1 + .../integration/text_changing_effects.rs | 806 ++++++++++ crates/manabrew-compat/src/lib.rs | 3 +- crates/phase-ai/src/decision_kind.rs | 4 +- .../phase-ai/src/policies/effect_classify.rs | 4 +- .../src/policies/redundancy_avoidance.rs | 4 +- crates/phase-ai/src/policies/x_reference.rs | 2 + crates/phase-ai/src/search.rs | 5 + .../src/game_action_payload_guard.rs | 3 + 45 files changed, 3466 insertions(+), 139 deletions(-) create mode 100644 crates/engine/src/game/effects/text_change.rs create mode 100644 crates/engine/src/game/text_substitution.rs create mode 100644 crates/engine/tests/integration/text_changing_effects.rs diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 85f35cb50b..c5a6c3c142 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1334,6 +1334,15 @@ export type MulliganDecisionPhase = | { type: "Declare" } | { type: "BottomCards"; count: number; then: PendingMulliganAction }; +// CR 612.1 + CR 612.2: one legal text-word substitution offered to the player. +// `label` is engine-computed; the frontend renders it verbatim. +export interface TextWordReplacementOption { + category: string; + from: unknown; + to: unknown; + label: string; +} + export type WaitingFor = | { type: "Priority"; data: { player: PlayerId } } | { type: "MeldPairChoice"; data: { player: PlayerId; choices: MeldSelection[] } } @@ -1395,6 +1404,8 @@ export type WaitingFor = | { type: "OpponentGuess"; data: { player: PlayerId; options: string[]; choice_type: string | Record; source_id: ObjectId; proposition_truth?: boolean } } | { type: "SpellbookDraft"; data: { player: PlayerId; source_id: ObjectId; options: string[]; destination: Zone; tapped?: boolean } } | { type: "DamageSourceChoice"; data: { player: PlayerId; source_filter: TargetFilter; options: ObjectId[] } } + // CR 612.1: pick one engine-computed text-word substitution to install. + | { type: "TextWordReplacement"; data: { player: PlayerId; source: ObjectId; target: ObjectId; options: TextWordReplacementOption[]; duration?: unknown } } | { type: "ModeChoice"; data: { player: PlayerId; modal: ModalChoice; pending_cast: PendingCast; unavailable_modes?: number[] } } | { type: "AbilityModeChoice"; data: { player: PlayerId; modal: ModalChoice; source_id: ObjectId; mode_abilities: unknown[]; is_activated: boolean; ability_index?: number; ability_cost?: unknown; unavailable_modes?: number[] } } | { type: "DiscardToHandSize"; data: { player: PlayerId; count: number; cards: ObjectId[] } } @@ -1908,6 +1919,8 @@ export type GameAction = | { type: "ChooseBranch"; data: { index: number } } | { type: "SubmitLifeRedistribution"; data: { option_index: number } } | { type: "ChooseDamageSource"; data: { source: ObjectId } } + // CR 612.1: index into the offered TextWordReplacement options. + | { type: "ChooseTextWordReplacement"; data: { index: number } } | { type: "SelectModes"; data: { indices: number[] } } | { type: "DecideOptionalCost"; data: { pay: boolean } } | { type: "RespondToSpliceOffer"; data: { card: ObjectId | null } } diff --git a/client/src/components/modal/CardChoiceModal.tsx b/client/src/components/modal/CardChoiceModal.tsx index c550117603..76bcf7897e 100644 --- a/client/src/components/modal/CardChoiceModal.tsx +++ b/client/src/components/modal/CardChoiceModal.tsx @@ -108,6 +108,7 @@ type RevealUntilKeptChoice = Extract< type RepeatDecision = Extract; type ManifestDreadChoice = Extract; type DamageSourceChoice = Extract; +type TextWordReplacement = Extract; type LearnChoice = Extract; type BeholdChoice = Extract; @@ -192,6 +193,9 @@ export function CardChoiceModal() { case "DamageSourceChoice": if (!canActForWaitingState) return null; return ; + case "TextWordReplacement": + if (!canActForWaitingState) return null; + return ; case "VoteChoice": if (!canActForWaitingState) return null; return ; @@ -2888,6 +2892,48 @@ function DamageSourceModal({ data }: { data: DamageSourceChoice["data"] }) { ); } +// ── Text-Word Replacement Modal (CR 612.1) ─────────────────────────────── + +// CR 612.1: the engine pre-computes every legal (category, from, to) option and +// its display `label`; the frontend renders one button per option and dispatches +// the chosen index. No game logic is computed here. +function TextWordReplacementModal({ + data, +}: { + data: TextWordReplacement["data"]; +}) { + const { t } = useTranslation("game"); + const dispatch = useGameDispatch(); + + return ( + +
+ {data.options.map((option, index) => ( + + dispatch({ + type: "ChooseTextWordReplacement", + data: { index }, + }) + } + > + {option.label} + + ))} +
+
+ ); +} + // ── Manifest Dread Modal ───────────────────────────────────────────────── function ManifestDreadModal({ data }: { data: ManifestDreadChoice["data"] }) { diff --git a/client/src/game/waitingForRegistry.ts b/client/src/game/waitingForRegistry.ts index 8efc6119b1..6eb64a2a7d 100644 --- a/client/src/game/waitingForRegistry.ts +++ b/client/src/game/waitingForRegistry.ts @@ -146,6 +146,7 @@ export const HANDLED_WAITING_FOR_TYPES: ReadonlySet = "RetargetChoice", "CopyRetarget", "DamageSourceChoice", + "TextWordReplacement", "DiscardToHandSize", "MiracleReveal", "TributeChoice", diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index 15a509a651..3be35cce97 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -1119,6 +1119,10 @@ } }, "cardChoice": { + "textWordReplacement": { + "title": "Change Text", + "subtitle": "Choose a word to replace" + }, "eachPlayerCopyChosen": { "title": "Choose creatures to copy", "prompt": "The first creature you choose is copied; if you choose a second, its power becomes +1/+1 counters on the copy.", diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index 68b62ec408..e4c25efcc3 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -1549,6 +1549,10 @@ "title": "Damage Source", "subtitle": "Choose a source" }, + "textWordReplacement": { + "title": "Change Text", + "subtitle": "Choose a word to replace" + }, "manifestDread": { "title": "Manifest Dread", "subtitle": "Choose a card to manifest face-down. The other goes to your graveyard.", diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index 15454d3e37..a41ee7e33a 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -1119,6 +1119,10 @@ } }, "cardChoice": { + "textWordReplacement": { + "title": "Change Text", + "subtitle": "Choose a word to replace" + }, "eachPlayerCopyChosen": { "title": "Choose creatures to copy", "prompt": "The first creature you choose is copied; if you choose a second, its power becomes +1/+1 counters on the copy.", diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index 5d3e68ac7b..b045ce1519 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -1119,6 +1119,10 @@ } }, "cardChoice": { + "textWordReplacement": { + "title": "Change Text", + "subtitle": "Choose a word to replace" + }, "eachPlayerCopyChosen": { "title": "Choose creatures to copy", "prompt": "The first creature you choose is copied; if you choose a second, its power becomes +1/+1 counters on the copy.", diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index c69ef7de59..65cba3ac22 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -1119,6 +1119,10 @@ } }, "cardChoice": { + "textWordReplacement": { + "title": "Change Text", + "subtitle": "Choose a word to replace" + }, "eachPlayerCopyChosen": { "title": "Choose creatures to copy", "prompt": "The first creature you choose is copied; if you choose a second, its power becomes +1/+1 counters on the copy.", diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index 48ff20d9f8..51c179443b 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -1119,6 +1119,10 @@ } }, "cardChoice": { + "textWordReplacement": { + "title": "Change Text", + "subtitle": "Choose a word to replace" + }, "eachPlayerCopyChosen": { "title": "Choose creatures to copy", "prompt": "The first creature you choose is copied; if you choose a second, its power becomes +1/+1 counters on the copy.", diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index aa265626fa..d3d706054d 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -1119,6 +1119,10 @@ } }, "cardChoice": { + "textWordReplacement": { + "title": "Change Text", + "subtitle": "Choose a word to replace" + }, "eachPlayerCopyChosen": { "title": "Choose creatures to copy", "prompt": "The first creature you choose is copied; if you choose a second, its power becomes +1/+1 counters on the copy.", diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index d07a70a039..adcaec2a50 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -1495,6 +1495,19 @@ pub fn candidate_actions_broad_with_probe( ) }) .collect(), + // CR 612.1: each pre-computed (category, from, to) option is a legal + // indexed choice. + WaitingFor::TextWordReplacement { + player, options, .. + } => (0..options.len()) + .map(|index| { + candidate( + GameAction::ChooseTextWordReplacement { index }, + TacticalClass::Selection, + Some(*player), + ) + }) + .collect(), // CR 701.38: Vote — every option is a legal candidate; the AI picks via // the standard ChooseOption action. Each remaining vote produces an // identical action set (CR 701.38d allows repeats), so emitting one diff --git a/crates/engine/src/analysis/ability_graph.rs b/crates/engine/src/analysis/ability_graph.rs index 30af21d33d..79e4d73bb8 100644 --- a/crates/engine/src/analysis/ability_graph.rs +++ b/crates/engine/src/analysis/ability_graph.rs @@ -843,7 +843,8 @@ fn effect_projection(effect: &Effect) -> Projection { } } // ----- UNMODELED (over-approximate candidate stage; no modeled axis) ----- - Effect::StartYourEngines { .. } + Effect::ChangeTextWords { .. } + | Effect::StartYourEngines { .. } | Effect::ChangeSpeed { .. } | Effect::ApplyPostReplacementDamage { .. } | Effect::Pump { .. } diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index ce0190f39d..c038a0753e 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -2716,6 +2716,9 @@ fn legacy_continuous_modification(m: &ContinuousModification) -> bool { // CR 612.8 + 613.1c: Layer-3 name-set from source's chosen name (Psychic // Paper); a granted continuous mod, no frozen event-context tag. | ContinuousModification::SetChosenName + // CR 612.1: Layer-3 text-word replacement; operands are latched at + // resolution, so it carries no frozen event-context tag. + | ContinuousModification::ReplaceTextWord { .. } | ContinuousModification::RetainPrintedTriggerFromSource { .. } | ContinuousModification::RetainPrintedAbilityFromSource { .. } | ContinuousModification::AddSupertype { .. } @@ -3326,6 +3329,8 @@ fn legacy_effect(x: &Effect) -> bool { | Effect::ManifestDread | Effect::Choose { .. } | Effect::ApplyPostReplacementDamage { .. } + // CR 612.1: operands latched at resolution; no frozen event-context tag. + | Effect::ChangeTextWords { .. } | Effect::Unimplemented { .. } => false, } } @@ -5087,7 +5092,10 @@ fn rw_effect( } (p, None) } - Effect::AddTargetReplacement { .. } + // CR 612.1: latched text-word replacement — reads no member/event-bound + // value and writes no event object; empty profile. + Effect::ChangeTextWords { .. } + | Effect::AddTargetReplacement { .. } | Effect::AddRestriction { .. } | Effect::ReduceNextSpellCost { .. } | Effect::GrantNextSpellAbility { .. } diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index d3b58f7f46..114ae40aaa 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -340,6 +340,9 @@ fn scan_target_selection_constraint(c: &TargetSelectionConstraint) -> Axes { fn scan_effect(x: &Effect) -> Axes { match x { + // CR 612.1: text-change contributes no scan axis (its target/choice are + // resolved interactively, carrying no cost/count/zone axis). + Effect::ChangeTextWords { .. } => Axes::NONE, Effect::StartYourEngines { player_scope } => { let mut acc = Axes::NONE; acc = acc.or(scan_player_filter(player_scope)); @@ -4400,6 +4403,8 @@ fn effect_resolution_choice_freedom(e: &Effect) -> ResolutionChoiceFreedom { | Effect::RedistributeLifeTotals | Effect::ReverseTurnOrder | Effect::ChooseOneOf { .. } + // CR 612.1: raises `WaitingFor::TextWordReplacement` — fail-closed prompt. + | Effect::ChangeTextWords { .. } | Effect::Unimplemented { .. } => ResolutionChoiceFreedom::MayPrompt, } } @@ -4655,6 +4660,8 @@ pub(crate) fn effect_is_randomness_bearing(e: &Effect) -> bool { | Effect::RedistributeLifeTotals | Effect::ReverseTurnOrder | Effect::ChooseOneOf { .. } + // CR 612.1: text-change draws no RNG. + | Effect::ChangeTextWords { .. } | Effect::Unimplemented { .. } => false, } } @@ -5204,6 +5211,12 @@ mod tests { count: 1, target: TargetFilter::Any, }, // discard selection prompt + Effect::ChangeTextWords { + target: TargetFilter::Any, + allowed_categories: Vec::new(), + excluded_to: Vec::new(), + duration: None, + }, // WaitingFor::TextWordReplacement — text_change.rs ]; for e in &rejects { assert_eq!( diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 3942f97bf8..609f773b2c 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -2171,6 +2171,12 @@ fn fmt_count_scope(scope: &CountScope) -> &'static str { fn effect_details(effect: &Effect) -> Vec<(String, String)> { let mut d = Vec::new(); match effect { + // CR 612.1: text-change — record which word categories it may replace. + Effect::ChangeTextWords { + allowed_categories, .. + } => { + d.push(("categories".into(), format!("{allowed_categories:?}"))); + } Effect::StartYourEngines { player_scope } => { d.push(("players".into(), fmt_player_filter(player_scope))); } @@ -4189,6 +4195,7 @@ fn fmt_modification(m: &crate::types::ability::ContinuousModification) -> String } ContinuousModification::SetChosenBasicLandType => "set chosen land type".into(), ContinuousModification::SetChosenName => "set chosen name".into(), + ContinuousModification::ReplaceTextWord { .. } => "replace text word".into(), ContinuousModification::AssignNoCombatDamage => "assign no combat damage".into(), ContinuousModification::RetainPrintedTriggerFromSource { source_trigger_index, diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index fbe5235a16..beb777d7a1 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -212,6 +212,7 @@ pub mod suspect; pub mod swap_chosen_labels; pub mod switch_pt; pub mod tap_untap; +pub mod text_change; pub mod time_travel; pub mod token; pub mod token_copy; @@ -1998,6 +1999,10 @@ fn waits_for_resolution_choice(waiting_for: &WaitingFor) -> bool { // `NamedChoice` / Scry "If you do, Y"). | WaitingFor::OpponentGuess { .. } | WaitingFor::DamageSourceChoice { .. } + // CR 612.1: the controller's text-word pick is a deferred resolution + // choice, so any follow-up chain (Crystal Spray's "Draw a card") + // stashes as pending_continuation rather than firing early. + | WaitingFor::TextWordReplacement { .. } | WaitingFor::MultiTargetSelection { .. } | WaitingFor::ReplacementChoice { .. } | WaitingFor::OptionalEffectChoice { .. } @@ -3200,6 +3205,7 @@ pub fn resolve_effect( events: &mut Vec, ) -> Result<(), EffectError> { match &ability.effect { + Effect::ChangeTextWords { .. } => text_change::resolve(state, ability, events), Effect::StartYourEngines { .. } => speed_effects::resolve_start(state, ability, events), Effect::ChangeSpeed { .. } => speed_effects::resolve_change_speed(state, ability, events), Effect::DealDamage { .. } => deal_damage::resolve(state, ability, events), diff --git a/crates/engine/src/game/effects/text_change.rs b/crates/engine/src/game/effects/text_change.rs new file mode 100644 index 0000000000..4892b33f37 --- /dev/null +++ b/crates/engine/src/game/effects/text_change.rs @@ -0,0 +1,142 @@ +//! CR 612.1: Interactive text-changing effect (`Effect::ChangeTextWords`). +//! +//! At resolution the controller of the effect chooses one concrete +//! `(category, from, to)` substitution to install as a Layer-3 text-changing +//! continuous effect on the targeted object. The engine pre-computes every legal +//! option so the choice is a single index (`GameAction::ChooseTextWordReplacement`). + +use std::collections::BTreeSet; + +use crate::game::text_substitution::collect_present_words; +use crate::types::ability::{ + BasicLandType, Effect, EffectError, EffectKind, ResolvedAbility, TargetRef, TextWord, + TextWordCategory, +}; +use crate::types::events::GameEvent; +use crate::types::game_state::{GameState, TextWordReplacementOption, WaitingFor}; +use crate::types::mana::ManaColor; + +/// CR 612.1: Resolve `Effect::ChangeTextWords`. Enumerates every legal +/// `(category, from, to)` option for the target (CR 612.2) and pauses on +/// `WaitingFor::TextWordReplacement` for the controller to pick one. If the +/// target is gone (CR 608.2b) or no legal substitution exists (CR 609.3), the +/// effect does nothing. +pub fn resolve( + state: &mut GameState, + ability: &ResolvedAbility, + events: &mut Vec, +) -> Result<(), EffectError> { + let (allowed_categories, excluded_to, duration) = match &ability.effect { + Effect::ChangeTextWords { + allowed_categories, + excluded_to, + duration, + .. + } => ( + allowed_categories.clone(), + excluded_to.clone(), + duration.clone(), + ), + _ => { + return Err(EffectError::InvalidParam( + "expected ChangeTextWords effect".to_string(), + )) + } + }; + + // CR 608.2b: the effect needs a legal object target still in its zone. + let target = ability.targets.iter().find_map(|t| match t { + TargetRef::Object(id) => Some(*id), + TargetRef::Player(_) => None, + }); + let Some(target) = target.filter(|id| state.objects.contains_key(id)) else { + events.push(resolved_event(ability)); + return Ok(()); + }; + + let options = build_options(state, target, &allowed_categories, &excluded_to); + + // CR 609.3: no legal substitution exists — do as much as possible (nothing). + if options.is_empty() { + events.push(resolved_event(ability)); + return Ok(()); + } + + state.waiting_for = WaitingFor::TextWordReplacement { + player: ability.controller, + source: ability.source_id, + target, + options, + duration, + }; + events.push(resolved_event(ability)); + Ok(()) +} + +fn resolved_event(ability: &ResolvedAbility) -> GameEvent { + GameEvent::EffectResolved { + kind: EffectKind::from(&ability.effect), + source_id: ability.source_id, + subject: None, + } +} + +/// CR 612.1 + CR 612.2: Build every legal `(category, from, to)` option: `from` +/// ranges over the category words actually present on the target; `to` ranges +/// over that category's full word set minus `excluded_to`, and `to != from`. +fn build_options( + state: &GameState, + target: crate::types::identifiers::ObjectId, + allowed_categories: &[TextWordCategory], + excluded_to: &[TextWord], +) -> Vec { + let Some(target_obj) = state.objects.get(&target) else { + return Vec::new(); + }; + + let mut options = Vec::new(); + for &category in allowed_categories { + let mut present = collect_present_words(target_obj, category); + // CR 205.3m + CR 612.2: a creature-type `from` must be a real creature + // type; the walker over-reports non-basic subtypes, so intersect with the + // live creature-type set. + if category == TextWordCategory::CreatureType { + let creature_types: BTreeSet<&String> = state.all_creature_types.iter().collect(); + present.retain(|w| match w { + TextWord::CreatureType(name) => creature_types.contains(name), + _ => true, + }); + } + let to_words = category_all_words(state, category); + for from in &present { + for to in &to_words { + if to == from || excluded_to.contains(to) { + continue; + } + options.push(TextWordReplacementOption { + category, + label: format!("{} → {}", from.label(), to.label()), + from: from.clone(), + to: to.clone(), + }); + } + } + } + options +} + +/// CR 612.2: The full set of words of a category, from which `to` is chosen. +fn category_all_words(state: &GameState, category: TextWordCategory) -> Vec { + match category { + TextWordCategory::ColorWord => ManaColor::ALL.iter().map(|c| TextWord::Color(*c)).collect(), + TextWordCategory::BasicLandType => BasicLandType::all() + .iter() + .map(|lt| TextWord::BasicLandType(*lt)) + .collect(), + TextWordCategory::CreatureType => state + .all_creature_types + .iter() + .map(|s| TextWord::CreatureType(s.clone())) + .collect(), + } +} diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index f07e9e2d2f..6873f63835 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -164,6 +164,7 @@ pub(super) fn handles(waiting_for: &WaitingFor) -> bool { | WaitingFor::OpponentGuess { .. } | WaitingFor::SpellbookDraft { .. } | WaitingFor::DamageSourceChoice { .. } + | WaitingFor::TextWordReplacement { .. } | WaitingFor::ChooseRingBearer { .. } | WaitingFor::ChooseRoomDoor { .. } | WaitingFor::ChooseDungeon { .. } @@ -4242,6 +4243,41 @@ pub(super) fn handle_resolution_choice( state.last_chosen_damage_source = None; ResolutionChoiceOutcome::WaitingFor(state.waiting_for.clone()) } + // CR 612.1 + CR 613.1c: the controller picked one (category, from, to) + // substitution; install it as a Layer-3 text-changing continuous effect + // keyed to the target, then drain any parked follow-up (Crystal Spray's + // "Draw a card"). + ( + WaitingFor::TextWordReplacement { + player, + source, + target, + options, + duration, + }, + GameAction::ChooseTextWordReplacement { index }, + ) => { + let option = options.get(index).ok_or_else(|| { + EngineError::InvalidAction("Invalid text-word replacement index".to_string()) + })?; + state.add_transient_continuous_effect( + source, + player, + duration.unwrap_or(crate::types::ability::Duration::Permanent), + crate::types::ability::TargetFilter::SpecificObject { id: target }, + vec![ + crate::types::ability::ContinuousModification::ReplaceTextWord { + category: option.category, + from: option.from.clone(), + to: option.to.clone(), + }, + ], + None, + ); + set_priority(state, player); + effects::drain_pending_continuation(state, events); + ResolutionChoiceOutcome::WaitingFor(state.waiting_for.clone()) + } ( WaitingFor::ChooseRingBearer { player, candidates }, GameAction::ChooseRingBearer { target }, diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index ebde4012ec..29be8fd2ae 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -5140,6 +5140,18 @@ fn apply_continuous_effect_filtered( obj.name = name.clone(); } } + // CR 612.1 + CR 613.1c: Layer 3 — replace every instance of `from` + // (used as `category`) with `to` across the object's derived + // characteristics. Operands are latched at resolution (no pre-read); + // relies on the per-pass base re-seed + `mark_full()` re-derivation so + // each layer pass starts from printed values and re-applies the swap. + ContinuousModification::ReplaceTextWord { category, from, to } => { + crate::game::text_substitution::walk_object_words( + obj, + *category, + &mut crate::game::text_substitution::WordCursor::Replace { from, to }, + ); + } ContinuousModification::AddPower { value } => { if let Some(ref mut p) = obj.power { *p = saturating_pt_add(*p, *value); diff --git a/crates/engine/src/game/mod.rs b/crates/engine/src/game/mod.rs index adbaaf9446..7769e6680c 100644 --- a/crates/engine/src/game/mod.rs +++ b/crates/engine/src/game/mod.rs @@ -154,6 +154,7 @@ pub mod stickers; #[path = "stickers_tests.rs"] mod stickers_tests; pub mod targeting; +pub mod text_substitution; pub mod token_presets; pub mod topology; pub mod transform; diff --git a/crates/engine/src/game/printed_cards.rs b/crates/engine/src/game/printed_cards.rs index e16176d579..a92d0a3af0 100644 --- a/crates/engine/src/game/printed_cards.rs +++ b/crates/engine/src/game/printed_cards.rs @@ -792,6 +792,9 @@ fn walk_continuous_mod(modification: &ContinuousModification, out: &mut Vec) { } } // Leaf effects with no nested ability/effect carrier. - Effect::StartYourEngines { .. } + Effect::ChangeTextWords { .. } + | Effect::StartYourEngines { .. } | Effect::ChangeSpeed { .. } | Effect::DealDamage { .. } | Effect::ApplyPostReplacementDamage { .. } diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index 67d6cc9a89..f012aa53ba 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -502,6 +502,8 @@ pub(crate) fn continuous_modification_dynamic_quantity( | ContinuousModification::SetBasicLandType { .. } | ContinuousModification::SetChosenBasicLandType | ContinuousModification::SetChosenName + // CR 612.1: latched text-word replacement carries no dynamic quantity. + | ContinuousModification::ReplaceTextWord { .. } | ContinuousModification::RetainPrintedTriggerFromSource { .. } | ContinuousModification::RetainPrintedAbilityFromSource { .. } | ContinuousModification::AddSupertype { .. } diff --git a/crates/engine/src/game/scenario.rs b/crates/engine/src/game/scenario.rs index 05df1b91d4..12f5a0130d 100644 --- a/crates/engine/src/game/scenario.rs +++ b/crates/engine/src/game/scenario.rs @@ -1521,6 +1521,7 @@ impl GameRunner { /// Returns the current waiting-state variant name for lightweight assertions. pub fn waiting_for_kind(&self) -> &'static str { match &self.state.waiting_for { + WaitingFor::TextWordReplacement { .. } => "TextWordReplacement", WaitingFor::Priority { .. } => "Priority", WaitingFor::MeldPairChoice { .. } => "MeldPairChoice", WaitingFor::MeldAttackTargetChoice { .. } => "MeldAttackTargetChoice", diff --git a/crates/engine/src/game/text_substitution.rs b/crates/engine/src/game/text_substitution.rs new file mode 100644 index 0000000000..331430c526 --- /dev/null +++ b/crates/engine/src/game/text_substitution.rs @@ -0,0 +1,1339 @@ +//! CR 612: Text-changing effects — word replacement (Layer 3). +//! +//! Single authority for walking a [`GameObject`]'s derived characteristics and +//! either COLLECTING the text words of a category currently present on it +//! (CR 612.2 — the enumerator of legal `from` words) or REPLACING every instance +//! of one word (`from`) with another (`to`). Both directions share one traversal +//! ([`walk_object_words`]) so the set of characteristics a text-change reads and +//! the set it writes can never drift apart. +//! +//! CR 612.2 scoping: only words "used in the correct way" are touched — +//! - a Magic color word used as a color word (rules-text color predicates, +//! `Protection`/`HexproofFrom` color params, `SetColor`/`AddColor`, devotion), +//! - a basic land type used as a land type (type-line subtypes, `Landwalk`, +//! `SetBasicLandType`, nested subtype filters), +//! - a creature type used as a creature type (type-line subtypes, typal filters). +//! +//! Structurally EXCLUDED (never walked, per CR 612.2): the object's name / +//! base name, its Layer-5 `color` field, and its mana cost / mana-symbol pips — +//! these roots are simply not descended into. A mana SYMBOL ({R}) or a +//! color-set-size predicate is not a color WORD (CR 612.2 + CR 107.4), so those +//! carriers are explicit no-ops below. +//! +//! Every enum match here is exhaustive with no `_` wildcard: a future +//! word-bearing variant fails to compile until it is classified as a carrier, +//! a recursion point, or an explicit no-op. + +use std::collections::BTreeSet; +use std::str::FromStr; +use std::sync::Arc; + +use crate::game::game_object::GameObject; +use crate::types::ability::{ + AbilityDefinition, BasicLandType, ContinuousModification, DevotionColors, Effect, FilterProp, + ObjectProperty, QuantityExpr, QuantityRef, StaticCondition, StaticDefinition, TargetFilter, + TextWord, TextWordCategory, TriggerDefinition, TypeFilter, TypedFilter, +}; +use crate::types::keywords::{HexproofFilter, Keyword, ProtectionTarget}; +use crate::types::mana::ManaColor; + +/// Direction of a text-word walk. +pub enum WordCursor<'a> { + /// Accumulate every text word of the walk's category present on the object. + Collect(&'a mut BTreeSet), + /// Replace each instance of `from` with `to` (both of the walk's category). + Replace { + from: &'a TextWord, + to: &'a TextWord, + }, +} + +impl WordCursor<'_> { + /// Visit a color-word carrier (`ManaColor`). Only acts under the color-word + /// category (CR 612.2: a color word used as a color word). + fn color(&mut self, category: TextWordCategory, c: &mut ManaColor) { + if category != TextWordCategory::ColorWord { + return; + } + match self { + WordCursor::Collect(set) => { + set.insert(TextWord::Color(*c)); + } + WordCursor::Replace { from, to } => { + if let (TextWord::Color(f), TextWord::Color(t)) = (&**from, &**to) { + if *c == *f { + *c = *t; + } + } + } + } + } + + /// Visit a basic-land-type carrier stored as a typed [`BasicLandType`] + /// (`SetBasicLandType`). Only acts under the basic-land-type category. + fn basic_land_type(&mut self, category: TextWordCategory, lt: &mut BasicLandType) { + if category != TextWordCategory::BasicLandType { + return; + } + match self { + WordCursor::Collect(set) => { + set.insert(TextWord::BasicLandType(*lt)); + } + WordCursor::Replace { from, to } => { + if let (TextWord::BasicLandType(f), TextWord::BasicLandType(t)) = (&**from, &**to) { + if *lt == *f { + *lt = *t; + } + } + } + } + } + + /// Visit a subtype string that may name a basic land type or a creature type + /// (type-line subtypes, `AddSubtype`/`RemoveSubtype`, `TypeFilter::Subtype`). + /// CR 612.2 + CR 205.3: the walk's `category` disambiguates which meaning the + /// string carries — a "Mountain" subtype is a land type under the land + /// category and (never a creature type) under the creature category. For + /// creature-type collection this may over-report non-creature subtypes; the + /// resolver intersects the result with the live creature-type set. + fn subtype(&mut self, category: TextWordCategory, s: &mut String) { + match self { + WordCursor::Collect(set) => match category { + TextWordCategory::BasicLandType => { + if let Ok(bt) = BasicLandType::from_str(s) { + set.insert(TextWord::BasicLandType(bt)); + } + } + TextWordCategory::CreatureType => { + if BasicLandType::from_str(s).is_err() { + set.insert(TextWord::CreatureType(s.clone())); + } + } + TextWordCategory::ColorWord => {} + }, + WordCursor::Replace { from, to } => match (category, &**from, &**to) { + ( + TextWordCategory::BasicLandType, + TextWord::BasicLandType(f), + TextWord::BasicLandType(t), + ) if s.as_str() == f.as_subtype_str() => { + *s = t.as_subtype_str().to_string(); + } + ( + TextWordCategory::CreatureType, + TextWord::CreatureType(f), + TextWord::CreatureType(t), + ) if s == f => { + *s = t.clone(); + } + _ => {} + }, + } + } + + /// Visit a landwalk string. CR 612.2: landwalk names a land type, so only the + /// basic-land-type category applies (delegates to [`Self::subtype`]). + fn landwalk(&mut self, category: TextWordCategory, s: &mut String) { + if category == TextWordCategory::BasicLandType { + self.subtype(category, s); + } + } +} + +/// CR 612.2 enumerator: collect every text word of `category` currently present +/// on `obj` (the legal `from` words for a text-changing effect). Runs the shared +/// walker with a `Collect` cursor over a throwaway copy so no state is mutated. +pub fn collect_present_words(obj: &GameObject, category: TextWordCategory) -> BTreeSet { + let mut set = BTreeSet::new(); + let mut scratch = obj.clone(); + walk_object_words(&mut scratch, category, &mut WordCursor::Collect(&mut set)); + set +} + +/// CR 612.1 + CR 613.1c: The single traversal authority. Walks the object's live +/// (post-layer) word-bearing roots in place. Never descends into name / color / +/// mana-cost roots (CR 612.2 structural exclusion). +/// +/// Ability *costs* and non-`affected`/`condition`/`modifications` static fields +/// (e.g. `StaticMode`, `attack_defended`) and `AbilityCondition` bodies are an +/// intentional coverage gap: no covered card changes a word buried there, and +/// leaving them out keeps the traversal to the roots CR 612 actually reaches for +/// this class. A future card needing them extends the roots here. +pub fn walk_object_words( + obj: &mut GameObject, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + // Root 1: type-line subtypes (CR 205.3 — land / creature types). + for subtype in obj.card_types.subtypes.iter_mut() { + cursor.subtype(category, subtype); + } + // Root 2: keyword abilities (landwalk land types, protection / hexproof-from + // color params). + for keyword in obj.keywords.iter_mut() { + walk_keyword(keyword, category, cursor); + } + // Root 3: activated / spell abilities (their effects and embedded filters). + for ability in Arc::make_mut(&mut obj.abilities).iter_mut() { + walk_ability_definition(ability, category, cursor); + } + // Root 4: triggered abilities. + for i in 0..obj.trigger_definitions.len() { + if let Some(trigger) = obj.trigger_definitions.get_mut(i) { + walk_trigger_definition(trigger, category, cursor); + } + } + // Root 5: static abilities (affected set, condition, layered modifications). + for i in 0..obj.static_definitions.len() { + if let Some(static_def) = obj.static_definitions.get_mut(i) { + walk_static_definition(static_def, category, cursor); + } + } +} + +fn walk_keyword(keyword: &mut Keyword, category: TextWordCategory, cursor: &mut WordCursor) { + match keyword { + // CR 702.16: protection from [color] carries a color WORD. + Keyword::Protection(target) => walk_protection_target(target, category, cursor), + // CR 702.11d: hexproof from [color] carries a color WORD. + Keyword::HexproofFrom(filter) => walk_hexproof_filter(filter, category, cursor), + // CR 702.14: landwalk names a land type. + Keyword::Landwalk(land) => cursor.landwalk(category, land), + // Every other keyword carries no color/land/creature WORD used as such. + Keyword::Flying + | Keyword::FirstStrike + | Keyword::DoubleStrike + | Keyword::Trample + | Keyword::TrampleOverPlaneswalkers + | Keyword::Deathtouch + | Keyword::Lifelink + | Keyword::Vigilance + | Keyword::Haste + | Keyword::Reach + | Keyword::Defender + | Keyword::Menace + | Keyword::Indestructible + | Keyword::Hexproof + | Keyword::Shroud + | Keyword::Flash + | Keyword::Fear + | Keyword::Intimidate + | Keyword::Skulk + | Keyword::Shadow + | Keyword::Horsemanship + | Keyword::Wither + | Keyword::Infect + | Keyword::Afflict(..) + | Keyword::StartingIntensity(..) + | Keyword::Prowess + | Keyword::Undying + | Keyword::Persist + | Keyword::Cascade + | Keyword::Exalted + | Keyword::Flanking + | Keyword::Evolve + | Keyword::Extort + | Keyword::Exploit + | Keyword::Explore + | Keyword::Ascend + | Keyword::StartYourEngines + | Keyword::Dredge(..) + | Keyword::Modular(..) + | Keyword::Renown(..) + | Keyword::Fabricate(..) + | Keyword::Annihilator(..) + | Keyword::Bushido(..) + | Keyword::Frenzy(..) + | Keyword::Tribute(..) + | Keyword::Soulbond + | Keyword::Unearth(..) + | Keyword::Convoke + | Keyword::Waterbend + | Keyword::Delve + | Keyword::Devoid + | Keyword::Changeling + | Keyword::Phasing + | Keyword::Battlecry + | Keyword::Decayed + | Keyword::Unleash + | Keyword::Riot + | Keyword::Afterlife(..) + | Keyword::Enchant(..) + | Keyword::EtbCounter { .. } + | Keyword::Reconfigure(..) + | Keyword::LivingWeapon + | Keyword::JobSelect + | Keyword::TotemArmor + | Keyword::Bestow(..) + | Keyword::Embalm(..) + | Keyword::Eternalize(..) + | Keyword::Fading(..) + | Keyword::Vanishing(..) + | Keyword::Kicker(..) + | Keyword::Cycling(..) + | Keyword::Flashback(..) + | Keyword::Ward(..) + | Keyword::Equip(..) + | Keyword::Rampage(..) + | Keyword::Absorb(..) + | Keyword::Crew { .. } + | Keyword::Partner(..) + | Keyword::Companion(..) + | Keyword::Ninjutsu(..) + | Keyword::CommanderNinjutsu(..) + | Keyword::Prowl(..) + | Keyword::Morph(..) + | Keyword::Megamorph(..) + | Keyword::Mayhem(..) + | Keyword::Madness(..) + | Keyword::Miracle(..) + | Keyword::Dash(..) + | Keyword::Emerge(..) + | Keyword::Escape(..) + | Keyword::Harmonize(..) + | Keyword::Evoke(..) + | Keyword::Foretell(..) + | Keyword::Mutate(..) + | Keyword::Disturb(..) + | Keyword::Disguise(..) + | Keyword::Blitz(..) + | Keyword::Overload(..) + | Keyword::Spectacle(..) + | Keyword::Surge(..) + | Keyword::Encore(..) + | Keyword::Buyback(..) + | Keyword::Casualty(..) + | Keyword::Echo(..) + | Keyword::Entwine(..) + | Keyword::Outlast(..) + | Keyword::Scavenge(..) + | Keyword::Reinforce { .. } + | Keyword::Fortify(..) + | Keyword::Prototype { .. } + | Keyword::Plot(..) + | Keyword::Craft { .. } + | Keyword::Offspring(..) + | Keyword::Impending { .. } + | Keyword::LevelUp(..) + | Keyword::Affinity(..) + | Keyword::CumulativeUpkeep(..) + | Keyword::Banding + | Keyword::BandsWithOther(..) + | Keyword::Epic + | Keyword::Fuse + | Keyword::Gravestorm + | Keyword::Haunt + | Keyword::Hideaway(..) + | Keyword::Improvise + | Keyword::Ingest + | Keyword::Melee + | Keyword::Mentor + | Keyword::Myriad + | Keyword::Provoke + | Keyword::Rebound + | Keyword::Retrace + | Keyword::Ripple(..) + | Keyword::SplitSecond + | Keyword::Storm + | Keyword::Suspend { .. } + | Keyword::Totem + | Keyword::Warp(..) + | Keyword::Sneak(..) + | Keyword::WebSlinging(..) + | Keyword::Mobilize(..) + | Keyword::Gift(..) + | Keyword::Discover(..) + | Keyword::Spree + | Keyword::Ravenous + | Keyword::Daybound + | Keyword::Nightbound + | Keyword::Enlist + | Keyword::ReadAhead + | Keyword::Compleated + | Keyword::Conspire + | Keyword::Demonstrate + | Keyword::Dethrone + | Keyword::DoubleTeam + | Keyword::LivingMetal + | Keyword::Poisonous(..) + | Keyword::Bloodthirst(..) + | Keyword::Amplify(..) + | Keyword::Graft(..) + | Keyword::Devour(..) + | Keyword::Toxic(..) + | Keyword::Saddle(..) + | Keyword::Teamwork(..) + | Keyword::Soulshift(..) + | Keyword::Backup(..) + | Keyword::Squad(..) + | Keyword::Typecycling { .. } + | Keyword::Firebending(..) + | Keyword::Splice { .. } + | Keyword::Bargain + | Keyword::Sunburst + | Keyword::Champion(..) + | Keyword::Training + | Keyword::Assist + | Keyword::Augment + | Keyword::Aftermath + | Keyword::JumpStart + | Keyword::Cipher + | Keyword::Transmute(..) + | Keyword::Transfigure(..) + | Keyword::Escalate(..) + | Keyword::Recover(..) + | Keyword::Cleave(..) + | Keyword::Undaunted + | Keyword::Paradigm + | Keyword::Station + | Keyword::Replicate(..) + | Keyword::Awaken { .. } + | Keyword::ForMirrodin + | Keyword::MoreThanMeetsTheEye(..) + | Keyword::Freerunning(..) + | Keyword::Increment + | Keyword::Specialize(..) + | Keyword::Offering(..) + | Keyword::Unknown(..) => {} + } +} + +fn walk_protection_target( + target: &mut ProtectionTarget, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + match target { + ProtectionTarget::Color(color) => cursor.color(category, color), + // CR 702.16a: a filter-quality protection may embed a color/type predicate. + ProtectionTarget::Filter(filter) => walk_target_filter(filter, category, cursor), + ProtectionTarget::CardType(..) + | ProtectionTarget::Quality(..) + | ProtectionTarget::Multicolored + | ProtectionTarget::ChosenColor + | ProtectionTarget::ChosenCardType + | ProtectionTarget::Everything + | ProtectionTarget::FromPlayer(..) => {} + } +} + +fn walk_hexproof_filter( + filter: &mut HexproofFilter, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + match filter { + HexproofFilter::Color(color) => cursor.color(category, color), + HexproofFilter::CardType(..) + | HexproofFilter::Quality(..) + | HexproofFilter::ChosenColor => {} + } +} + +fn walk_target_filter( + filter: &mut TargetFilter, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + match filter { + TargetFilter::Typed(typed) => walk_typed_filter(typed, category, cursor), + TargetFilter::Not { filter } => walk_target_filter(filter, category, cursor), + TargetFilter::Or { filters } | TargetFilter::And { filters } => { + for f in filters.iter_mut() { + walk_target_filter(f, category, cursor); + } + } + TargetFilter::None + | TargetFilter::Any + | TargetFilter::Player + | TargetFilter::Controller + | TargetFilter::SelfRef + | TargetFilter::GrantingObject + | TargetFilter::SourceOrPaired + | TargetFilter::StackAbility { .. } + | TargetFilter::StackSpell + | TargetFilter::SpecificObject { .. } + | TargetFilter::SpecificPlayer { .. } + | TargetFilter::PlayerWhoChoseLabel { .. } + | TargetFilter::Neighbor { .. } + | TargetFilter::ScopedPlayer + | TargetFilter::AttachedTo + | TargetFilter::LastCreated + | TargetFilter::LastRevealed + | TargetFilter::CostPaidObject + | TargetFilter::ChosenCard + | TargetFilter::TrackedSet { .. } + | TargetFilter::TrackedSetFiltered { .. } + | TargetFilter::ExiledBySource + | TargetFilter::ExiledCardByIndex { .. } + | TargetFilter::TriggeringSpellController + | TargetFilter::TriggeringSpellOwner + | TargetFilter::TriggeringPlayer + | TargetFilter::TriggeringSource + | TargetFilter::EventTarget + | TargetFilter::TriggeringSourceController + | TargetFilter::ParentTarget + | TargetFilter::ParentTargetSlot { .. } + | TargetFilter::ParentTargetController + | TargetFilter::ParentTargetOwner + | TargetFilter::SourceChosenPlayer + | TargetFilter::OriginalController + | TargetFilter::OriginalSource + | TargetFilter::PostReplacementSourceController + | TargetFilter::PostReplacementDamageTarget + | TargetFilter::PostReplacementDamageTargetOwner + | TargetFilter::DefendingPlayer + | TargetFilter::HasChosenName + | TargetFilter::ChosenDamageSource { .. } + | TargetFilter::Named { .. } + | TargetFilter::Owner + | TargetFilter::AllPlayers => {} + } +} + +fn walk_typed_filter(typed: &mut TypedFilter, category: TextWordCategory, cursor: &mut WordCursor) { + for tf in typed.type_filters.iter_mut() { + walk_type_filter(tf, category, cursor); + } + for prop in typed.properties.iter_mut() { + walk_filter_prop(prop, category, cursor); + } +} + +fn walk_type_filter(filter: &mut TypeFilter, category: TextWordCategory, cursor: &mut WordCursor) { + match filter { + // CR 205.3: a subtype token may be a land type or creature type. + TypeFilter::Subtype(s) => cursor.subtype(category, s), + TypeFilter::Non(inner) => walk_type_filter(inner, category, cursor), + TypeFilter::AnyOf(inner) => { + for f in inner.iter_mut() { + walk_type_filter(f, category, cursor); + } + } + TypeFilter::Creature + | TypeFilter::Land + | TypeFilter::Artifact + | TypeFilter::Enchantment + | TypeFilter::Instant + | TypeFilter::Sorcery + | TypeFilter::Planeswalker + | TypeFilter::Battle + | TypeFilter::Kindred + | TypeFilter::Permanent + | TypeFilter::Card + | TypeFilter::Any => {} + } +} + +fn walk_filter_prop(prop: &mut FilterProp, category: TextWordCategory, cursor: &mut WordCursor) { + match prop { + // CR 105 color-word predicates. + FilterProp::HasColor { color } => cursor.color(category, color), + FilterProp::NotColor { color } => cursor.color(category, color), + FilterProp::CanEnchant { target } => walk_target_filter(target, category, cursor), + FilterProp::AnyOf { props } => { + for fp in props.iter_mut() { + walk_filter_prop(fp, category, cursor); + } + } + FilterProp::Not { prop } => walk_filter_prop(prop, category, cursor), + FilterProp::WithKeyword { value } | FilterProp::WithoutKeyword { value } => { + walk_keyword(value, category, cursor) + } + FilterProp::Counters { count, .. } => walk_quantity_expr(count, category, cursor), + FilterProp::Cmc { value, .. } => walk_quantity_expr(value, category, cursor), + FilterProp::PtComparison { value, .. } => walk_quantity_expr(value, category, cursor), + // CR 612.2 + CR 107.4: `ColorCount` / `ManaSymbolCount` measure set size or + // mana pips, not color WORDS — not text-changed. `IsChosenColor` reads a + // chosen ref, not a printed word. + FilterProp::Token + | FilterProp::NonToken + | FilterProp::ControllerChoseLabel { .. } + | FilterProp::ControllerMatches { .. } + | FilterProp::WasPlayed + | FilterProp::Attacking { .. } + | FilterProp::Blocking + | FilterProp::BlockingSource + | FilterProp::CombatRelation { .. } + | FilterProp::Unblocked + | FilterProp::AttackingAlone + | FilterProp::BlockingAlone + | FilterProp::Tapped + | FilterProp::Untapped + | FilterProp::IsSaddled + | FilterProp::SaddledSource + | FilterProp::ConvokedSource + | FilterProp::ProtectorMatches { .. } + | FilterProp::HasHasteOrControlledSinceTurnBegan + | FilterProp::HasKeywordKind { .. } + | FilterProp::WithoutKeywordKind { .. } + | FilterProp::ManaValueParity { .. } + | FilterProp::ManaCostIn { .. } + | FilterProp::InZone { .. } + | FilterProp::Owned { .. } + | FilterProp::Foretold + | FilterProp::EnchantedBy + | FilterProp::EquippedBy + | FilterProp::AttachedToSource + | FilterProp::AttachedToRecipient + | FilterProp::HasAttachment { .. } + | FilterProp::HasAnyAttachmentOf { .. } + | FilterProp::Another + | FilterProp::Unpaired + | FilterProp::OtherThanTriggerObject + | FilterProp::PowerGTSource + | FilterProp::ColorCount { .. } + | FilterProp::ManaSymbolCount { .. } + | FilterProp::HasSupertype { .. } + | FilterProp::IsChosenCreatureType + | FilterProp::MostPrevalentCreatureTypeIn { .. } + | FilterProp::IsChosenColor + | FilterProp::IsChosenCardType + | FilterProp::MatchesLastChosenCardPredicate + | FilterProp::HasSingleTarget + | FilterProp::Modal + | FilterProp::NotSupertype { .. } + | FilterProp::Suspected + | FilterProp::Renowned + | FilterProp::ToughnessGTPower + | FilterProp::PowerExceedsBase + | FilterProp::InTrackedSet { .. } + | FilterProp::Modified + | FilterProp::Historic + | FilterProp::NotHistoric + | FilterProp::DifferentNameFrom { .. } + | FilterProp::DistinctFrom { .. } + | FilterProp::InAnyZone { .. } + | FilterProp::SharesQuality { .. } + | FilterProp::WasDealtDamageThisTurn + | FilterProp::EnteredThisTurn + | FilterProp::ControlledContinuouslySinceTurnBegan + | FilterProp::ZoneChangedThisTurn { .. } + | FilterProp::AttackedThisTurn { .. } + | FilterProp::BlockedThisTurn + | FilterProp::AttackedOrBlockedThisTurn + | FilterProp::CountersPutOnThisTurn { .. } + | FilterProp::FaceDown + | FilterProp::Transformed + | FilterProp::TargetsOnly { .. } + | FilterProp::Targets { .. } + | FilterProp::CouldBeTargetedByTriggeringSpell + | FilterProp::HasXInManaCost + | FilterProp::HasXInActivationCost + | FilterProp::WasKicked + | FilterProp::HasManaAbility + | FilterProp::HasNoAbilities + | FilterProp::Named { .. } + | FilterProp::SameName + | FilterProp::SameNameAsParentTarget + | FilterProp::NameMatchesAnyPermanent { .. } + | FilterProp::IsCommander + | FilterProp::SharesCreatureTypeWithCommander + // CR 612.2: structural "represented by a card" predicate — no color/land/ + // creature-type word to change. + | FilterProp::RepresentedByCard + | FilterProp::Other { .. } => {} + } +} + +fn walk_static_condition( + condition: &mut StaticCondition, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + match condition { + // CR 700.5: devotion text spells the color WORD (contrast the {R}-pip no-op). + StaticCondition::DevotionGE { colors, .. } => { + for c in colors.iter_mut() { + cursor.color(category, c); + } + } + StaticCondition::QuantityComparison { lhs, rhs, .. } => { + walk_quantity_expr(lhs, category, cursor); + walk_quantity_expr(rhs, category, cursor); + } + StaticCondition::And { conditions } | StaticCondition::Or { conditions } => { + for c in conditions.iter_mut() { + walk_static_condition(c, category, cursor); + } + } + StaticCondition::Not { condition } => walk_static_condition(condition, category, cursor), + StaticCondition::IsPresent { filter } => { + if let Some(f) = filter { + walk_target_filter(f, category, cursor); + } + } + StaticCondition::DefendingPlayerControls { filter } + | StaticCondition::SourceMatchesFilter { filter } + | StaticCondition::TopOfLibraryMatches { filter } + | StaticCondition::RecipientMatchesFilter { filter } => { + walk_target_filter(filter, category, cursor) + } + StaticCondition::ChosenColorIs { .. } + | StaticCondition::ChosenLabelIs { .. } + | StaticCondition::HasMaxSpeed + | StaticCondition::SpeedGE { .. } + | StaticCondition::DayNightIs { .. } + | StaticCondition::HasCounters { .. } + | StaticCondition::CastVariantPaid { .. } + | StaticCondition::RecipientHasCounters { .. } + | StaticCondition::ClassLevelGE { .. } + | StaticCondition::SourceAttackingAlone + | StaticCondition::SourceIsAttacking + | StaticCondition::SourceIsBlocking + | StaticCondition::SourceIsBlocked + | StaticCondition::IsMonarch + | StaticCondition::IsInitiative + | StaticCondition::NoMonarch + | StaticCondition::HasCityBlessing + | StaticCondition::CompletedADungeon + | StaticCondition::WasStartingPlayer { .. } + | StaticCondition::SpellCastWithVariantThisTurn { .. } + | StaticCondition::OpponentPoisonAtLeast { .. } + | StaticCondition::UnlessPay { .. } + | StaticCondition::Unrecognized { .. } + | StaticCondition::DuringYourTurn + | StaticCondition::SharesColorWithMostCommonColorAmongPermanents + | StaticCondition::SourceEnteredThisTurn + | StaticCondition::SourceHasDealtDamage + | StaticCondition::WasCast { .. } + | StaticCondition::IsRingBearer + | StaticCondition::RingLevelAtLeast { .. } + | StaticCondition::ControlsCommander { .. } + | StaticCondition::SourceIsTapped + | StaticCondition::IsTapped { .. } + | StaticCondition::SourceIsFaceUp + | StaticCondition::SourceIsSaddled + | StaticCondition::SourceControllerEquals { .. } + | StaticCondition::SourceIsEquipped + | StaticCondition::SourceIsEnchanted + | StaticCondition::SourceIsMonstrous + | StaticCondition::SourceIsHarnessed + | StaticCondition::SourceAttachedToCreature + | StaticCondition::RecipientAttackingOwnerTarget { .. } + | StaticCondition::SourceIsPaired + | StaticCondition::SourceInZone { .. } + | StaticCondition::EnchantedIsFaceDown + | StaticCondition::AdditionalCostPaid + | StaticCondition::CastingAsVariant { .. } + | StaticCondition::None => {} + } +} + +fn walk_quantity_expr( + expr: &mut QuantityExpr, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + match expr { + QuantityExpr::Ref { qty } => walk_quantity_ref(qty, category, cursor), + QuantityExpr::Fixed { .. } => {} + QuantityExpr::DivideRounded { inner, .. } + | QuantityExpr::Offset { inner, .. } + | QuantityExpr::ClampMin { inner, .. } + | QuantityExpr::Multiply { inner, .. } => walk_quantity_expr(inner, category, cursor), + QuantityExpr::UpTo { max } => walk_quantity_expr(max, category, cursor), + QuantityExpr::Power { exponent, .. } => walk_quantity_expr(exponent, category, cursor), + QuantityExpr::Difference { left, right } => { + walk_quantity_expr(left, category, cursor); + walk_quantity_expr(right, category, cursor); + } + QuantityExpr::Sum { exprs } | QuantityExpr::Max { exprs } => { + for e in exprs.iter_mut() { + walk_quantity_expr(e, category, cursor); + } + } + } +} + +fn walk_quantity_ref(qty: &mut QuantityRef, category: TextWordCategory, cursor: &mut WordCursor) { + match qty { + // CR 700.5: devotion to fixed colors spells color WORDS. + QuantityRef::Devotion { colors } => match colors { + DevotionColors::Fixed(v) => { + for c in v.iter_mut() { + cursor.color(category, c); + } + } + DevotionColors::ChosenColor => {} + }, + QuantityRef::ObjectCount { filter } + | QuantityRef::ObjectCountDistinct { filter, .. } + | QuantityRef::ObjectCountBySharedQuality { filter, .. } + | QuantityRef::CountersOnObjects { filter, .. } + | QuantityRef::ControlledByEachPlayer { filter, .. } + | QuantityRef::EnteredThisTurn { filter } + | QuantityRef::SacrificedThisTurn { filter, .. } + | QuantityRef::BattlefieldEntriesThisTurn { filter, .. } + | QuantityRef::ZoneChangeCountThisTurn { filter, .. } + | QuantityRef::TokensCreatedThisTurn { filter, .. } + | QuantityRef::DistinctColorsAmongPermanents { filter } + | QuantityRef::DistinctCounterKindsAmong { filter } => { + walk_target_filter(filter, category, cursor) + } + QuantityRef::Aggregate { + filter, property, .. + } + | QuantityRef::ZoneChangeAggregateThisTurn { + filter, property, .. + } => { + walk_target_filter(filter, category, cursor); + walk_object_property(property, category, cursor); + } + QuantityRef::TrackedSetAggregate { property, .. } => { + walk_object_property(property, category, cursor) + } + QuantityRef::CounterAddedThisTurn { target, .. } => { + walk_target_filter(target, category, cursor) + } + // Box fields need an explicit deref (a `|`-group binding + // cannot mix `&mut TargetFilter` with `&mut Box`). + QuantityRef::FilteredTrackedSetSize { filter, .. } + | QuantityRef::TargetObjectManaValue { filter } => { + walk_target_filter(filter, category, cursor) + } + QuantityRef::DamageDealtThisTurn { source, target, .. } => { + walk_target_filter(source, category, cursor); + walk_target_filter(target, category, cursor); + } + QuantityRef::ZoneCardCount { + card_types, filter, .. + } => { + for tf in card_types.iter_mut() { + walk_type_filter(tf, category, cursor); + } + if let Some(f) = filter { + walk_target_filter(f, category, cursor); + } + } + QuantityRef::SpellsCastThisTurn { filter, .. } + | QuantityRef::AttackedThisTurn { filter, .. } + | QuantityRef::SpellsCastThisGame { filter, .. } => { + if let Some(f) = filter { + walk_target_filter(f, category, cursor); + } + } + QuantityRef::HandSize { .. } + | QuantityRef::LifeTotal { .. } + | QuantityRef::GraveyardSize { .. } + | QuantityRef::LifeAboveStarting + | QuantityRef::StartingLifeTotal + | QuantityRef::TriggeringDiscoverValue + | QuantityRef::PlayerCount { .. } + | QuantityRef::CountersOn { .. } + | QuantityRef::PlayerCounter { .. } + | QuantityRef::TargetControllerCounter { .. } + | QuantityRef::Variable { .. } + | QuantityRef::Power { .. } + | QuantityRef::Intensity { .. } + | QuantityRef::Toughness { .. } + | QuantityRef::ObjectManaValue { .. } + | QuantityRef::ObjectColorCount { .. } + | QuantityRef::ObjectNameWordCount { .. } + | QuantityRef::ObjectTypelineComponentCount { .. } + | QuantityRef::ManaSymbolsInManaCost { .. } + | QuantityRef::SelfManaValue + | QuantityRef::TargetZoneCardCount { .. } + | QuantityRef::DistinctCardTypes { .. } + | QuantityRef::DistinctSubtypes { .. } + | QuantityRef::CardsExiledBySource + | QuantityRef::ExiledCardPower { .. } + | QuantityRef::BasicLandTypeCount { .. } + | QuantityRef::TrackedSetSize + | QuantityRef::ExiledFromHandThisResolution + | QuantityRef::PreviousEffectAmount { .. } + | QuantityRef::LifeLostThisTurn { .. } + | QuantityRef::PartySize { .. } + | QuantityRef::UnspentMana { .. } + | QuantityRef::Speed { .. } + | QuantityRef::EventContextAmount + | QuantityRef::AttachmentsOnLeavingObject { .. } + | QuantityRef::EventContextSourceCostX + | QuantityRef::CrimesCommittedThisTurn + | QuantityRef::BendTypesThisTurn + | QuantityRef::LifeGainedThisTurn { .. } + | QuantityRef::CardsDrawnThisTurn { .. } + | QuantityRef::LandsPlayedThisTurn { .. } + | QuantityRef::TurnsTaken + | QuantityRef::ChosenNumber + | QuantityRef::DescendedThisTurn + | QuantityRef::LoyaltyAbilitiesActivatedThisTurn { .. } + | QuantityRef::SpellsCastLastTurn + | QuantityRef::CardsDiscardedThisTurn { .. } + | QuantityRef::PlayerActionsThisTurn { .. } + | QuantityRef::DungeonsCompleted + | QuantityRef::CostXPaid + | QuantityRef::KickerCount + | QuantityRef::AdditionalCostPaymentCount + | QuantityRef::AdditionalCostPaymentCountFor { .. } + | QuantityRef::ConvokedCreatureCount + | QuantityRef::TimesCostPaidThisResolution + | QuantityRef::ManaSpentToCast { .. } + | QuantityRef::ColorsInCommandersColorIdentity + | QuantityRef::CommanderCastFromCommandZoneCount + | QuantityRef::CommanderManaValue { .. } + | QuantityRef::VoteCount { .. } => {} + } +} + +/// CR 612.2 + CR 107.4: object properties reference power/toughness/mana value or +/// a mana SYMBOL count — none is a color/land/creature WORD. All no-op; exists so +/// a future word-bearing `ObjectProperty` variant must be classified. +fn walk_object_property( + property: &mut ObjectProperty, + _category: TextWordCategory, + _cursor: &mut WordCursor, +) { + match property { + ObjectProperty::Power + | ObjectProperty::Toughness + | ObjectProperty::ManaValue + | ObjectProperty::ManaSymbolCount(..) => {} + } +} + +fn walk_ability_definition( + ability: &mut AbilityDefinition, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + walk_effect(&mut ability.effect, category, cursor); + if let Some(sub) = &mut ability.sub_ability { + walk_ability_definition(sub, category, cursor); + } + if let Some(else_ability) = &mut ability.else_ability { + walk_ability_definition(else_ability, category, cursor); + } + for mode in ability.mode_abilities.iter_mut() { + walk_ability_definition(mode, category, cursor); + } + if let Some(repeat) = &mut ability.repeat_for { + walk_quantity_expr(repeat, category, cursor); + } +} + +fn walk_trigger_definition( + trigger: &mut TriggerDefinition, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + if let Some(execute) = &mut trigger.execute { + walk_ability_definition(execute, category, cursor); + } + if let Some(valid_card) = &mut trigger.valid_card { + walk_target_filter(valid_card, category, cursor); + } +} + +fn walk_static_definition( + static_def: &mut StaticDefinition, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + if let Some(affected) = &mut static_def.affected { + walk_target_filter(affected, category, cursor); + } + if let Some(condition) = &mut static_def.condition { + walk_static_condition(condition, category, cursor); + } + for modification in static_def.modifications.iter_mut() { + walk_continuous_modification(modification, category, cursor); + } +} + +fn walk_continuous_modification( + modification: &mut ContinuousModification, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + match modification { + ContinuousModification::SetColor { colors } => { + for c in colors.iter_mut() { + cursor.color(category, c); + } + } + ContinuousModification::AddColor { color } => cursor.color(category, color), + ContinuousModification::SetBasicLandType { land_type } => { + cursor.basic_land_type(category, land_type) + } + ContinuousModification::AddSubtype { subtype } + | ContinuousModification::RemoveSubtype { subtype } => cursor.subtype(category, subtype), + ContinuousModification::AddKeyword { keyword } + | ContinuousModification::RemoveKeyword { keyword } => { + walk_keyword(keyword, category, cursor) + } + ContinuousModification::GrantAbility { definition } => { + walk_ability_definition(definition, category, cursor) + } + ContinuousModification::GrantStaticAbility { definition } => { + walk_static_definition(definition, category, cursor) + } + ContinuousModification::GrantTrigger { trigger } => { + walk_trigger_definition(trigger, category, cursor) + } + ContinuousModification::GrantAllActivatedAbilitiesOf { source, .. } + | ContinuousModification::GrantAllTriggeredAbilitiesOf { source } => { + walk_target_filter(source, category, cursor) + } + ContinuousModification::SetDynamicPower { value } + | ContinuousModification::SetDynamicToughness { value } + | ContinuousModification::SetPowerDynamic { value } + | ContinuousModification::SetToughnessDynamic { value } + | ContinuousModification::AddDynamicPower { value } + | ContinuousModification::AddDynamicToughness { value } + | ContinuousModification::AddDynamicKeyword { value, .. } => { + walk_quantity_expr(value, category, cursor) + } + ContinuousModification::CopyValues { .. } + | ContinuousModification::SetName { .. } + | ContinuousModification::AddPower { .. } + | ContinuousModification::AddToughness { .. } + | ContinuousModification::SetPower { .. } + | ContinuousModification::SetToughness { .. } + | ContinuousModification::RemoveAllAbilities + | ContinuousModification::AddType { .. } + | ContinuousModification::RemoveType { .. } + | ContinuousModification::SetCardTypes { .. } + | ContinuousModification::RemoveAllSubtypes { .. } + | ContinuousModification::AddKeywordWithDerivedCost { .. } + | ContinuousModification::AddAllCreatureTypes + | ContinuousModification::AddAllBasicLandTypes + | ContinuousModification::AddAllLandTypes + | ContinuousModification::AddChosenSubtype { .. } + | ContinuousModification::AddChosenColor { .. } + | ContinuousModification::RemoveChosenKeyword + | ContinuousModification::AddChosenKeyword + | ContinuousModification::AddStaticMode { .. } + | ContinuousModification::SwitchPowerToughness + | ContinuousModification::AssignDamageFromToughness + | ContinuousModification::AssignDamageAsThoughUnblocked + | ContinuousModification::AssignNoCombatDamage + | ContinuousModification::ChangeController + | ContinuousModification::SetChosenBasicLandType + | ContinuousModification::SetChosenName + // CR 612.1: a nested text-word replacement carries concrete operands, not + // printed words used as words on this object. + | ContinuousModification::ReplaceTextWord { .. } + | ContinuousModification::RetainPrintedTriggerFromSource { .. } + | ContinuousModification::RetainPrintedAbilityFromSource { .. } + | ContinuousModification::AddSupertype { .. } + | ContinuousModification::RemoveSupertype { .. } + | ContinuousModification::AddCounterOnEnter { .. } + | ContinuousModification::SetStartingLoyalty { .. } + | ContinuousModification::RemoveManaCost => {} + } +} + +/// CR 612.1: Walk the word-bearing children of an ability's effect. Descends into +/// nested-ability composites (so granted statics/keywords/subtype filters are +/// reached) and the two nested-effect replacement builders. +/// +/// CR 612.2: a color word / creature type / land type can also live inside a +/// leaf effect's own target/source `TargetFilter` (e.g. "{T}: Destroy target red +/// creature", "Pump target Zombie", a "target Zombie ... gains ..." grant). That +/// instance is reached first, through the shared [`Effect::target_filter_mut`] +/// accessor + the same [`walk_target_filter`] traversal used for +/// `StaticDefinition.affected` — so a text-changing effect offers and rewrites it +/// too. `target_filter_mut` classification mirrors `Effect::target_filter`, so +/// mass-population filters (`DestroyAll`/`PumpAll`/etc.) and the alternate +/// `CopyTokenOf`/`Token` owner axis are surfaced only where that targeting-layer +/// accessor surfaces them; no covered card changes a word inside a mass filter, +/// and coverage there stays red rather than silently mis-substituting. +fn walk_effect(effect: &mut Effect, category: TextWordCategory, cursor: &mut WordCursor) { + // Reach a word embedded in this effect's own declared target/source filter + // before dispatching the structural / nested-ability arms below. The borrow + // ends with the `if let`, so the `match effect` re-borrow is disjoint. + if let Some(filter) = effect.target_filter_mut() { + walk_target_filter(filter, category, cursor); + } + match effect { + Effect::CreateDrawReplacement { replacement_effect } + | Effect::CreatePlaneswalkReplacement { replacement_effect } => { + walk_effect(replacement_effect, category, cursor) + } + Effect::CreateDelayedTrigger { effect, .. } => { + walk_ability_definition(effect, category, cursor) + } + Effect::FlipCoin { + win_effect, + lose_effect, + .. + } + | Effect::FlipCoins { + win_effect, + lose_effect, + .. + } => { + if let Some(w) = win_effect { + walk_ability_definition(w, category, cursor); + } + if let Some(l) = lose_effect { + walk_ability_definition(l, category, cursor); + } + } + Effect::FlipCoinUntilLose { win_effect } => { + walk_ability_definition(win_effect, category, cursor) + } + Effect::RollDie { results, .. } => { + for branch in results.iter_mut() { + walk_ability_definition(&mut branch.effect, category, cursor); + } + } + Effect::ChooseOneOf { branches, .. } => { + for branch in branches.iter_mut() { + walk_ability_definition(branch, category, cursor); + } + } + Effect::Vote { + per_choice_effect, .. + } => { + for sub in per_choice_effect.iter_mut() { + walk_ability_definition(sub, category, cursor); + } + } + Effect::SeparateIntoPiles { + chosen_pile_effect, + unchosen_pile_effect, + .. + } => { + walk_ability_definition(chosen_pile_effect, category, cursor); + if let Some(unchosen) = unchosen_pile_effect { + walk_ability_definition(unchosen, category, cursor); + } + } + Effect::RevealFromHand { on_decline, .. } => { + if let Some(sub) = on_decline { + walk_ability_definition(sub, category, cursor); + } + } + Effect::GenericEffect { + static_abilities, .. + } + | Effect::Token { + static_abilities, .. + } => { + for static_def in static_abilities.iter_mut() { + walk_static_definition(static_def, category, cursor); + } + } + Effect::CreateEmblem { statics, triggers } => { + for static_def in statics.iter_mut() { + walk_static_definition(static_def, category, cursor); + } + for trigger in triggers.iter_mut() { + walk_trigger_definition(trigger, category, cursor); + } + } + Effect::ChangeTextWords { .. } + | Effect::StartYourEngines { .. } + | Effect::ChangeSpeed { .. } + | Effect::DealDamage { .. } + | Effect::ApplyPostReplacementDamage { .. } + | Effect::EachDealsDamageEqualToPower { .. } + | Effect::EachSourceDealsDamage { .. } + | Effect::Draw { .. } + | Effect::Pump { .. } + | Effect::PairWith { .. } + | Effect::Destroy { .. } + | Effect::Regenerate { .. } + | Effect::RemoveAllDamage { .. } + | Effect::Counter { .. } + | Effect::CounterAll { .. } + | Effect::GainLife { .. } + | Effect::LoseLife { .. } + | Effect::SetTapState { .. } + | Effect::RemoveCounter { .. } + | Effect::Sacrifice { .. } + | Effect::DiscardCard { .. } + | Effect::Mill { .. } + | Effect::Scry { .. } + | Effect::PumpAll { .. } + | Effect::DamageAll { .. } + | Effect::DamageEachPlayer { .. } + | Effect::DestroyAll { .. } + | Effect::ChangeZone { .. } + | Effect::ChangeZoneAll { .. } + | Effect::Dig { .. } + | Effect::GainControl { .. } + | Effect::GainControlAll { .. } + | Effect::ControlNextTurn { .. } + | Effect::Attach { .. } + | Effect::UnattachAll { .. } + | Effect::Surveil { .. } + | Effect::Fight { .. } + | Effect::Bounce { .. } + | Effect::BounceAll { .. } + | Effect::Explore + | Effect::ExploreAll { .. } + | Effect::Investigate + | Effect::Tribute { .. } + | Effect::TimeTravel + | Effect::BecomeMonarch + | Effect::NoOp + | Effect::Proliferate + | Effect::ProliferateTarget { .. } + | Effect::Populate + | Effect::Clash + | Effect::Behold { .. } + | Effect::EndTheTurn + | Effect::EndCombatPhase + | Effect::SwitchPT { .. } + | Effect::CopySpell { .. } + | Effect::EpicCopy { .. } + | Effect::CastCopyOfCard { .. } + | Effect::CopyTokenOf { .. } + | Effect::CreateTokenCopyFromPool { .. } + | Effect::Myriad + | Effect::Encore + | Effect::CombineHost { .. } + | Effect::ChooseAugmentAndCombineWithHost { .. } + | Effect::Meld { .. } + | Effect::ExileHaunting { .. } + | Effect::HideawayConceal { .. } + | Effect::CopyTokenBlockingAttacker { .. } + | Effect::BecomeCopy { .. } + | Effect::GainActivatedAbilitiesOfTarget { .. } + | Effect::ChooseCard { .. } + | Effect::PutCounter { .. } + | Effect::ChooseCounterKind { .. } + | Effect::PutChosenCounter { .. } + | Effect::PutCounterAll { .. } + | Effect::MultiplyCounter { .. } + | Effect::ChooseCounterAdjustment { .. } + | Effect::DoublePT { .. } + | Effect::DoublePTAll { .. } + | Effect::MoveCounters { .. } + | Effect::Animate { .. } + | Effect::ReturnAsAura { .. } + | Effect::RegisterBending { .. } + | Effect::Cleanup { .. } + | Effect::Mana { .. } + | Effect::Discard { .. } + | Effect::Shuffle { .. } + | Effect::Transform { .. } + | Effect::SearchLibrary { .. } + | Effect::SearchOutsideGame { .. } + | Effect::RevealHand { .. } + | Effect::Reveal { .. } + | Effect::RevealTop { .. } + | Effect::ExileTop { .. } + | Effect::TargetOnly { .. } + | Effect::Choose { .. } + | Effect::OpponentGuess { .. } + | Effect::SwapChosenLabels { .. } + | Effect::ChooseDamageSource { .. } + | Effect::Suspect { .. } + | Effect::Unsuspect { .. } + | Effect::Connive { .. } + | Effect::PhaseOut { .. } + | Effect::PhaseIn { .. } + | Effect::ForceBlock { .. } + | Effect::ForceAttack { .. } + | Effect::SolveCase + | Effect::BecomePrepared { .. } + | Effect::BecomeUnprepared { .. } + | Effect::BecomeSaddled { .. } + | Effect::SetClassLevel { .. } + | Effect::AddTargetReplacement { .. } + | Effect::AddRestriction { .. } + | Effect::ReduceNextSpellCost { .. } + | Effect::GrantNextSpellAbility { .. } + | Effect::AddPendingETBCounters { .. } + | Effect::AddPendingEntersModifications { .. } + | Effect::PayCost { .. } + | Effect::CastFromZone { .. } + | Effect::FreeCastFromZones { .. } + | Effect::ExileResolvingSpellInsteadOfGraveyard { .. } + | Effect::PreventDamage { .. } + | Effect::CreateDamageReplacement { .. } + | Effect::LoseTheGame { .. } + | Effect::WinTheGame { .. } + | Effect::RingTemptsYou + | Effect::VentureIntoDungeon + | Effect::VentureInto { .. } + | Effect::TakeTheInitiative + | Effect::Planeswalk + | Effect::ChaosEnsues + | Effect::ReverseTurnOrder + | Effect::RedistributeLifeTotals + | Effect::OpenAttractions { .. } + | Effect::RollToVisitAttractions + | Effect::AssembleContraptions { .. } + | Effect::AssembleContraptionsFromRollDifference + | Effect::CrankContraptions { .. } + | Effect::ReassembleContraption { .. } + | Effect::AssembleContraptionOnSprocket { .. } + | Effect::ReassembleContraptionOnSprocket { .. } + | Effect::PutSticker { .. } + | Effect::ApplySticker { .. } + | Effect::ProcessRadCounters + | Effect::GrantCastingPermission { .. } + | Effect::ChooseFromZone { .. } + | Effect::RememberCard { .. } + | Effect::ForEachCategory { .. } + | Effect::ChooseObjectsIntoTrackedSet { .. } + | Effect::ChooseAndSacrificeRest { .. } + | Effect::EachPlayerCopyChosen { .. } + | Effect::Exploit { .. } + | Effect::GainEnergy { .. } + | Effect::GivePlayerCounter { .. } + | Effect::LoseAllPlayerCounters { .. } + | Effect::ExileFromTopUntil { .. } + | Effect::RevealUntil { .. } + | Effect::Discover { .. } + | Effect::Heist { .. } + | Effect::HeistExile + | Effect::Cascade + | Effect::Ripple { .. } + | Effect::MiracleCast { .. } + | Effect::MadnessCast { .. } + | Effect::PutAtLibraryPosition { .. } + | Effect::ChooseDrawnThisTurnPayOrTopdeck { .. } + | Effect::PutOnTopOrBottom { .. } + | Effect::GiftDelivery { .. } + | Effect::Goad { .. } + | Effect::GoadAll { .. } + | Effect::Detain { .. } + | Effect::SetRoomDoorLock { .. } + | Effect::ExchangeControl { .. } + | Effect::ChangeTargets { .. } + | Effect::Manifest { .. } + | Effect::ManifestDread + | Effect::Cloak { .. } + | Effect::TurnFaceUp { .. } + | Effect::TurnFaceDown { .. } + | Effect::ExtraTurn { .. } + | Effect::GrantExtraLoyaltyActivations { .. } + | Effect::SkipNextTurn { .. } + | Effect::SkipNextStep { .. } + | Effect::AdditionalPhase { .. } + | Effect::Double { .. } + | Effect::RuntimeHandled { .. } + | Effect::Incubate { .. } + | Effect::Amass { .. } + | Effect::Monstrosity { .. } + | Effect::Specialize + | Effect::Renown { .. } + | Effect::Bolster { .. } + | Effect::Adapt { .. } + | Effect::Learn + | Effect::Forage + | Effect::Harness + | Effect::CollectEvidence { .. } + | Effect::Endure { .. } + | Effect::BlightEffect { .. } + | Effect::Seek { .. } + | Effect::SetLifeTotal { .. } + | Effect::ExchangeLifeWithStat { .. } + | Effect::ExchangeLifeTotals { .. } + | Effect::SetDayNight { .. } + | Effect::GiveControl { .. } + | Effect::RemoveFromCombat { .. } + | Effect::BecomeBlocked { .. } + | Effect::Conjure { .. } + | Effect::ApplyPerpetual { .. } + | Effect::Intensify { .. } + | Effect::DraftFromSpellbook { .. } + | Effect::Unimplemented { .. } => {} + } +} diff --git a/crates/engine/src/game/trigger_index.rs b/crates/engine/src/game/trigger_index.rs index c7689ec503..72fce55dc8 100644 --- a/crates/engine/src/game/trigger_index.rs +++ b/crates/engine/src/game/trigger_index.rs @@ -696,6 +696,9 @@ pub(crate) fn keys_from_event(event: &GameEvent, state: &GameState) -> Keys { /// matcher in `trigger_matchers.rs` emit keys; all others are no-ops. fn keys_from_effect_kind(kind: EffectKind, push: &mut impl FnMut(TriggerEventKey)) { match kind { + // CR 612.1: text-change installs a continuous effect; no EffectResolved + // trigger matcher keys off it. + EffectKind::ChangeTextWords => {} // Production EffectResolved matchers — see `trigger_matchers.rs` lines // 1896, 2072, 2126, 2172, 2198, 2234, 2261, 2313, 2338. EffectKind::Attach | EffectKind::AttachAll | EffectKind::Equip => { diff --git a/crates/engine/src/parser/clause_shell.rs b/crates/engine/src/parser/clause_shell.rs index 527bfe31b8..54ba4a4e3c 100644 --- a/crates/engine/src/parser/clause_shell.rs +++ b/crates/engine/src/parser/clause_shell.rs @@ -428,6 +428,10 @@ fn is_specialized_duration_carrier(text_lower: &str) -> bool { // at `oracle_effect/mod.rs:2701`. value((), tag("you may play ")), value((), tag("you may cast ")), + // CR 612.1: the text-change parser consumes its own trailing duration + // ("until end of turn", Crystal Spray) as the effect's duration, so the + // shell must not pre-peel it into an outer duration wrapper. + value((), tag("change the text of ")), // CR 400.7i + CR 118.9 — Gonti, Night Minister third-person impulse // play with any-mana conjunct. Same deferral as the first-person forms. value((), tag("they may play ")), diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index e3b432c18b..94976a5d7d 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -8798,6 +8798,8 @@ fn apply_where_x_continuous_modification( | ContinuousModification::SetBasicLandType { .. } | ContinuousModification::SetChosenBasicLandType | ContinuousModification::SetChosenName + // CR 612.1: latched text-word replacement carries no where-X quantity. + | ContinuousModification::ReplaceTextWord { .. } | ContinuousModification::RetainPrintedTriggerFromSource { .. } | ContinuousModification::RetainPrintedAbilityFromSource { .. } | ContinuousModification::AddSupertype { .. } @@ -8895,6 +8897,8 @@ fn rebind_target_anaphor_continuous_modification(modification: &mut ContinuousMo | ContinuousModification::SetBasicLandType { .. } | ContinuousModification::SetChosenBasicLandType | ContinuousModification::SetChosenName + // CR 612.1: latched text-word replacement carries no target anaphor. + | ContinuousModification::ReplaceTextWord { .. } | ContinuousModification::RetainPrintedTriggerFromSource { .. } | ContinuousModification::RetainPrintedAbilityFromSource { .. } | ContinuousModification::AddSupertype { .. } diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 4935cb443c..666125ca75 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -107,8 +107,8 @@ use crate::types::ability::{ RestrictionPlayerScope, RevealUntilDisposition, RoundingMode, SharedQuality, SharedQualityRelation, SkipScope, SpellStackToGraveyardReplacement, StaticCondition, StaticDefinition, StepSkipTarget, SubAbilityLink, TapStateChange, TargetFilter, - TargetSelectionMode, ThisWayCause, TrackedAnaphorSource, TriggerCondition, TriggerDefinition, - TypeFilter, TypedFilter, UnlessPayModifier, UntilCondition, ZoneOwner, + TargetSelectionMode, TextWordCategory, ThisWayCause, TrackedAnaphorSource, TriggerCondition, + TriggerDefinition, TypeFilter, TypedFilter, UnlessPayModifier, UntilCondition, ZoneOwner, }; #[cfg(test)] use crate::types::ability::{AttackScope, AttackSubject}; @@ -7802,6 +7802,12 @@ fn parse_effect_clause_inner(text: &str, ctx: &mut ParseContext) -> ParsedEffect return parsed_clause(effect); } + // CR 612.1 + CR 612.2: "change the text of by replacing all + // instances of ..." — text-changing word replacement. + if let Some(clause) = try_parse_change_text(tp) { + return clause; + } + // "it's still a/an [type]" / "that's still a/an [type]" — type-retention clause // CR 205.1a: Retains the original type in addition to new types from animation effects if let Some(clause) = try_parse_still_a_type(tp) { @@ -9632,6 +9638,79 @@ fn try_parse_still_a_type(tp: TextPair) -> Option { }) } +/// CR 612.1 + CR 612.2: Parse "change the text of by replacing all +/// instances of [or ]" — the text-changing word-replacement +/// class (Mind Bend, Sleight of Mind, Glamerdye, Alter Reality, Magical Hack, +/// Artificial Evolution, Crystal Spray, Trait Doctoring, Whim of Volrath, +/// Spectral Shift's modes). Every axis is a single `alt()` — no permutation +/// enumeration. Composes: prefix → `parse_target` (target axis) → connector → +/// one or two categories → optional trailing duration. +fn try_parse_change_text(tp: TextPair) -> Option { + // CR 115.1: "change the text of ". Advance both cases past the prefix, + // then let `parse_target` claim the target noun phrase. + let (rest_lower, _) = tag::<_, _, OracleError<'_>>("change the text of ") + .parse(tp.lower) + .ok()?; + let prefix_len = tp.lower.len() - rest_lower.len(); + let rest_orig = &tp.original[prefix_len..]; + let (target, after_target_orig) = super::oracle_target::parse_target(rest_orig); + let after_target_lower = &rest_lower[rest_lower.len() - after_target_orig.len()..]; + + // CR 612.2: connector, then one or two category clauses. + let (rest, _) = tag::<_, _, OracleError<'_>>(" by replacing all instances of ") + .parse(after_target_lower) + .ok()?; + let (rest, first) = parse_text_word_category(rest).ok()?; + let (rest, second) = opt(preceded( + tag::<_, _, OracleError<'_>>(" or "), + parse_text_word_category, + )) + .parse(rest) + .ok()?; + + // CR 611.2b: optional trailing duration ("until end of turn"); its absence + // means the text change lasts indefinitely (Permanent) — the resolver + // defaults `None` to `Duration::Permanent`. + let rest = rest.trim_start(); + let (rest, duration) = super::oracle_nom::duration::parse_optional_duration(rest).ok()?; + if !rest.trim().trim_end_matches('.').trim().is_empty() { + return None; + } + + let mut allowed_categories = vec![first]; + if let Some(second) = second { + if second != first { + allowed_categories.push(second); + } + } + + Some(parsed_clause(Effect::ChangeTextWords { + target, + allowed_categories, + excluded_to: Vec::new(), + duration, + })) +} + +/// CR 612.2: one replaceable word category, as templated in Oracle text. +fn parse_text_word_category(input: &str) -> OracleResult<'_, TextWordCategory> { + alt(( + value( + TextWordCategory::ColorWord, + tag("one color word with another"), + ), + value( + TextWordCategory::BasicLandType, + tag("one basic land type with another"), + ), + value( + TextWordCategory::CreatureType, + tag("one creature type with another"), + ), + )) + .parse(input) +} + /// CR 614.10a: Parse "[subject] skip[s] [their|your] next [step] step[s]" — /// one-shot step skips. Handles controller and target-player forms. fn try_parse_skip_next_step(tp: TextPair, ctx: &ParseContext) -> Option { diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 46398a548a..2444476edb 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -19,14 +19,15 @@ use crate::parser::oracle_quantity::{ parse_cda_quantity, parse_event_context_quantity, parse_for_each_object_filter_clause, parse_quantity_ref, }; +use crate::types::ability::BasicLandType; use crate::types::ability::{ AbilityCondition, AbilityDefinition, AbilityKind, CastingPermission, ChoiceType, Chooser, ContinuousModification, ControllerRef, CopyRetargetPermission, CounterSourceRider, DigSource, Duration, Effect, EffectScope, ExcessRecipient, FaceDownBody, FaceDownProfile, FilterProp, ForEachCategoryAction, LibraryPosition, MultiTargetSpec, ObjectScope, PermissionGrantee, PlayerFilter, PtValue, QuantityExpr, QuantityRef, RevealUntilDisposition, - SpellStackToGraveyardReplacement, StaticDefinition, TargetChoiceTiming, TargetFilter, - TypeFilter, TypedFilter, + SpellStackToGraveyardReplacement, StaticDefinition, TargetChoiceTiming, TargetFilter, TextWord, + TextWordCategory, TypeFilter, TypedFilter, }; use crate::types::card_type::CoreType; use crate::types::counter::CounterType; @@ -4699,6 +4700,19 @@ pub(super) fn apply_clause_continuation( *existing = filter; } } + ContinuationAst::TextChangeExcludedTo { word } => { + // CR 612.2: push the excluded word onto the preceding text-changing + // effect so the resolver drops it from the `to` option set. + let Some(previous) = defs.last_mut() else { + return; + }; + if let Effect::ChangeTextWords { excluded_to, .. } = &mut *previous.effect { + // allow-noncombinator: Vec membership test, not string dispatch. + if !excluded_to.contains(&word) { + excluded_to.push(word); + } + } + } } } @@ -4877,6 +4891,10 @@ pub(super) fn continuation_absorbs_current( // pushes the conceal sub-ability — it emits no sibling def. ContinuationAst::ExileOneOfThemFaceDown { .. } => true, ContinuationAst::ChooseAndSacrificeRestFilter { .. } => true, + // CR 612.2: recognition was already gated on the preceding effect being + // ChangeTextWords in parse_followup_continuation_ast, so absorption is + // unconditional — the rider never emits a sibling effect. + ContinuationAst::TextChangeExcludedTo { .. } => true, } } @@ -5750,7 +5768,8 @@ pub(super) fn clause_is_dig_lookback_transparent(effect: &Effect) -> bool { // CR 708.2a: turning a permanent face down is its own resolving effect, // not a Dig-lookback-transparent clause. Effect::TurnFaceDown { .. } => false, - Effect::StartYourEngines { .. } + Effect::ChangeTextWords { .. } + | Effect::StartYourEngines { .. } | Effect::EpicCopy { .. } | Effect::ChangeSpeed { .. } | Effect::DealDamage { .. } @@ -6958,10 +6977,65 @@ pub(super) fn parse_followup_continuation_ast( }) .or_else(|| try_parse_token_enters_with_counters(&lower)) .or_else(|| try_parse_put_counters_on_token_followup(&lower)), + // CR 612.2 + CR 608.2c: "The new can't be ." rider on a + // text-changing effect (Artificial Evolution: "The new creature type + // can't be Wall."). Gated on the preceding effect being ChangeTextWords + // AND the excluded word belonging to a category the effect operates on, + // so the parsed word can be pushed into `excluded_to`. + Effect::ChangeTextWords { + allowed_categories, .. + } if let Some(word) = parse_text_change_excluded_to(&lower) + // allow-noncombinator: Vec membership test, not string dispatch. + .filter(|w| allowed_categories.contains(&w.category())) => + { + Some(ContinuationAst::TextChangeExcludedTo { word }) + } _ => None, } } +/// CR 612.2 + CR 608.2c: Parse "the new can't be [.]" — the excluded-`to` rider on a text-changing +/// effect. Combinator-only: `tag`/`alt`/`value` dispatch on the category phrase, +/// then the excluded word is parsed in that same category (colors via +/// `parse_color`, creature/land types via the canonical subtype matcher). One +/// `alt` per axis — no permutation enumeration. Returns the parsed [`TextWord`], +/// or `None` when the sentence is not this rider or the word is unrecognized. +fn parse_text_change_excluded_to(lower: &str) -> Option { + let (rest, _) = tag::<_, _, OracleError<'_>>("the new ").parse(lower).ok()?; + let (rest, category) = alt(( + value( + TextWordCategory::ColorWord, + alt((tag::<_, _, OracleError<'_>>("color word"), tag("color"))), + ), + value(TextWordCategory::BasicLandType, tag("basic land type")), + value(TextWordCategory::CreatureType, tag("creature type")), + )) + .parse(rest) + .ok()?; + let (rest, _) = tag::<_, _, OracleError<'_>>(" can't be ") + .parse(rest) + .ok()?; + let word = rest.trim().trim_end_matches('.').trim(); + match category { + TextWordCategory::ColorWord => { + let (_, color) = nom_primitives::parse_color(word).ok()?; + Some(TextWord::Color(color)) + } + TextWordCategory::BasicLandType => { + let (canonical, _) = crate::parser::oracle_util::parse_subtype(word)?; + canonical + .parse::() + .ok() + .map(TextWord::BasicLandType) + } + TextWordCategory::CreatureType => { + let (canonical, _) = crate::parser::oracle_util::parse_subtype(word)?; + Some(TextWord::CreatureType(canonical)) + } + } +} + fn is_reveal_until_rest_pile_clause_after_intervening_effect(lower: &str) -> bool { parse_reveal_until_rest_zone(lower).is_some() && (nom_primitives::scan_contains(lower, "that card") diff --git a/crates/engine/src/parser/oracle_ir/ast.rs b/crates/engine/src/parser/oracle_ir/ast.rs index 7402559b98..cf54b3ca11 100644 --- a/crates/engine/src/parser/oracle_ir/ast.rs +++ b/crates/engine/src/parser/oracle_ir/ast.rs @@ -7,7 +7,7 @@ use crate::types::ability::{ CounterSourceRider, DoorLockOp, Duration, Effect, FaceDownProfile, LibraryPosition, ManaProduction, ManaSpendRestriction, ModalSelectionConstraint, OutsideGameSourcePool, PlayerFilter, PtStat, PtValue, QuantityExpr, SearchDestinationSplit, SearchSelectionConstraint, - SpellStackToGraveyardReplacement, StaticCondition, StaticDefinition, TargetFilter, + SpellStackToGraveyardReplacement, StaticCondition, StaticDefinition, TargetFilter, TextWord, }; use crate::types::card_type::Supertype; use crate::types::counter::CounterType; @@ -466,6 +466,13 @@ pub(crate) enum ContinuationAst { ChooseAndSacrificeRestFilter { sacrifice_filter: Option, }, + /// CR 612.2 + CR 608.2c: "The new + /// can't be ." rider on a text-changing effect (Artificial Evolution: + /// "The new creature type can't be Wall."). Pushes `word` into the preceding + /// `Effect::ChangeTextWords.excluded_to`, so the resolver drops that word + /// from the `to` option set. Generalized across all three categories; Wall + /// (a creature type) is the only printed instance. + TextChangeExcludedTo { word: TextWord }, } /// CR 701.20e / CR 701.17c: How many cards a "from among [set]" continuation diff --git a/crates/engine/src/parser/oracle_ir/doc.rs b/crates/engine/src/parser/oracle_ir/doc.rs index bdee38ccf0..e59fa87d1e 100644 --- a/crates/engine/src/parser/oracle_ir/doc.rs +++ b/crates/engine/src/parser/oracle_ir/doc.rs @@ -1413,6 +1413,8 @@ fn stamp_effect_printed_slot(effect: &mut Effect, slot: usize, kind: PrintedItem Effect::ApplyPerpetual { .. } => {} Effect::Intensify { .. } => {} Effect::DraftFromSpellbook { .. } => {} + // CR 612.1: text-change carries no printed-slot self-reference. + Effect::ChangeTextWords { .. } => {} Effect::Unimplemented { .. } => {} } } diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 00f9a265f8..7a14d271b5 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -847,7 +847,7 @@ impl<'de> Deserialize<'de> for ChoiceType { } /// The five basic land types (CR 305.6). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum BasicLandType { Plains, Island, @@ -905,6 +905,55 @@ impl std::str::FromStr for BasicLandType { } } +/// CR 612.2: The three kinds of words a text-changing effect can replace — a +/// Magic color word, a basic land type, or a creature type — each "used in the +/// correct way". A text-changing effect operates within exactly one category so +/// that a color word is never confused with a same-spelled land type or name. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum TextWordCategory { + ColorWord, + BasicLandType, + CreatureType, +} + +/// CR 612.2: a color word, land type, or creature type used in the correct sense. +/// A single replaceable word, tagged by its category. The `from`/`to` operands of +/// a text-changing effect are `TextWord`s of the same category. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum TextWord { + Color(ManaColor), + BasicLandType(BasicLandType), + CreatureType(String), +} + +impl TextWord { + /// The category this word belongs to. + pub fn category(&self) -> TextWordCategory { + match self { + TextWord::Color(_) => TextWordCategory::ColorWord, + TextWord::BasicLandType(_) => TextWordCategory::BasicLandType, + TextWord::CreatureType(_) => TextWordCategory::CreatureType, + } + } + + /// Human-readable option label shown to the choosing player (the frontend + /// renders this string verbatim — the engine computes every label). + pub fn label(&self) -> String { + match self { + TextWord::Color(color) => match color { + ManaColor::White => "White", + ManaColor::Blue => "Blue", + ManaColor::Black => "Black", + ManaColor::Red => "Red", + ManaColor::Green => "Green", + } + .to_string(), + TextWord::BasicLandType(land) => land.as_subtype_str().to_string(), + TextWord::CreatureType(name) => name.clone(), + } + } +} + /// Odd or even — used by cards like "choose odd or even." #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Parity { @@ -9449,6 +9498,22 @@ pub enum ExiledSpellRider { #[derive(Clone, PartialEq, Eq, Serialize, Deserialize, strum::IntoStaticStr)] #[serde(tag = "type")] pub enum Effect { + /// CR 612.1: interactive text-change; the controller chooses a category ∈ + /// `allowed_categories`, a `from` word ∈ the words present in the target + /// (CR 612.2), and a `to` word ∈ that category's words minus `excluded_to`. + /// Creates a Layer-3 text-changing continuous effect keyed to the target. + /// `target` defines the spell's target slot (CR 115.1 — "target spell or + /// permanent"); the concrete chosen object is read from + /// `ResolvedAbility.targets` at resolution (CR 608.2b). + ChangeTextWords { + #[serde(default = "default_target_filter_any")] + target: TargetFilter, + allowed_categories: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + excluded_to: Vec, + #[serde(default)] + duration: Option, + }, /// CR 702.179a: A player starts their engines, setting speed to 1 if they have no speed. StartYourEngines { player_scope: PlayerFilter, @@ -13585,7 +13650,8 @@ impl Effect { pub fn target_filter(&self) -> Option<&TargetFilter> { match self { // --- Effects with a `target: TargetFilter` field --- - Effect::DealDamage { target, .. } + Effect::ChangeTextWords { target, .. } + | Effect::DealDamage { target, .. } | Effect::Draw { target, .. } | Effect::Scry { target, .. } | Effect::Surveil { target, .. } @@ -14091,132 +14157,653 @@ impl Effect { } } - /// CR 107.3 + CR 608.2c: Returns the `QuantityExpr` carrying this effect's - /// primary count/amount, for the full class of count- and amount-bearing - /// effects (token creation, counters, draws, damage, mill, discard, etc.). - /// Returns `None` for effects whose magnitude is not a `QuantityExpr` - /// (fixed structural effects, choices, zone-level operations). - /// - /// Single authority used to bind and inspect a dynamic count after an - /// effect body has been parsed — e.g. vote-tally parsing binds the - /// per-choice `QuantityRef::VoteCount` into this slot (`count_expr_mut`), - /// and `Effect::resolve_tally` reads it back (`count_expr`) to decide - /// aggregate vs. per-vote resolution. + /// CR 612.2: Mutable mirror of [`Effect::target_filter`]. Surfaces the same + /// primary target/source `TargetFilter` for in-place rewriting — used by the + /// text-substitution walker (`game::text_substitution`) to reach a color + /// word / creature type / land type that lives inside an ability's effect + /// target filter (e.g. "{T}: Destroy target red creature", "Pump target + /// Zombie") so a text-changing effect can offer and change that instance. /// - /// Exhaustive match — no wildcards — so the compiler forces an update when - /// a new count/amount-bearing Effect variant is added. - pub fn count_expr(&self) -> Option<&QuantityExpr> { + /// Arm classification is kept identical to `target_filter()` so the two stay + /// paired — edit both together. Exhaustive match (no wildcard) forces every + /// future target-bearing `Effect` variant to be classified here. + pub fn target_filter_mut(&mut self) -> Option<&mut TargetFilter> { match self { - // --- Effects whose magnitude is a `count: QuantityExpr` --- - Effect::Draw { count, .. } - | Effect::Token { count, .. } - | Effect::Sacrifice { count, .. } - | Effect::Mill { count, .. } - | Effect::Scry { count, .. } - | Effect::Dig { count, .. } - | Effect::Surveil { count, .. } - | Effect::CopyTokenOf { count, .. } - | Effect::CreateTokenCopyFromPool { count, .. } - | Effect::PutCounter { count, .. } - | Effect::PutCounterAll { count, .. } - // CR 122.1 + CR 122.6: how many counters of the chosen kind to add. - | Effect::PutChosenCounter { count, .. } - | Effect::Discard { count, .. } - | Effect::SearchLibrary { count, .. } - | Effect::SearchOutsideGame { count, .. } - | Effect::ExileTop { count, .. } - | Effect::AddPendingETBCounters { count, .. } - | Effect::RollDie { count, .. } - | Effect::FlipCoins { count, .. } - | Effect::GivePlayerCounter { count, .. } - | Effect::PutAtLibraryPosition { count, .. } - | Effect::ChooseDrawnThisTurnPayOrTopdeck { count, .. } - | Effect::Manifest { count, .. } - | Effect::Cloak { count, .. } - | Effect::SkipNextTurn { count, .. } - | Effect::SkipNextStep { count, .. } - | Effect::AdditionalPhase { count, .. } - | Effect::Incubate { count, .. } - | Effect::Amass { count, .. } - | Effect::Monstrosity { count, .. } - | Effect::Renown { count, .. } - | Effect::Bolster { count, .. } - | Effect::Adapt { count, .. } - | Effect::AssembleContraptions { count } - // CR 701.20a: how many matching cards to reveal before the - // until-loop terminates ("reveal until you reveal X [filter] cards"). - | Effect::RevealUntil { count, .. } - | Effect::Seek { count, .. } => Some(count), + // --- Effects with a `target: TargetFilter` field --- + Effect::ChangeTextWords { target, .. } + | Effect::DealDamage { target, .. } + | Effect::Draw { target, .. } + | Effect::Scry { target, .. } + | Effect::Surveil { target, .. } + | Effect::Pump { target, .. } + | Effect::RememberCard { target } + | Effect::PairWith { target } + | Effect::Destroy { target, .. } + | Effect::Regenerate { target, .. } + | Effect::RemoveAllDamage { target, .. } + | Effect::Counter { target, .. } + | Effect::RemoveCounter { target, .. } + | Effect::Sacrifice { target, .. } + | Effect::DiscardCard { target, .. } + | Effect::Mill { target, .. } + | Effect::ChangeZone { target, .. } + | Effect::GainControl { target, .. } + | Effect::ControlNextTurn { target, .. } + | Effect::Attach { target, .. } + | Effect::UnattachAll { target, .. } + | Effect::Fight { target, .. } + | Effect::Bounce { target, .. } + | Effect::SwitchPT { target, .. } + | Effect::CopySpell { target, .. } + | Effect::CastCopyOfCard { target, .. } + | Effect::BecomeCopy { target, .. } + | Effect::GainActivatedAbilitiesOfTarget { target, .. } + | Effect::ChooseCard { target, .. } + | Effect::PutCounter { target, .. } + // CR 608.2d + CR 122.1: `ChooseCounterKind`/`PutChosenCounter` + // surface their `target` (typically `ParentTarget`) so the + // member-driven `repeat_for` loop rebinds it to the i-th permanent + // per iteration (The Caves of Androzani), mirroring `PutCounter`. + | Effect::ChooseCounterKind { target, .. } + | Effect::PutChosenCounter { target, .. } + | Effect::MultiplyCounter { target, .. } + | Effect::DoublePT { target, .. } + | Effect::MoveCounters { target, .. } + | Effect::Animate { target, .. } + | Effect::Discard { target, .. } + | Effect::Shuffle { target, .. } + | Effect::Transform { target, .. } + | Effect::RevealHand { target, .. } + | Effect::Reveal { target, .. } + | Effect::TargetOnly { target, .. } + | Effect::Connive { target, .. } + | Effect::PhaseOut { target, .. } + | Effect::PhaseIn { target, .. } + | Effect::ForceBlock { target, .. } + | Effect::ForceAttack { target, .. } + | Effect::BecomePrepared { target, .. } + | Effect::BecomeUnprepared { target, .. } + | Effect::BecomeSaddled { target, .. } + | Effect::CastFromZone { target, .. } + | Effect::PreventDamage { target, .. } + | Effect::Exploit { target, .. } + | Effect::GivePlayerCounter { target, .. } + | Effect::LoseAllPlayerCounters { target, .. } + | Effect::PutAtLibraryPosition { target, .. } + | Effect::PutOnTopOrBottom { target, .. } + | Effect::Goad { target, .. } + | Effect::Detain { target, .. } + // CR 708.2a: "Turn target creature face down" (Cyber Conversion) + // declares a real target as the spell is cast; surface it so the + // cast-time target slot is built and CR 608.2b re-validates it at + // resolution. + | Effect::TurnFaceDown { target, .. } + // CR 709.5f-g: the Room is a real declared target ("target Room you + // control"); surface it so the cast/trigger-time target slot is + // built and CR 608.2b re-validates it at resolution. + | Effect::SetRoomDoorLock { target, .. } + | Effect::ExtraTurn { target, .. } + | Effect::GrantExtraLoyaltyActivations { target, .. } + | Effect::SkipNextTurn { target, .. } + | Effect::SkipNextStep { target, .. } + | Effect::AdditionalPhase { target, .. } + | Effect::Double { target, .. } + | Effect::SetLifeTotal { target, .. } + | Effect::GiveControl { target, .. } + | Effect::RemoveFromCombat { target, .. } + | Effect::BecomeBlocked { target, .. } + | Effect::PutSticker { target, .. } + | Effect::ApplySticker { target, .. } + | Effect::ProliferateTarget { target, .. } + // CR 115.7 + CR 115.1: "Change the target of target spell or ability" + // (Bolt Bend, Redirect, Misdirection) targets the stack spell/ability + // it will retarget. That target is chosen as the spell is cast (CR + // 115.1), so it must be surfaced here — both to build the cast-time + // target slot and so resolution-time re-validation (CR 608.2b) checks + // it against the StackSpell/StackAbility filter instead of the + // battlefield-only default (which would always fizzle a stack target). + | Effect::ChangeTargets { target, .. } + // CR 702.55a: Haunt — "exile it haunting target creature". The + // haunted creature is a real target chosen as the haunt trigger goes + // on the stack, so it must be surfaced for the target-slot path. + | Effect::ExileHaunting { target } => Some(target), - // --- Effects whose magnitude is an `amount: QuantityExpr` --- - Effect::ChangeSpeed { amount, .. } - | Effect::DealDamage { amount, .. } - // CR 120.1: uniform per-source damage amount. - | Effect::EachSourceDealsDamage { amount, .. } - | Effect::GainLife { amount, .. } - | Effect::LoseLife { amount, .. } - | Effect::DamageAll { amount, .. } - | Effect::DamageEachPlayer { amount, .. } - | Effect::GainEnergy { amount, .. } - | Effect::GrantExtraLoyaltyActivations { amount, .. } - | Effect::SetLifeTotal { amount, .. } - | Effect::Intensify { amount, .. } => Some(amount), + // CR 115.1 / CR 608.2c: a `Shared` recipient is resolved exactly like + // `DealDamage::target` — surface it so the same target-slot collection + // and event-context hydration build / bind the recipient. The + // `EachController` and deferred per-source recipients carry no slot and + // fall through to the `None` group below. + Effect::EachSourceDealsDamage { + recipient: EachDamageRecipient::Shared(filter), + .. + } => Some(filter), - // --- Effects whose count/amount is an `Option` --- - Effect::BounceAll { count, .. } - | Effect::MoveCounters { count, .. } - | Effect::RevealHand { count, .. } => count.as_ref(), + Effect::CombineHost { host, .. } + | Effect::ChooseAugmentAndCombineWithHost { host, .. } => Some(host.as_mut()), + Effect::CrankContraptions { target } + | Effect::ReassembleContraption { target, .. } + | Effect::ReassembleContraptionOnSprocket { target, .. } => Some(target), - // --- Effects with no QuantityExpr count/amount --- - Effect::ApplyPerpetual { .. } - // Deferred continuous-modification carrier — the mods Vec carries no - // QuantityExpr count/amount (CR 613 type grant). - | Effect::AddPendingEntersModifications { .. } - | Effect::StartYourEngines { .. } - // CR 608.2d: the counter-kind CHOICE carries no magnitude. - | Effect::ChooseCounterKind { .. } - | Effect::ApplyPostReplacementDamage { .. } - | Effect::Pump { .. } - | Effect::PairWith { .. } - | Effect::Destroy { .. } - | Effect::Regenerate { .. } - | Effect::RemoveAllDamage { .. } - | Effect::Counter { .. } - | Effect::CounterAll { .. } - // CR 701.26a/b: tap/untap carry no QuantityExpr in any scope. - | Effect::SetTapState { .. } - | Effect::RemoveCounter { .. } - | Effect::DiscardCard { .. } - | Effect::ChangeZone { .. } - | Effect::ChangeZoneAll { .. } - | Effect::GainControl { .. } - | Effect::GainControlAll { .. } - | Effect::ControlNextTurn { .. } - | Effect::Attach { .. } - | Effect::UnattachAll { .. } - | Effect::Fight { .. } - | Effect::EachDealsDamageEqualToPower { .. } - | Effect::Bounce { .. } - | Effect::Explore - | Effect::ExploreAll { .. } - | Effect::Investigate - | Effect::Tribute { .. } - | Effect::TimeTravel - | Effect::BecomeMonarch - | Effect::NoOp - | Effect::Proliferate - | Effect::ProliferateTarget { .. } - | Effect::EndTheTurn - | Effect::EndCombatPhase - | Effect::Populate - | Effect::Clash - | Effect::OpponentGuess { .. } - | Effect::Behold { .. } - | Effect::Vote { .. } - | Effect::SeparateIntoPiles { .. } - | Effect::SwitchPT { .. } - | Effect::CopySpell { .. } - | Effect::EpicCopy { .. } + // CR 702.75a: Hideaway conceal acts on the just-exiled card inherited + // from the parent `Dig` continuation (`ParentTarget`); it is never + // announced as a target, but surfacing the filter keeps chain-time + // resolution consistent. + Effect::HideawayConceal { target } => Some(target), + + // Heist targets the opponent whose library is heisted. + Effect::Heist { target, .. } => Some(target), + + // CR 109.4 + CR 115.1 + CR 707.2: `CopyTokenOf` has two + // potentially-targetable axes — the copy *source* (`target`) and + // the token *creator/owner* (`owner`). `target_filter()` surfaces + // exactly one as the stack-push target slot: + // * When the copy source is a declared target (`source_filter` is + // `None` and `target` is a real targetable filter, e.g. + // "create a token that's a copy of target creature"), the + // copy-source axis wins — it must keep its slot. + // * Otherwise the copy source is a context ref (`SelfRef` / + // `ParentTarget` — Wedding Ring, Twinflame Strike) or a + // non-targeting `source_filter` set, so the copy-source axis + // needs no slot; the `owner` filter is surfaced instead so + // "target opponent creates a token that's a copy of it" can + // declare the opponent as a target. This mirrors `Effect::Token` + // below, which surfaces its `owner` unconditionally. + // No real card targets both axes at once; if one ever exists, the + // copy-source axis is surfaced and `owner` resolution falls back to + // the controller (documented at `token::resolve_token_owner`). + Effect::CopyTokenOf { + target, + owner, + source_filter, + .. + } => { + if source_filter.is_none() && !target.is_context_ref() { + Some(target) + } else { + Some(owner) + } + } + + Effect::Dig { player, .. } + | Effect::ExileTop { player, .. } + | Effect::ExchangeLifeWithStat { player, .. } + | Effect::ExileFromTopUntil { player, .. } + // CR 119.3: `GainLife.player` is a TargetFilter. `extract_target_filter_from_effect` + // drops context-refs (Controller) via `.filter(|t| !t.is_context_ref())`, so the + // default "you gain life" still surfaces no target slot. + | Effect::GainLife { player, .. } + // CR 701.57a: Discover's discovering player. CR 701.68a: Blight's + // blighting player. Both default to `TargetFilter::Controller` (a + // context ref), so bare "discover N" / "blight N" surface no target + // slot; "Target opponent blights N" surfaces the opponent as a real + // target via the same `is_context_ref()` filter the other player-axis + // effects use. + | Effect::Discover { player, .. } + | Effect::BlightEffect { player, .. } => Some(player), + + // Digital-only Alchemy: `ApplyPerpetual.target` selects the modified + // object (`~` → Any/source fallback; "that creature"/"the duplicate" + // → ParentTarget event/chain anaphor). Context refs surface no + // target slot; Any likewise resolves to the source without one. + Effect::ApplyPerpetual { target, .. } => { + if matches!(target, TargetFilter::Any) { + None + } else { + Some(target) + } + } + + // CR 115.1a + CR 601.2c: "Create a [Role/Aura] token attached to + // target creature" targets its host — surface `attach_to` as the + // target slot when it is a real targetable filter. CR 303.4 + the + // Asinine Antics ruling: a for-each host (`ParentTarget`, a context + // ref) is NOT targeted (hexproof can't stop it); it's bound + // per-iteration by the member-driven loop, so `owner` is surfaced and + // `attach_to` is reached as a hidden parent-ref slot instead. Mirrors + // `CopyTokenOf` (two targetable axes; no real card targets both — + // `attach_to` wins and `owner` falls back to the controller at resolve + // via `token::resolve_token_owner`). + // + // CR 111.2 + CR 601.2c: "Target player creates ..." token modes + // (e.g. Ashling's Command mode 4, Brigid's Command, Prismari Command) + // surface their token-creation target as the `owner` filter — the + // player who creates the token is its owner. The default + // `TargetFilter::Controller` preserves "you create ..." semantics. + Effect::Token { owner, attach_to, .. } => match attach_to { + Some(f) if !f.is_context_ref() => Some(f), + _ => Some(owner), + }, + + // GenericEffect and LoseLife have Option + // + // CR 104.3e + CR 115.1 + CR 603.7c: `LoseTheGame.target` and + // `WinTheGame.target` are Some(filter) when the Oracle text names + // a specific subject ("that player loses the game" — Ezio + // Auditore da Firenze; the filter resolves to + // `TargetFilter::TriggeringPlayer` so the trigger machinery binds + // the damaged player into `ability.targets`). When None the + // resolver falls back to `ability.controller` (the "you lose the + // game" / "you win the game" default). + Effect::GenericEffect { target, .. } + | Effect::LoseLife { target, .. } + | Effect::LoseTheGame { target, .. } + | Effect::WinTheGame { target, .. } => target.as_mut(), + + // CR 115.1 + CR 115.7: Mana abilities normally don't target, but a + // few spell-only mana effects (Jeska's Will mode 1: "Add {R} for + // each card in target opponent's hand") declare a player target so + // the `TargetZoneCardCount` quantity in `produced` can resolve + // against `ability.targets`. The optional `target` is `None` for + // every classic mana ability (Cabal Coffers, Reflecting Pool, etc.). + Effect::Mana { target, .. } => target.as_mut(), + + // CR 120.4b: Internal post-replacement damage continuations carry a + // concrete target already chosen by an earlier effect; no new target + // slot is exposed. + Effect::ApplyPostReplacementDamage { .. } => None, + + // CR 701.26a/b: `SetTapState` exposes its target only for the + // single-permanent scope (legacy `Tap`/`Untap`). The `All` scope + // (legacy `TapAll`/`UntapAll`) is a non-targeting population filter. + Effect::SetTapState { + scope: EffectScope::Single, + target, + .. + } => Some(target), + Effect::SetTapState { + scope: EffectScope::All, + .. + } => None, + + // CR 701.60a: `Suspect`/`Unsuspect` expose a target slot only for the + // single-permanent scope (targeted/anaphoric "suspect target + // creature" / "it's no longer suspected"). The `All` scope ("all + // suspected creatures are no longer suspected") is a non-targeting + // population filter enumerated at resolution — like `DestroyAll`, + // its `target_filter()` is None. + Effect::Suspect { + scope: EffectScope::Single, + target, + } + | Effect::Unsuspect { + scope: EffectScope::Single, + target, + } => Some(target), + Effect::Suspect { + scope: EffectScope::All, + .. + } + | Effect::Unsuspect { + scope: EffectScope::All, + .. + } => None, + + // --- Effects with no player-selectable target field --- + // These use filters, zone-level operations, or have no targeting at all. + Effect::StartYourEngines { .. } + // CR 311.7: the chaos anchor swap is a non-targeting per-player effect. + | Effect::SwapChosenLabels { .. } + // CR 109.4: owner/type_filter are non-targeting resolution-time + // filters; the copy source is chosen from the format pool, not + // declared as a target. + | Effect::CreateTokenCopyFromPool { .. } + | Effect::Myriad + // CR 702.141a: opponents and per-opponent attack binding are chosen + // by the effect, not declared as targets. + | Effect::Encore + // CR 701.42b: the meld partner is found by name + ownership at + // resolution, not declared as a player-selectable target. + | Effect::Meld { .. } + // CR 508.1: copies are chosen by the effect, not declared as targets. + | Effect::CopyTokenBlockingAttacker { .. } + | Effect::ChangeSpeed { .. } + | Effect::PumpAll { .. } + | Effect::DamageAll { .. } + | Effect::DamageEachPlayer { .. } + | Effect::DestroyAll { .. } + // CR 613.1b: GainControlAll's `target` is a mass *population* filter + // (enumerated at resolution), not a chosen target slot — like + // DestroyAll, its `target_filter()` is None. + | Effect::GainControlAll { .. } + | Effect::GoadAll { .. } + | Effect::BounceAll { .. } + | Effect::CounterAll { .. } + | Effect::ChangeZoneAll { .. } + | Effect::PutCounterAll { .. } + | Effect::DoublePTAll { .. } + | Effect::Explore + | Effect::Investigate + | Effect::Tribute { .. } + | Effect::BecomeMonarch + | Effect::NoOp + | Effect::Proliferate + | Effect::Populate + | Effect::Clash + // CR 608.2d: behold's quality is chosen as the effect resolves, not a + // declared stack target — target_filter() is None (like Clash/Populate). + | Effect::Behold { .. } + | Effect::EndTheTurn + | Effect::EndCombatPhase + | Effect::Vote { .. } + | Effect::Cleanup { .. } + | Effect::SearchOutsideGame { .. } + | Effect::Choose { .. } + | Effect::OpponentGuess { .. } + | Effect::ChooseDamageSource { .. } + | Effect::SolveCase + | Effect::SetClassLevel { .. } + | Effect::CreateDelayedTrigger { .. } + | Effect::AddTargetReplacement { .. } + | Effect::AddRestriction { .. } + | Effect::ReduceNextSpellCost { .. } + | Effect::GrantNextSpellAbility { .. } + | Effect::AddPendingETBCounters { .. } + | Effect::AddPendingEntersModifications { .. } + | Effect::CreateEmblem { .. } + | Effect::PayCost { .. } + | Effect::GrantCastingPermission { .. } + | Effect::RegisterBending { .. } + // CR 303.4 + CR 115.1: ReturnAsAura attaches to a CHOICE (not a + // target) picked at resolution time via + // `WaitingFor::ReturnAsAuraTarget`. No stack-push target slot. + | Effect::ReturnAsAura { .. } + | Effect::ChooseFromZone { .. } + | Effect::ForEachCategory { .. } + | Effect::ChooseAndSacrificeRest { .. } + | Effect::EachPlayerCopyChosen { .. } + | Effect::GainEnergy { .. } + | Effect::HeistExile + | Effect::Cascade + | Effect::Ripple { .. } + | Effect::MiracleCast { .. } + | Effect::MadnessCast { .. } + | Effect::GiftDelivery { .. } + | Effect::ExchangeControl { .. } + // CR 115.1d + CR 115.1: the source set and the recipient are surfaced + // as dual target slots by `ability_utils::collect_target_slots` (the + // "up to two" sources slot is driven by the ability's `multi_target` + // spec, the recipient is one mandatory slot), not by `target_filter()`. + | Effect::EachDealsDamageEqualToPower { .. } + // CR 109.4 + CR 120.3a: `EachController` resolves per-source at the + // resolver — no player-selectable target slot. Exhaustive match + // ensures any future `EachDamageRecipient` variant must be + // explicitly decided here rather than silently falling through. + | Effect::EachSourceDealsDamage { + recipient: EachDamageRecipient::EachController, + .. + } + // CR 701.12a: player targets (player_a/player_b) are surfaced as + // dual target slots by ability_utils, not by `target_filter()`. + | Effect::ExchangeLifeTotals { .. } + // CR 601.2a: candidates gathered by `filter`/`zones` at resolution, + // no player-selectable target slot. + | Effect::FreeCastFromZones { .. } + // CR 614.1a: acts on the triggering spell (the trigger source), not a + // player-declared target. + | Effect::ExileResolvingSpellInsteadOfGraveyard { .. } + | Effect::Manifest { .. } + | Effect::ManifestDread + | Effect::Cloak { .. } + | Effect::TurnFaceUp { .. } + | Effect::RollDie { .. } + | Effect::FlipCoin { .. } + | Effect::FlipCoins { .. } + | Effect::FlipCoinUntilLose { .. } + | Effect::RingTemptsYou + | Effect::VentureIntoDungeon + | Effect::VentureInto { .. } + | Effect::TakeTheInitiative + | Effect::Planeswalk + | Effect::ChaosEnsues + | Effect::RedistributeLifeTotals + | Effect::ReverseTurnOrder + | Effect::OpenAttractions { .. } + | Effect::RollToVisitAttractions + | Effect::AssembleContraptions { .. } + | Effect::AssembleContraptionsFromRollDifference + | Effect::AssembleContraptionOnSprocket { .. } + | Effect::ProcessRadCounters + | Effect::Incubate { .. } + | Effect::Amass { .. } + | Effect::Monstrosity { .. } + | Effect::Specialize + | Effect::Renown { .. } + | Effect::Bolster { .. } + | Effect::Adapt { .. } + | Effect::Learn + | Effect::Forage + | Effect::Harness + | Effect::CollectEvidence { .. } + | Effect::Endure { .. } + | Effect::ExploreAll { .. } + | Effect::Seek { .. } + | Effect::SetDayNight { .. } + | Effect::TimeTravel + | Effect::RuntimeHandled { .. } + | Effect::Conjure { .. } + | Effect::Intensify { .. } + | Effect::DraftFromSpellbook { .. } + | Effect::ChooseOneOf { .. } + // CR 122.1 + CR 608.2d: ChooseCounterAdjustment is slot-less like + // ChooseOneOf — its single target arrives via the propagated + // `ability.targets` chain, not a cast-time slot, so it must NOT be + // surfaced by `collect_target_slots`. + | Effect::ChooseCounterAdjustment { .. } + | Effect::Unimplemented { .. } + // CR 603.7e: ChooseObjectsIntoTrackedSet has no discrete effect-target + // slot — `chooser` is a player ref resolved like `PayCost.payer`, and + // `filter` constrains the interactive selection, not a targeting slot. + | Effect::ChooseObjectsIntoTrackedSet { .. } + // CR 700.3b: SeparateIntoPiles has no targeting slot — partitioning + // is a resolution-time set computation against `object_filter`. + | Effect::SeparateIntoPiles { .. } + // CR 701.20a: RevealFromHand implicitly targets the controller's own hand; + // it has no discrete `target` field for the generic targeting layer. + | Effect::RevealFromHand { .. } + // CR 614.9 + CR 115.1: CreateDamageReplacement has no `target: + // TargetFilter` field. Its "to target creature" redirect recipient + // (Soltari Guerrillas — `redirect_to: ChosenObjectTarget`) is + // surfaced through dedicated branches in `ability_utils` + // (`collect_target_slots` / `collect_target_slot_specs`), mirroring + // `MoveCounters`/`Attach`; all other forms host on the controller or + // source and declare no target. + // CR 702.50a: EpicCopy carries its targets inside the snapshotted + // spell ability, not in a top-level `target` field. + | Effect::EpicCopy { .. } + | Effect::CreateDamageReplacement { .. } + // CR 614.11: CreateDrawReplacement is non-targeted — "you would + // draw" scopes via the shield's source-player default, no slot. + | Effect::CreateDrawReplacement { .. } + // CR 614.1a: CreatePlaneswalkReplacement is non-targeted — "a player + // would planeswalk" scopes via the shield's player scope, no slot. + | Effect::CreatePlaneswalkReplacement { .. } => None, + // CR 115.1 + CR 601.2c: "two target players each reveal the top card of + // their library" (Parker Luck) needs a stack-time player target slot so + // the multi_target spec expands to one slot per revealer. Scoped to the + // bare `Player` filter (NOT the general `!is_context_ref()` that + // RevealUntil uses): a `Typed(opponent)` "target opponent reveals … deals + // damage to that player" reveal (Cerebral Eruption) additionally depends + // on the ParentTarget-after-reveal player binding, which currently + // mis-binds to the revealed card via `last_revealed_ids` injection + // (measured: damage lands on the revealed library card, not the player). + // Surfacing those slots would expose that separate pre-existing runtime + // bug, so the `Typed`-opponent reveal-targeting is a documented + // follow-up (S25 deferral D8) — do NOT re-broaden this arm to + // `!is_context_ref()` without first fixing the ParentTarget binding, + // or Cerebral Eruption re-ships damage to the revealed card. + Effect::RevealTop { + player: player @ TargetFilter::Player, + .. + } => Some(player), + // CR 115.1: every other RevealTop (context-ref `Controller`/`ScopedPlayer` + // "your library", `Typed(opponent)`, or `Any`) surfaces no target slot. + Effect::RevealTop { .. } => None, + // CR 115.1: RevealUntil with a non-context player filter ("target + // opponent reveals...") requires a stack-time player target slot. + Effect::RevealUntil { player, .. } => { + if player.is_context_ref() { + None + } else { + Some(player) + } + } + // CR 701.23a: SearchLibrary has an optional player target for opponent + // search ("search target opponent's library" → a stack-time slot). + // CR 608.2c + CR 108.3 / CR 109.4: an object-relative searched player + // ("search ITS controller's/owner's graveyard, hand, and library" — + // the name-hate class) is carried as a `Typed` controller context-ref + // and resolved at resolution by `resolve_library_owner` (never a + // cast-time target). It stays a `Typed` wrapper (not the bare + // `ParentTargetController` variant) so `searcher_is_library_owner` + // returns false and the caster remains the searcher (CR 701.23a + // asymmetric); surface no slot for it, mirroring `RevealUntil`. + Effect::SearchLibrary { target_player, .. } => match target_player { + Some(TargetFilter::Typed(tf)) + if tf.type_filters.is_empty() + && tf.properties.is_empty() + && matches!( + tf.controller, + Some( + ControllerRef::ParentTargetOwner + | ControllerRef::ParentTargetController + ) + ) => + { + None + } + other => other.as_mut(), + }, + Effect::ChooseDrawnThisTurnPayOrTopdeck { player, .. } => Some(player), + } + } + + /// CR 107.3 + CR 608.2c: Returns the `QuantityExpr` carrying this effect's + /// primary count/amount, for the full class of count- and amount-bearing + /// effects (token creation, counters, draws, damage, mill, discard, etc.). + /// Returns `None` for effects whose magnitude is not a `QuantityExpr` + /// (fixed structural effects, choices, zone-level operations). + /// + /// Single authority used to bind and inspect a dynamic count after an + /// effect body has been parsed — e.g. vote-tally parsing binds the + /// per-choice `QuantityRef::VoteCount` into this slot (`count_expr_mut`), + /// and `Effect::resolve_tally` reads it back (`count_expr`) to decide + /// aggregate vs. per-vote resolution. + /// + /// Exhaustive match — no wildcards — so the compiler forces an update when + /// a new count/amount-bearing Effect variant is added. + pub fn count_expr(&self) -> Option<&QuantityExpr> { + match self { + Effect::ChangeTextWords { .. } => None, + // --- Effects whose magnitude is a `count: QuantityExpr` --- + Effect::Draw { count, .. } + | Effect::Token { count, .. } + | Effect::Sacrifice { count, .. } + | Effect::Mill { count, .. } + | Effect::Scry { count, .. } + | Effect::Dig { count, .. } + | Effect::Surveil { count, .. } + | Effect::CopyTokenOf { count, .. } + | Effect::CreateTokenCopyFromPool { count, .. } + | Effect::PutCounter { count, .. } + | Effect::PutCounterAll { count, .. } + // CR 122.1 + CR 122.6: how many counters of the chosen kind to add. + | Effect::PutChosenCounter { count, .. } + | Effect::Discard { count, .. } + | Effect::SearchLibrary { count, .. } + | Effect::SearchOutsideGame { count, .. } + | Effect::ExileTop { count, .. } + | Effect::AddPendingETBCounters { count, .. } + | Effect::RollDie { count, .. } + | Effect::FlipCoins { count, .. } + | Effect::GivePlayerCounter { count, .. } + | Effect::PutAtLibraryPosition { count, .. } + | Effect::ChooseDrawnThisTurnPayOrTopdeck { count, .. } + | Effect::Manifest { count, .. } + | Effect::Cloak { count, .. } + | Effect::SkipNextTurn { count, .. } + | Effect::SkipNextStep { count, .. } + | Effect::AdditionalPhase { count, .. } + | Effect::Incubate { count, .. } + | Effect::Amass { count, .. } + | Effect::Monstrosity { count, .. } + | Effect::Renown { count, .. } + | Effect::Bolster { count, .. } + | Effect::Adapt { count, .. } + | Effect::AssembleContraptions { count } + // CR 701.20a: how many matching cards to reveal before the + // until-loop terminates ("reveal until you reveal X [filter] cards"). + | Effect::RevealUntil { count, .. } + | Effect::Seek { count, .. } => Some(count), + + // --- Effects whose magnitude is an `amount: QuantityExpr` --- + Effect::ChangeSpeed { amount, .. } + | Effect::DealDamage { amount, .. } + // CR 120.1: uniform per-source damage amount. + | Effect::EachSourceDealsDamage { amount, .. } + | Effect::GainLife { amount, .. } + | Effect::LoseLife { amount, .. } + | Effect::DamageAll { amount, .. } + | Effect::DamageEachPlayer { amount, .. } + | Effect::GainEnergy { amount, .. } + | Effect::GrantExtraLoyaltyActivations { amount, .. } + | Effect::SetLifeTotal { amount, .. } + | Effect::Intensify { amount, .. } => Some(amount), + + // --- Effects whose count/amount is an `Option` --- + Effect::BounceAll { count, .. } + | Effect::MoveCounters { count, .. } + | Effect::RevealHand { count, .. } => count.as_ref(), + + // --- Effects with no QuantityExpr count/amount --- + Effect::ApplyPerpetual { .. } + // Deferred continuous-modification carrier — the mods Vec carries no + // QuantityExpr count/amount (CR 613 type grant). + | Effect::AddPendingEntersModifications { .. } + | Effect::StartYourEngines { .. } + // CR 608.2d: the counter-kind CHOICE carries no magnitude. + | Effect::ChooseCounterKind { .. } + | Effect::ApplyPostReplacementDamage { .. } + | Effect::Pump { .. } + | Effect::PairWith { .. } + | Effect::Destroy { .. } + | Effect::Regenerate { .. } + | Effect::RemoveAllDamage { .. } + | Effect::Counter { .. } + | Effect::CounterAll { .. } + // CR 701.26a/b: tap/untap carry no QuantityExpr in any scope. + | Effect::SetTapState { .. } + | Effect::RemoveCounter { .. } + | Effect::DiscardCard { .. } + | Effect::ChangeZone { .. } + | Effect::ChangeZoneAll { .. } + | Effect::GainControl { .. } + | Effect::GainControlAll { .. } + | Effect::ControlNextTurn { .. } + | Effect::Attach { .. } + | Effect::UnattachAll { .. } + | Effect::Fight { .. } + | Effect::EachDealsDamageEqualToPower { .. } + | Effect::Bounce { .. } + | Effect::Explore + | Effect::ExploreAll { .. } + | Effect::Investigate + | Effect::Tribute { .. } + | Effect::TimeTravel + | Effect::BecomeMonarch + | Effect::NoOp + | Effect::Proliferate + | Effect::ProliferateTarget { .. } + | Effect::EndTheTurn + | Effect::EndCombatPhase + | Effect::Populate + | Effect::Clash + | Effect::OpponentGuess { .. } + | Effect::Behold { .. } + | Effect::Vote { .. } + | Effect::SeparateIntoPiles { .. } + | Effect::SwitchPT { .. } + | Effect::CopySpell { .. } + | Effect::EpicCopy { .. } | Effect::CastCopyOfCard { .. } | Effect::Myriad | Effect::Encore @@ -14359,6 +14946,7 @@ impl Effect { /// `count_expr` arm-for-arm. pub fn count_expr_mut(&mut self) -> Option<&mut QuantityExpr> { match self { + Effect::ChangeTextWords { .. } => None, // --- Effects whose magnitude is a `count: QuantityExpr` --- Effect::Draw { count, .. } | Effect::Token { count, .. } @@ -14609,6 +15197,7 @@ impl Effect { /// Production API for GameEvent::EffectResolved api_type strings and logging. pub fn effect_variant_name(effect: &Effect) -> &str { match effect { + Effect::ChangeTextWords { .. } => "ChangeTextWords", Effect::StartYourEngines { .. } => "StartYourEngines", Effect::ChangeSpeed { .. } => "ChangeSpeed", Effect::DealDamage { .. } => "DealDamage", @@ -14857,6 +15446,7 @@ pub fn effect_variant_name(effect: &Effect) -> &str { /// and trigger-condition placeholders (Reveal, Transform, TurnFaceUp, DayTimeChange). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum EffectKind { + ChangeTextWords, StartYourEngines, ChangeSpeed, DealDamage, @@ -15104,6 +15694,7 @@ pub enum EffectKind { impl From<&Effect> for EffectKind { fn from(effect: &Effect) -> Self { match effect { + Effect::ChangeTextWords { .. } => EffectKind::ChangeTextWords, Effect::StartYourEngines { .. } => EffectKind::StartYourEngines, Effect::ChangeSpeed { .. } => EffectKind::ChangeSpeed, Effect::DealDamage { .. } => EffectKind::DealDamage, @@ -19771,6 +20362,18 @@ pub enum ContinuousModification { /// sibling of `SetColor`. Used by "its name is the last chosen name" (Psychic /// Paper). Unit variant: the read source is implicitly `ChosenAttribute::CardName`. SetChosenName, + /// CR 612.1 + CR 612.2 + CR 613.1c: Layer-3 text-changing effect; replaces + /// every instance of `from` (used as `category`) with `to` across the + /// object's derived characteristics (rules text, type line, keyword params, + /// embedded filters/conditions). Operands are latched at resolution — the + /// controller's `from`/`to` choices are fixed when the effect is created, so + /// the modification carries concrete `TextWord`s rather than reading them at + /// layer-evaluation time. + ReplaceTextWord { + category: TextWordCategory, + from: TextWord, + to: TextWord, + }, /// CR 707.9a: Retain a printed triggered ability from the source object's /// printed trigger list at the given index. Used by "becomes a copy of , /// except it has this ability" patterns (Irma Part-Time Mutant, Cryptoplasm, diff --git a/crates/engine/src/types/actions.rs b/crates/engine/src/types/actions.rs index e7faf68799..e5fafbe9a5 100644 --- a/crates/engine/src/types/actions.rs +++ b/crates/engine/src/types/actions.rs @@ -133,6 +133,12 @@ pub enum GameAction { ChooseEntryAttackTarget { target: AttackTarget, }, + /// CR 612.1: Response to `WaitingFor::TextWordReplacement` — the controller + /// selects one of the pre-computed `(category, from, to)` options by index, + /// installing the corresponding Layer-3 text-changing effect on the target. + ChooseTextWordReplacement { + index: usize, + }, PlayLand { object_id: ObjectId, card_id: CardId, @@ -1442,6 +1448,8 @@ impl GameAction { match self { GameAction::ChooseMeldPair { source_id, .. } => Some(*source_id), GameAction::ChooseEntryAttackTarget { .. } => None, + // CR 612.1: a resolution-time choice, not tied to a source object. + GameAction::ChooseTextWordReplacement { .. } => None, GameAction::PlayLand { object_id, .. } => Some(*object_id), GameAction::CastSpell { object_id, .. } => Some(*object_id), GameAction::Foretell { object_id, .. } => Some(*object_id), diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index bfe6664f26..60acac80c6 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -14,7 +14,8 @@ use super::ability::{ DelayedTriggerCondition, Duration, EffectKind, GameRestriction, KeywordAction, KickerVariant, LibraryPosition, ModalChoice, PermanentEntryMode, PileSource, QuantityExpr, ResolvedAbility, SearchDestinationSplit, SearchSelectionConstraint, StaticCondition, TapCreaturesAggregate, - TargetFilter, TargetRef, ThisWayCause, TriggerCondition, TriggerDefinition, + TargetFilter, TargetRef, TextWord, TextWordCategory, ThisWayCause, TriggerCondition, + TriggerDefinition, }; use super::attribution::ObjectAttribution; use super::card::{CardFace, TokenImageRef}; @@ -5574,6 +5575,37 @@ pub enum WaitingFor { /// length of the submitted `Vec`. shards: Vec, }, + /// CR 612.1: The controller of a resolving `Effect::ChangeTextWords` picks one + /// concrete `(category, from, to)` substitution to install as a Layer-3 + /// text-changing continuous effect on `target`. The engine pre-computes every + /// legal option (each `from` word actually present in the target per CR 612.2, + /// paired with each legal `to` word of the same category), so the player simply + /// indexes into `options`. Using a flat `options` list keeps the interaction a + /// single indexed choice (`GameAction::ChooseTextWordReplacement { index }`); + /// even for the creature-type category the product stays small in practice and + /// AI/frontend handle `0..options.len()` uniformly, so no two-step choose-then-to + /// interaction is needed. `label` is engine-computed; the frontend renders it. + TextWordReplacement { + player: PlayerId, + /// The resolving text-change spell/ability object — recorded as the + /// source of the Layer-3 continuous effect installed on `target`. + source: ObjectId, + target: ObjectId, + options: Vec, + #[serde(default)] + duration: Option, + }, +} + +/// CR 612.1 + CR 612.2: One legal text-word substitution the controller may pick +/// when resolving `Effect::ChangeTextWords`. `label` is the engine-computed display +/// string (frontend renders it verbatim — the frontend computes nothing). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TextWordReplacementOption { + pub category: TextWordCategory, + pub from: TextWord, + pub to: TextWord, + pub label: String, } /// CR 707.10c / CR 722.3c: A target slot on a copied spell, showing the @@ -5675,6 +5707,7 @@ impl WaitingFor { /// in `game/scenario.rs`, which are private and non-exhaustive. pub fn variant_name(&self) -> &'static str { match self { + WaitingFor::TextWordReplacement { .. } => "TextWordReplacement", WaitingFor::Priority { .. } => "Priority", WaitingFor::MeldPairChoice { .. } => "MeldPairChoice", WaitingFor::MeldAttackTargetChoice { .. } => "MeldAttackTargetChoice", @@ -5938,7 +5971,8 @@ impl WaitingFor { | WaitingFor::CommanderZoneChoice { player, .. } | WaitingFor::SeparatePilesChooseOpponent { player, .. } | WaitingFor::SeparatePilesPartition { player, .. } - | WaitingFor::SeparatePilesChoice { player, .. } => Some(*player), + | WaitingFor::SeparatePilesChoice { player, .. } + | WaitingFor::TextWordReplacement { player, .. } => Some(*player), // CR 608.2c: For `ControllerLabels` votes (Battlebond friend-or-foe // cards), the ACTOR is the spell controller, not `player` (the // subject being labeled). `VoteActor::resolve` returns the diff --git a/crates/engine/src/types/layers.rs b/crates/engine/src/types/layers.rs index 5403acc873..4f49340db2 100644 --- a/crates/engine/src/types/layers.rs +++ b/crates/engine/src/types/layers.rs @@ -84,6 +84,8 @@ impl ContinuousModification { // CR 612.8 + CR 613.1c: Setting an object's name to the source's // chosen card name is a text-changing effect — Layer 3. ContinuousModification::SetChosenName => Layer::Text, + // CR 613.1c: Text-changing effects are applied in Layer 3. + ContinuousModification::ReplaceTextWord { .. } => Layer::Text, ContinuousModification::AddPower { .. } | ContinuousModification::AddToughness { .. } | ContinuousModification::AddDynamicPower { .. } @@ -265,6 +267,16 @@ mod tests { ); // CR 612.8 + CR 613.1c: SetChosenName is a text-changing effect (Layer 3). assert_eq!(ContinuousModification::SetChosenName.layer(), Layer::Text); + // CR 613.1c: ReplaceTextWord is a text-changing effect (Layer 3). + assert_eq!( + ContinuousModification::ReplaceTextWord { + category: crate::types::ability::TextWordCategory::ColorWord, + from: crate::types::ability::TextWord::Color(crate::types::mana::ManaColor::Red), + to: crate::types::ability::TextWord::Color(crate::types::mana::ManaColor::Blue), + } + .layer(), + Layer::Text + ); assert_eq!( ContinuousModification::AddPower { value: 1 }.layer(), Layer::ModifyPT diff --git a/crates/engine/src/types/mana.rs b/crates/engine/src/types/mana.rs index f19072a1a5..eb410fd872 100644 --- a/crates/engine/src/types/mana.rs +++ b/crates/engine/src/types/mana.rs @@ -9,7 +9,7 @@ use super::keywords::{Keyword, KeywordKind}; use super::player::PlayerId; use super::zones::Zone; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum ManaColor { White, Blue, diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index c3fefbbac8..3a2c79bca3 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -705,6 +705,7 @@ mod tempt_with_discovery; mod terra_herald_optional_prompt; mod terra_magical_adept_milled_enchantment; mod terror_of_the_peaks_issue_2911; +mod text_changing_effects; mod the_chain_veil_loyalty_grants; mod the_fourteenth_doctor_graveyard_copy; mod the_kingpin_of_crime_combat_damage; diff --git a/crates/engine/tests/integration/text_changing_effects.rs b/crates/engine/tests/integration/text_changing_effects.rs new file mode 100644 index 0000000000..66bfac4753 --- /dev/null +++ b/crates/engine/tests/integration/text_changing_effects.rs @@ -0,0 +1,806 @@ +//! CR 612: Text-changing effects — word replacement. Runtime regressions driving +//! the real cast pipeline (parse → cast → resolve → `WaitingFor::TextWordReplacement` +//! → `GameAction::ChooseTextWordReplacement` → Layer-3 continuous effect). + +use engine::game::layers::{flush_layers, prune_end_of_turn_effects}; +use engine::game::scenario::{GameScenario, P0}; +use engine::game::text_substitution::collect_present_words; +use engine::parser::oracle::parse_oracle_text; +use engine::types::ability::{ + AbilityDefinition, BasicLandType, Duration, Effect, TextWord, TextWordCategory, +}; +use engine::types::actions::GameAction; +use engine::types::game_state::{TextWordReplacementOption, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::keywords::{Keyword, ProtectionTarget}; +use engine::types::mana::ManaColor; +use engine::types::phase::Phase; + +const SLEIGHT_OF_MIND: &str = "Change the text of target spell or permanent by replacing all instances of one color word with another."; +const ARTIFICIAL_EVOLUTION: &str = "Change the text of target permanent by replacing all instances of one creature type with another."; + +/// Find the index of the option matching `(from, to)` in the current +/// `WaitingFor::TextWordReplacement`, panicking with a useful message otherwise. +fn choose_index( + runner: &engine::game::scenario::GameRunner, + from: TextWord, + to: TextWord, +) -> usize { + match &runner.state().waiting_for { + WaitingFor::TextWordReplacement { options, .. } => options + .iter() + .position(|o| o.from == from && o.to == to) + .unwrap_or_else(|| panic!("no option {from:?}->{to:?} among {options:?}")), + other => panic!("expected TextWordReplacement, got {other:?}"), + } +} + +/// CR 612.2: a color word used in a keyword parameter (`protection from red`) is +/// text-changed. Revert guard: deleting the walker's `Keyword::Protection` / +/// `ProtectionTarget::Color` arm leaves the keyword `red` and flips this test. +#[test] +fn color_word_in_protection_keyword_is_replaced() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let creature = scenario + .add_creature(P0, "Ruby Sentinel", 2, 2) + .with_keyword(Keyword::Protection(ProtectionTarget::Color(ManaColor::Red))) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Sleight of Mind", true, SLEIGHT_OF_MIND) + .id(); + + let mut runner = scenario.build(); + runner.cast(spell).target_object(creature).resolve(); + + let index = choose_index( + &runner, + TextWord::Color(ManaColor::Red), + TextWord::Color(ManaColor::Blue), + ); + runner + .act(GameAction::ChooseTextWordReplacement { index }) + .expect("submit text-word choice"); + flush_layers(runner.state_mut()); + + let keywords = &runner.state().objects[&creature].keywords; + assert!( + keywords.contains(&Keyword::Protection(ProtectionTarget::Color( + ManaColor::Blue + ))), + "protection should now be from blue: {keywords:?}" + ); + assert!( + !keywords.contains(&Keyword::Protection(ProtectionTarget::Color( + ManaColor::Red + ))), + "protection from red must be gone: {keywords:?}" + ); +} + +/// CR 612.2 structural exclusion: a text-change never rewrites a card name even +/// when it contains a color/type substring. Positive reach-guard: a real color +/// keyword on the same object DID change, proving the input reached the walker. +#[test] +fn card_name_is_not_text_changed() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let creature = scenario + .add_creature(P0, "Whitemane Lion", 2, 2) + .with_keyword(Keyword::Protection(ProtectionTarget::Color( + ManaColor::White, + ))) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Sleight of Mind", true, SLEIGHT_OF_MIND) + .id(); + + let mut runner = scenario.build(); + runner.cast(spell).target_object(creature).resolve(); + + let index = choose_index( + &runner, + TextWord::Color(ManaColor::White), + TextWord::Color(ManaColor::Blue), + ); + runner + .act(GameAction::ChooseTextWordReplacement { index }) + .expect("submit text-word choice"); + flush_layers(runner.state_mut()); + + let obj = &runner.state().objects[&creature]; + // Name (and base name) untouched even though it contains "white". + assert_eq!(obj.name, "Whitemane Lion"); + assert_eq!(obj.base_name, "Whitemane Lion"); + // Positive reach-guard: the real color keyword ref DID change. + assert!( + obj.keywords + .contains(&Keyword::Protection(ProtectionTarget::Color( + ManaColor::Blue + ))), + "the rules-text 'white' ref should have become blue: {:?}", + obj.keywords + ); +} + +/// CR 612.2 + CR 205.3: a creature-type word on the type line is text-changed. +/// Revert guard: dropping the `card_types.subtypes` walk root leaves "Zombie". +#[test] +fn creature_type_on_type_line_is_replaced() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let creature = scenario + .add_creature(P0, "Shambler", 2, 2) + .with_subtypes(vec!["Zombie"]) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Artificial Evolution", true, ARTIFICIAL_EVOLUTION) + .id(); + + let mut runner = scenario.build(); + // CR 205.3m: the legal creature-type words come from the live type set. + runner.state_mut().all_creature_types = + vec!["Zombie".to_string(), "Elf".to_string(), "Wall".to_string()]; + + runner.cast(spell).target_object(creature).resolve(); + + let index = choose_index( + &runner, + TextWord::CreatureType("Zombie".to_string()), + TextWord::CreatureType("Elf".to_string()), + ); + runner + .act(GameAction::ChooseTextWordReplacement { index }) + .expect("submit text-word choice"); + flush_layers(runner.state_mut()); + + let subtypes = &runner.state().objects[&creature].card_types.subtypes; + assert!( + subtypes.iter().any(|s| s == "Elf"), + "expected Elf: {subtypes:?}" + ); + assert!( + !subtypes.iter().any(|s| s == "Zombie"), + "Zombie must be gone: {subtypes:?}" + ); +} + +/// CR 609.3: when the target has no word of the chosen category, the effect does +/// nothing — no `WaitingFor::TextWordReplacement`, no continuous effect. Paired +/// positive: the color test above proves the same pipeline DOES pause when a word +/// is present, so this negative is not vacuous. +#[test] +fn no_color_word_present_is_a_no_op() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let creature: ObjectId = scenario.add_creature(P0, "Grey Ogre", 2, 2).id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Sleight of Mind", true, SLEIGHT_OF_MIND) + .id(); + + let mut runner = scenario.build(); + runner.cast(spell).target_object(creature).resolve(); + + assert!( + !matches!( + runner.state().waiting_for, + WaitingFor::TextWordReplacement { .. } + ), + "no color word present must not raise a replacement choice: {:?}", + runner.state().waiting_for + ); + // The vanilla creature is unchanged. + assert!(runner.state().objects[&creature].keywords.is_empty()); +} + +// Verbatim (reminder-stripped) Oracle text driven through the real parser. +const CRYSTAL_SPRAY: &str = "Change the text of target spell or permanent by \ + replacing all instances of one color word with another or one basic land \ + type with another until end of turn.\nDraw a card."; +const MAGICAL_HACK: &str = "Change the text of target spell or permanent by \ + replacing all instances of one basic land type with another."; +// CR 612.2: the excluded-`to` rider is a second sentence; Task-1's continuation +// absorber must push Wall into `excluded_to`. +const ARTIFICIAL_EVOLUTION_FULL: &str = "Change the text of target spell or \ + permanent by replacing all instances of one creature type with another. \ + The new creature type can't be Wall."; + +/// Submit the chosen replacement and re-derive layers. Panics with a useful +/// message if the expected `(from, to)` option is not offered. +fn apply_replacement( + runner: &mut engine::game::scenario::GameRunner, + from: TextWord, + to: TextWord, +) { + let index = choose_index(runner, from, to); + runner + .act(GameAction::ChooseTextWordReplacement { index }) + .expect("submit text-word choice"); + flush_layers(runner.state_mut()); +} + +/// CR 611.2b + CR 514.2 (plan 5): an "until end of turn" text change (Crystal +/// Spray) installs a Layer-3 TCE that is pruned at cleanup, while an indefinite +/// change (Sleight of Mind) persists past cleanup. Revert guard: if Crystal +/// Spray's duration were mis-wired to `Permanent`, the post-cleanup assertion +/// that protection reverts to red would fail. +#[test] +fn until_end_of_turn_change_expires_indefinite_persists() { + // --- Crystal Spray: expires at cleanup. --- + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature(P0, "Ruby Sentinel", 2, 2) + .with_keyword(Keyword::Protection(ProtectionTarget::Color(ManaColor::Red))) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Crystal Spray", true, CRYSTAL_SPRAY) + .id(); + // Crystal Spray's trailing "Draw a card" must draw from a non-empty library, + // else the caster decks out (CR 104.3c) and the game ends before the swap. + scenario.with_library_top(P0, &["Plains", "Plains"]); + let mut runner = scenario.build(); + runner.cast(spell).target_object(creature).resolve(); + apply_replacement( + &mut runner, + TextWord::Color(ManaColor::Red), + TextWord::Color(ManaColor::Blue), + ); + // The swap took effect this turn. + assert!( + runner.state().objects[&creature] + .keywords + .contains(&Keyword::Protection(ProtectionTarget::Color( + ManaColor::Blue + ))), + "protection should be blue during the turn: {:?}", + runner.state().objects[&creature].keywords + ); + // CR 514.2: cleanup prunes the UntilEndOfTurn TCE; re-derive layers. + prune_end_of_turn_effects(runner.state_mut()); + flush_layers(runner.state_mut()); + let keywords = &runner.state().objects[&creature].keywords; + assert!( + keywords.contains(&Keyword::Protection(ProtectionTarget::Color( + ManaColor::Red + ))), + "the until-end-of-turn change must be GONE after cleanup: {keywords:?}" + ); + assert!( + !keywords.contains(&Keyword::Protection(ProtectionTarget::Color( + ManaColor::Blue + ))), + "blue must not persist past cleanup: {keywords:?}" + ); + + // --- Sleight of Mind: persists past cleanup (indefinite). --- + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature(P0, "Ruby Sentinel", 2, 2) + .with_keyword(Keyword::Protection(ProtectionTarget::Color(ManaColor::Red))) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Sleight of Mind", true, SLEIGHT_OF_MIND) + .id(); + let mut runner = scenario.build(); + runner.cast(spell).target_object(creature).resolve(); + apply_replacement( + &mut runner, + TextWord::Color(ManaColor::Red), + TextWord::Color(ManaColor::Blue), + ); + prune_end_of_turn_effects(runner.state_mut()); + flush_layers(runner.state_mut()); + let keywords = &runner.state().objects[&creature].keywords; + assert!( + keywords.contains(&Keyword::Protection(ProtectionTarget::Color( + ManaColor::Blue + ))), + "indefinite change must PERSIST past cleanup: {keywords:?}" + ); +} + +/// CR 612.2 (plan 6): a color word in a static ability's `affected` filter (an +/// anthem — "Black creatures get +1/+1") is text-changed. Revert guard: dropping +/// the `walk_static_definition` → `affected` recursion in the walker means the +/// black color word is neither collected (reach guard fails) nor rewritten. +#[test] +fn color_word_in_static_affected_filter_is_replaced() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let anthem = scenario + .add_creature(P0, "Bad Moon", 1, 1) + .from_oracle_text("Black creatures get +1/+1.") + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Sleight of Mind", true, SLEIGHT_OF_MIND) + .id(); + let mut runner = scenario.build(); + + // Positive reach-guard: the anthem's only color word lives in its static's + // `affected` filter, so the walker seeing black proves it descends there. + let before = collect_present_words( + &runner.state().objects[&anthem], + TextWordCategory::ColorWord, + ); + assert!( + before.contains(&TextWord::Color(ManaColor::Black)), + "anthem's static affected filter should carry the black color word: {before:?}" + ); + + runner.cast(spell).target_object(anthem).resolve(); + apply_replacement( + &mut runner, + TextWord::Color(ManaColor::Black), + TextWord::Color(ManaColor::Red), + ); + + let after = collect_present_words( + &runner.state().objects[&anthem], + TextWordCategory::ColorWord, + ); + assert!( + after.contains(&TextWord::Color(ManaColor::Red)), + "the static's affected color word should now be red: {after:?}" + ); + assert!( + !after.contains(&TextWord::Color(ManaColor::Black)), + "black must be gone from the static filter: {after:?}" + ); +} + +/// CR 612.2: a color word that lives ONLY inside an ability's effect target +/// filter (an activated "{T}: Destroy target red creature") is text-changed. +/// This is the sub-class the walker previously under-applied: `walk_effect` +/// classified `Destroy` as a leaf no-op and never descended into its `target` +/// `TargetFilter`, so the `red` instance was neither offered nor rewritten. +/// +/// Revert guard: without the `walk_effect` → `Effect::target_filter_mut()` → +/// `walk_target_filter` recursion, the vanilla creature carries no other color +/// word, so `collect_present_words` returns empty. The positive reach-guard +/// (`before` contains red) then fails, and — because no color word is present — +/// the cast raises no `WaitingFor::TextWordReplacement`, so `apply_replacement` +/// panics. Both flip red→green only with the recursion in place. +#[test] +fn color_word_in_effect_target_filter_is_replaced() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature(P0, "Gatekeeper", 2, 2) + .from_oracle_text("{T}: Destroy target red creature.") + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Sleight of Mind", true, SLEIGHT_OF_MIND) + .id(); + let mut runner = scenario.build(); + + // Positive reach-guard: the creature's ONLY color word lives in its activated + // ability's `Destroy { target }` filter, so the walker seeing red proves it + // now descends into the effect target filter (empty pre-fix). + let before = collect_present_words( + &runner.state().objects[&creature], + TextWordCategory::ColorWord, + ); + assert!( + before.contains(&TextWord::Color(ManaColor::Red)), + "the effect target filter should carry the red color word: {before:?}" + ); + + runner.cast(spell).target_object(creature).resolve(); + apply_replacement( + &mut runner, + TextWord::Color(ManaColor::Red), + TextWord::Color(ManaColor::Blue), + ); + + // The only color carrier is the effect target filter, so re-collecting proves + // that filter now reads blue and no longer reads red (CR 612.2 completeness). + let after = collect_present_words( + &runner.state().objects[&creature], + TextWordCategory::ColorWord, + ); + assert!( + after.contains(&TextWord::Color(ManaColor::Blue)), + "the effect target filter's color word should now be blue: {after:?}" + ); + assert!( + !after.contains(&TextWord::Color(ManaColor::Red)), + "red must be gone from the effect target filter: {after:?}" + ); +} + +/// CR 612.2 + CR 702.14 (plan 4): a basic land type in a landwalk keyword is +/// text-changed (Magical Hack: Mountain → Island). Revert guard: dropping the +/// `walk_keyword` `Landwalk` arm leaves Mountainwalk. +#[test] +fn basic_land_type_in_landwalk_is_replaced() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature(P0, "Mountain Strider", 2, 2) + .with_keyword(Keyword::Landwalk("Mountain".to_string())) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Magical Hack", true, MAGICAL_HACK) + .id(); + let mut runner = scenario.build(); + runner.cast(spell).target_object(creature).resolve(); + apply_replacement( + &mut runner, + TextWord::BasicLandType(BasicLandType::Mountain), + TextWord::BasicLandType(BasicLandType::Island), + ); + let keywords = &runner.state().objects[&creature].keywords; + assert!( + keywords.contains(&Keyword::Landwalk("Island".to_string())), + "landwalk should now be Islandwalk: {keywords:?}" + ); + assert!( + !keywords.contains(&Keyword::Landwalk("Mountain".to_string())), + "Mountainwalk must be gone: {keywords:?}" + ); +} + +/// CR 612.2 category isolation (plan 4 NEGATIVE): a creature-type text change +/// must NOT touch a basic-land-type carrier. Artificial Evolution (creature +/// type) on a Zombie with Mountainwalk changes Zombie → Elf (positive reach +/// guard) but leaves the Mountain landwalk untouched. +#[test] +fn creature_type_change_does_not_touch_basic_land_landwalk() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature(P0, "Zombie Strider", 2, 2) + .with_subtypes(vec!["Zombie"]) + .with_keyword(Keyword::Landwalk("Mountain".to_string())) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Artificial Evolution", true, ARTIFICIAL_EVOLUTION) + .id(); + let mut runner = scenario.build(); + runner.state_mut().all_creature_types = vec!["Zombie".to_string(), "Elf".to_string()]; + runner.cast(spell).target_object(creature).resolve(); + apply_replacement( + &mut runner, + TextWord::CreatureType("Zombie".to_string()), + TextWord::CreatureType("Elf".to_string()), + ); + let obj = &runner.state().objects[&creature]; + // Positive reach guard: the creature-type change DID apply. + assert!( + obj.card_types.subtypes.iter().any(|s| s == "Elf"), + "Zombie should have become Elf: {:?}", + obj.card_types.subtypes + ); + // The basic-land-type landwalk is a DIFFERENT category — untouched. + assert!( + obj.keywords + .contains(&Keyword::Landwalk("Mountain".to_string())), + "a creature-type change must not touch Mountainwalk: {:?}", + obj.keywords + ); +} + +/// CR 613.7 (plan 9): two sequential text changes on one permanent compose by +/// timestamp order — black → blue, then blue → red, yields red. Revert guard: if +/// each TCE's operands were not latched per-effect (or the timestamp order were +/// reversed), the final protection would read blue. +#[test] +fn sequential_text_changes_compose_by_timestamp() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature(P0, "Onyx Sentinel", 2, 2) + .with_keyword(Keyword::Protection(ProtectionTarget::Color( + ManaColor::Black, + ))) + .id(); + let first = scenario + .add_spell_to_hand_from_oracle(P0, "Sleight of Mind", true, SLEIGHT_OF_MIND) + .id(); + let second = scenario + .add_spell_to_hand_from_oracle(P0, "Sleight of Mind", true, SLEIGHT_OF_MIND) + .id(); + let mut runner = scenario.build(); + + runner.cast(first).target_object(creature).resolve(); + apply_replacement( + &mut runner, + TextWord::Color(ManaColor::Black), + TextWord::Color(ManaColor::Blue), + ); + // The second change reads the now-blue live word (proving per-TCE operands). + runner.cast(second).target_object(creature).resolve(); + apply_replacement( + &mut runner, + TextWord::Color(ManaColor::Blue), + TextWord::Color(ManaColor::Red), + ); + + let keywords = &runner.state().objects[&creature].keywords; + assert!( + keywords.contains(&Keyword::Protection(ProtectionTarget::Color( + ManaColor::Red + ))), + "final protection must be red (CR 613.7 timestamp order): {keywords:?}" + ); + assert!( + !keywords.contains(&Keyword::Protection(ProtectionTarget::Color( + ManaColor::Blue + ))), + "the intermediate blue must not survive the second change: {keywords:?}" + ); +} + +/// CR 608.2c (plan 10): Crystal Spray's trailing "Draw a card" continuation +/// resolves after the replacement choice, and control returns to Priority. +/// Revert guard: if the choice handler dropped the parked continuation, the +/// hand-size delta would be zero and/or the game would remain stuck off Priority. +#[test] +fn text_change_continuation_draws_and_returns_to_priority() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature(P0, "Ruby Sentinel", 2, 2) + .with_keyword(Keyword::Protection(ProtectionTarget::Color(ManaColor::Red))) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Crystal Spray", true, CRYSTAL_SPRAY) + .id(); + // A non-empty library so the trailing "Draw a card" succeeds (drawing from an + // empty library would deck the caster out — CR 104.3c). + scenario.with_library_top(P0, &["Plains", "Plains"]); + let mut runner = scenario.build(); + + runner.cast(spell).target_object(creature).resolve(); + let hand_before = runner + .state() + .players + .iter() + .find(|p| p.id == P0) + .map(|p| p.hand.len()) + .expect("P0 exists"); + + apply_replacement( + &mut runner, + TextWord::Color(ManaColor::Red), + TextWord::Color(ManaColor::Blue), + ); + + let hand_after = runner + .state() + .players + .iter() + .find(|p| p.id == P0) + .map(|p| p.hand.len()) + .expect("P0 exists"); + assert_eq!( + hand_after, + hand_before + 1, + "Crystal Spray's 'Draw a card' continuation must draw exactly one card" + ); + assert!( + matches!(runner.state().waiting_for, WaitingFor::Priority { .. }), + "control must return to Priority after the continuation: {:?}", + runner.state().waiting_for + ); +} + +/// Recursively collect every `Effect::ChangeTextWords` in an ability tree +/// (top-level, modal `ChooseOneOf` branches, mode abilities, sub/else chains). +fn collect_change_text<'a>(def: &'a AbilityDefinition, out: &mut Vec<&'a Effect>) { + if matches!(&*def.effect, Effect::ChangeTextWords { .. }) { + out.push(&def.effect); + } + if let Effect::ChooseOneOf { branches, .. } = &*def.effect { + for branch in branches { + collect_change_text(branch, out); + } + } + if let Some(sub) = &def.sub_ability { + collect_change_text(sub, out); + } + if let Some(els) = &def.else_ability { + collect_change_text(els, out); + } + for mode in &def.mode_abilities { + collect_change_text(mode, out); + } +} + +/// Parse `oracle` and return each `ChangeTextWords`'s +/// `(allowed_categories, excluded_to, duration)`. +#[allow(clippy::type_complexity)] +fn change_text_snapshots( + name: &str, + oracle: &str, +) -> Vec<(Vec, Vec, Option)> { + let parsed = parse_oracle_text(oracle, name, &[], &["Instant".to_string()], &[]); + let mut effects = Vec::new(); + for def in &parsed.abilities { + collect_change_text(def, &mut effects); + } + effects + .into_iter() + .map(|e| match e { + Effect::ChangeTextWords { + allowed_categories, + excluded_to, + duration, + .. + } => ( + allowed_categories.clone(), + excluded_to.clone(), + duration.clone(), + ), + _ => unreachable!("filtered to ChangeTextWords above"), + }) + .collect() +} + +/// CR 612.1 + CR 612.2 (plan 11): every card in the text-changing class lowers to +/// `Effect::ChangeTextWords` with the correct `allowed_categories`, `excluded_to`, +/// and `duration`. Parser snapshot (shape) test — the runtime semantics are +/// covered by the cast-pipeline tests above; this pins the lowering surface. +#[test] +fn parser_snapshots_for_text_changing_class() { + use TextWordCategory::{BasicLandType as BLand, ColorWord, CreatureType}; + + // Single-category, indefinite. + assert_eq!( + change_text_snapshots("Sleight of Mind", SLEIGHT_OF_MIND), + vec![(vec![ColorWord], vec![], None)] + ); + assert_eq!( + change_text_snapshots( + "Glamerdye", + "Change the text of target spell or permanent by replacing all \ + instances of one color word with another." + ), + vec![(vec![ColorWord], vec![], None)] + ); + assert_eq!( + change_text_snapshots( + "Alter Reality", + "Change the text of target spell or permanent by replacing all \ + instances of one color word with another." + ), + vec![(vec![ColorWord], vec![], None)] + ); + assert_eq!( + change_text_snapshots("Magical Hack", MAGICAL_HACK), + vec![(vec![BLand], vec![], None)] + ); + + // Two-category, indefinite. + assert_eq!( + change_text_snapshots( + "Mind Bend", + "Change the text of target permanent by replacing all instances of \ + one color word with another or one basic land type with another." + ), + vec![(vec![ColorWord, BLand], vec![], None)] + ); + + // Two-category, until end of turn. + assert_eq!( + change_text_snapshots("Crystal Spray", CRYSTAL_SPRAY), + vec![( + vec![ColorWord, BLand], + vec![], + Some(Duration::UntilEndOfTurn) + )] + ); + assert_eq!( + change_text_snapshots( + "Trait Doctoring", + "Change the text of target permanent by replacing all instances of \ + one color word with another or one basic land type with another \ + until end of turn." + ), + vec![( + vec![ColorWord, BLand], + vec![], + Some(Duration::UntilEndOfTurn) + )] + ); + assert_eq!( + change_text_snapshots( + "Whim of Volrath", + "Change the text of target permanent by replacing all instances of \ + one color word with another or one basic land type with another \ + until end of turn." + ), + vec![( + vec![ColorWord, BLand], + vec![], + Some(Duration::UntilEndOfTurn) + )] + ); + + // Creature type with the Wall exclusion (Task-1 continuation absorber). + assert_eq!( + change_text_snapshots("Artificial Evolution", ARTIFICIAL_EVOLUTION_FULL), + vec![( + vec![CreatureType], + vec![TextWord::CreatureType("Wall".to_string())], + None + )] + ); + + // Modal: each mode lowers to a single-category ChangeTextWords. + let spectral = change_text_snapshots( + "Spectral Shift", + "Choose one —\n\ + • Change the text of target spell or permanent by replacing all \ + instances of one basic land type with another.\n\ + • Change the text of target spell or permanent by replacing all \ + instances of one color word with another.", + ); + assert_eq!( + spectral.len(), + 2, + "Spectral Shift must lower to two ChangeTextWords modes: {spectral:?}" + ); + for (cats, excluded, dur) in &spectral { + assert_eq!( + cats.len(), + 1, + "each mode is a single-category change: {cats:?}" + ); + assert!(excluded.is_empty(), "no exclusion on Spectral Shift modes"); + assert_eq!(*dur, None, "Spectral Shift modes are indefinite"); + } + let mode_cats: std::collections::BTreeSet = spectral + .iter() + .flat_map(|(c, _, _)| c.iter().copied()) + .collect(); + assert_eq!( + mode_cats, + [BLand, ColorWord].into_iter().collect(), + "Spectral Shift's modes cover the basic-land and color-word categories" + ); +} + +/// CR 612.1 (plan 12): the interactive `WaitingFor`/`GameAction` payloads and the +/// `Effect::ChangeTextWords` with a non-empty `excluded_to` round-trip through +/// serde (guards the `skip_serializing_if = "Vec::is_empty"` on `excluded_to`). +#[test] +fn serde_round_trip_text_word_types() { + let wf = WaitingFor::TextWordReplacement { + player: P0, + source: ObjectId(11), + target: ObjectId(22), + options: vec![TextWordReplacementOption { + category: TextWordCategory::ColorWord, + from: TextWord::Color(ManaColor::Red), + to: TextWord::Color(ManaColor::Blue), + label: "Red → Blue".to_string(), + }], + duration: Some(Duration::UntilEndOfTurn), + }; + let json = serde_json::to_string(&wf).expect("serialize WaitingFor"); + let back: WaitingFor = serde_json::from_str(&json).expect("deserialize WaitingFor"); + assert_eq!(wf, back); + + let action = GameAction::ChooseTextWordReplacement { index: 3 }; + let json = serde_json::to_string(&action).expect("serialize GameAction"); + let back: GameAction = serde_json::from_str(&json).expect("deserialize GameAction"); + assert_eq!(action, back); + + // Non-empty excluded_to must survive the round trip despite skip-if-empty. + let effect = Effect::ChangeTextWords { + target: engine::types::ability::TargetFilter::Any, + allowed_categories: vec![TextWordCategory::CreatureType], + excluded_to: vec![TextWord::CreatureType("Wall".to_string())], + duration: None, + }; + let json = serde_json::to_string(&effect).expect("serialize Effect"); + let back: Effect = serde_json::from_str(&json).expect("deserialize Effect"); + assert_eq!(effect, back); +} diff --git a/crates/manabrew-compat/src/lib.rs b/crates/manabrew-compat/src/lib.rs index 641c73b889..92a5c4cdec 100644 --- a/crates/manabrew-compat/src/lib.rs +++ b/crates/manabrew-compat/src/lib.rs @@ -1748,7 +1748,8 @@ pub fn convert_available_action(action: &GameAction, id: String) -> AvailableAct | GameAction::ChoosePile { .. } | GameAction::ChooseBranch { .. } | GameAction::SubmitLifeRedistribution { .. } - | GameAction::ChooseDamageSource { .. } => { + | GameAction::ChooseDamageSource { .. } + | GameAction::ChooseTextWordReplacement { .. } => { AvailableActionConversion::Unsupported("local.selection-unsupported") } GameAction::SubmitPilePartition { .. } => { diff --git a/crates/phase-ai/src/decision_kind.rs b/crates/phase-ai/src/decision_kind.rs index d80a6af4d7..64b49a072a 100644 --- a/crates/phase-ai/src/decision_kind.rs +++ b/crates/phase-ai/src/decision_kind.rs @@ -31,7 +31,9 @@ pub fn classify(waiting_for: &WaitingFor, action: &GameAction) -> DecisionKind { | WaitingFor::RetargetChoice { .. } | WaitingFor::DistributeAmong { .. } | WaitingFor::MoveCountersDistribution { .. } - | WaitingFor::RemoveCountersChoice { .. } => DecisionKind::SelectTarget, + | WaitingFor::RemoveCountersChoice { .. } + // CR 612.1: picking the text-word substitution is a selection choice. + | WaitingFor::TextWordReplacement { .. } => DecisionKind::SelectTarget, WaitingFor::DeclareAttackers { .. } => DecisionKind::DeclareAttackers, WaitingFor::DeclareBlockers { .. } => DecisionKind::DeclareBlockers, WaitingFor::UntapChoice { .. } => DecisionKind::ActivateAbility, diff --git a/crates/phase-ai/src/policies/effect_classify.rs b/crates/phase-ai/src/policies/effect_classify.rs index 826f570557..54e3fedc65 100644 --- a/crates/phase-ai/src/policies/effect_classify.rs +++ b/crates/phase-ai/src/policies/effect_classify.rs @@ -190,7 +190,9 @@ pub(crate) fn effect_polarity(effect: &Effect) -> EffectPolarity { EffectPolarity::Contextual } // Contextual: depends on usage context - Effect::GainControl { .. } + // CR 612.1: a text change can help or hinder depending on the target. + Effect::ChangeTextWords { .. } + | Effect::GainControl { .. } | Effect::GiftDelivery { .. } | Effect::Suspect { .. } | Effect::GivePlayerCounter { .. } diff --git a/crates/phase-ai/src/policies/redundancy_avoidance.rs b/crates/phase-ai/src/policies/redundancy_avoidance.rs index 3a20964e34..39b53c8047 100644 --- a/crates/phase-ai/src/policies/redundancy_avoidance.rs +++ b/crates/phase-ai/src/policies/redundancy_avoidance.rs @@ -364,7 +364,9 @@ fn redundancy_delta( // Each arm below explicitly returns `None`. Adding a new `Effect` // variant without extending this list is a compile error — that's // the coverage tracker at work. - Effect::StartYourEngines { .. } + // CR 612.1: text change has no redundancy check. + Effect::ChangeTextWords { .. } + | Effect::StartYourEngines { .. } | Effect::ChangeSpeed { .. } | Effect::Destroy { .. } | Effect::Regenerate { .. } diff --git a/crates/phase-ai/src/policies/x_reference.rs b/crates/phase-ai/src/policies/x_reference.rs index ed750b728d..d571f87523 100644 --- a/crates/phase-ai/src/policies/x_reference.rs +++ b/crates/phase-ai/src/policies/x_reference.rs @@ -218,6 +218,8 @@ fn continuous_modification_references_x(modification: &ContinuousModification) - | ContinuousModification::RemoveSupertype { .. } | ContinuousModification::SetStartingLoyalty { .. } | ContinuousModification::AddKeywordWithDerivedCost { .. } + // CR 612.1: latched text-word replacement carries no X reference. + | ContinuousModification::ReplaceTextWord { .. } | ContinuousModification::RemoveManaCost => false, } } diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index 17d9022939..c02bbae9bc 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -1088,6 +1088,11 @@ fn fallback_action(state: &GameState) -> Option { .first() .map(|&source| GameAction::ChooseDamageSource { source }), + // CR 612.1: pick the first offered text-word substitution. + WaitingFor::TextWordReplacement { options, .. } => options + .first() + .map(|_| GameAction::ChooseTextWordReplacement { index: 0 }), + // CR 709.5f-g: room-door choice — pick the first offered (op, door). WaitingFor::ChooseRoomDoor { object_id, options, .. diff --git a/crates/server-core/src/game_action_payload_guard.rs b/crates/server-core/src/game_action_payload_guard.rs index 1a67aaaff9..0ec71505e7 100644 --- a/crates/server-core/src/game_action_payload_guard.rs +++ b/crates/server-core/src/game_action_payload_guard.rs @@ -487,6 +487,9 @@ pub fn guard_game_action_payload(action: &GameAction) -> Result<(), String> { | GameAction::ChooseTopOrBottom { .. } | GameAction::ChooseMeldPair { .. } | GameAction::ChooseEntryAttackTarget { .. } + // CR 612.1: text-word replacement carries a single bounded option index — + // nothing client-controlled to bound. + | GameAction::ChooseTextWordReplacement { .. } // CR 702.140c: mutate merge side carries a single typed enum — nothing // client-controlled to bound. | GameAction::ChooseMutateMergeSide { .. } From 663739de3d5c283d33cc1c6fe7fa008d7ffdcd56 Mon Sep 17 00:00:00 2001 From: real-venus Date: Thu, 16 Jul 2026 08:10:55 -0700 Subject: [PATCH 2/9] fix(engine): complete CR 612 text-change word substitution across all rules-text roots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses maintainer review on #5866. Extends the Layer-3 word-substitution walker to reach every color/land/creature-type word carrier in a permanent's rules text, so a text-changing effect (Artificial Evolution, Glamerdye, Magical Hack, …) rewrites the word everywhere it is used as that kind (CR 612.1/612.2). Now traversed (previously silently skipped): - Ability costs and conditions (Sacrifice/Discard/Exile filters, unless-pay, AbilityCondition, cost-reduction, activation restrictions). - Trigger intervening-if: TriggerCondition (incl. AttackersDeclaredCount), TriggerConstraint, and PlayerFilter roots. - Replacement effects (Root 6): ReplacementCondition + replacement mode/decline. - Static modes (the entire StaticDefinition.mode field, 119 variants). - Durations (ForAsLongAs), and QuantityRef/QuantityExpr filter carriers. - Cross-reference wrappers that embed a walked carrier: Effect::Counter source-rider, Effect::AddTargetReplacement, ContinuousModification:: AddStaticMode, CreateDelayedTrigger/ExiledSpellRider conditions, Vote objects. A mechanical type-graph sweep enumerates every carrier-type field across the AST so the traversal is exhaustive rather than ad hoc; the module doc honestly lists the remaining excluded surfaces (mana pips, core types, names, card-level cast options), each genuinely wordless or unreachable from a battlefield object. Tests: real activation-pipeline regression for Goblin Chirurgeon (Elf sacrifice accepted, Goblin rejected), a trigger intervening-if firing regression, and revert-failing cast-pipeline tests for the higher-risk carriers. Full engine suite green; clippy --workspace --all-targets clean. --- .../src/components/modal/CardChoiceModal.tsx | 2 +- crates/engine/src/game/effects/text_change.rs | 30 +- crates/engine/src/game/text_substitution.rs | 2309 +++++++++++++++-- .../integration/text_changing_effects.rs | 1973 ++++++++++++-- 4 files changed, 3828 insertions(+), 486 deletions(-) diff --git a/client/src/components/modal/CardChoiceModal.tsx b/client/src/components/modal/CardChoiceModal.tsx index 76bcf7897e..11e3cb015f 100644 --- a/client/src/components/modal/CardChoiceModal.tsx +++ b/client/src/components/modal/CardChoiceModal.tsx @@ -2914,7 +2914,7 @@ function TextWordReplacementModal({ {data.options.map((option, index) => ( , ) -> Result<(), EffectError> { - let (allowed_categories, excluded_to, duration) = match &ability.effect { - Effect::ChangeTextWords { - allowed_categories, - excluded_to, - duration, - .. - } => ( - allowed_categories.clone(), - excluded_to.clone(), - duration.clone(), - ), - _ => { - return Err(EffectError::InvalidParam( - "expected ChangeTextWords effect".to_string(), - )) - } + let Effect::ChangeTextWords { + allowed_categories, + excluded_to, + duration, + .. + } = &ability.effect + else { + return Err(EffectError::InvalidParam( + "expected ChangeTextWords effect".to_string(), + )); }; // CR 608.2b: the effect needs a legal object target still in its zone. @@ -54,7 +48,7 @@ pub fn resolve( return Ok(()); }; - let options = build_options(state, target, &allowed_categories, &excluded_to); + let options = build_options(state, target, allowed_categories, excluded_to); // CR 609.3: no legal substitution exists — do as much as possible (nothing). if options.is_empty() { @@ -67,7 +61,7 @@ pub fn resolve( source: ability.source_id, target, options, - duration, + duration: duration.clone(), }; events.push(resolved_event(ability)); Ok(()) diff --git a/crates/engine/src/game/text_substitution.rs b/crates/engine/src/game/text_substitution.rs index 331430c526..358d662d80 100644 --- a/crates/engine/src/game/text_substitution.rs +++ b/crates/engine/src/game/text_substitution.rs @@ -30,12 +30,20 @@ use std::sync::Arc; use crate::game::game_object::GameObject; use crate::types::ability::{ - AbilityDefinition, BasicLandType, ContinuousModification, DevotionColors, Effect, FilterProp, - ObjectProperty, QuantityExpr, QuantityRef, StaticCondition, StaticDefinition, TargetFilter, - TextWord, TextWordCategory, TriggerDefinition, TypeFilter, TypedFilter, + AbilityCondition, AbilityCost, AbilityDefinition, ActivationRestriction, + AttackersDeclaredCountSubject, BasicLandType, CardTypeSetSource, ContinuousModification, + CostReduction, CounterSourceRider, DelayedTriggerCondition, DevotionColors, Duration, Effect, + ExiledSpellRider, FilterProp, ObjectProperty, ParsedCondition, PlayerFilter, PtValue, + QuantityExpr, QuantityRef, RepeatContinuation, ReplacementCondition, ReplacementDefinition, + ReplacementMode, StaticCondition, StaticDefinition, TargetFilter, TextWord, TextWordCategory, + TriggerCondition, TriggerConstraint, TriggerDefinition, TypeFilter, TypedFilter, + UnlessPayModifier, UntilCondition, VoteSubject, }; use crate::types::keywords::{HexproofFilter, Keyword, ProtectionTarget}; use crate::types::mana::ManaColor; +use crate::types::statics::{ + BlockExceptionKind, CostPaymentProhibition, HandSizeModification, StaticMode, +}; /// Direction of a text-word walk. pub enum WordCursor<'a> { @@ -154,11 +162,115 @@ pub fn collect_present_words(obj: &GameObject, category: TextWordCategory) -> BT /// (post-layer) word-bearing roots in place. Never descends into name / color / /// mana-cost roots (CR 612.2 structural exclusion). /// -/// Ability *costs* and non-`affected`/`condition`/`modifications` static fields -/// (e.g. `StaticMode`, `attack_defended`) and `AbilityCondition` bodies are an -/// intentional coverage gap: no covered card changes a word buried there, and -/// leaving them out keeps the traversal to the roots CR 612 actually reaches for -/// this class. A future card needing them extends the roots here. +/// CR 612.1 + CR 118.12: ability *costs* (`AbilityDefinition.cost` — Goblin +/// Chirurgeon's "Sacrifice a Goblin"), the resolution `condition` +/// (`AbilityCondition` — "if you control a Goblin"), the `unless_pay` modifier, +/// the `repeat_until` loop predicate, the self-referential `cost_reduction` +/// (count + `ParsedCondition` gate), the `activation_restrictions` +/// (`ParsedCondition` gates), the `activator_filter` / `player_scope` +/// (`PlayerFilter` roots), and the `target_chooser` / `announced_x` roots ARE +/// walked (see [`walk_ability_cost`] / [`walk_ability_condition`] / +/// [`walk_cost_reduction`] / [`walk_activation_restriction`] / +/// [`walk_player_filter`]). Trigger event-shape filters (`valid_card` and its +/// `valid_target` / `valid_source` / `valid_subject_player` siblings), a +/// trigger's `unless_pay`, its intervening-if `condition` (`TriggerCondition`), +/// and its rate-limit `constraint` (`TriggerConstraint`) are walked too. The +/// `PlayerFilter` root is descended into wherever it appears +/// (`AbilityCondition::ScopedPlayerMatches`, `TriggerCondition::DuringPlayersTurn`, +/// `player_scope`, `activator_filter`, and nested self-composed anchors). The +/// static `per_player_condition` (`ParsedCondition`) is walked +/// (see [`walk_static_definition`] / [`walk_parsed_condition`]). +/// +/// CR 614.1: replacement effects (`replacement_definitions`, Root 6) are walked — +/// their event filter, applicability `ReplacementCondition`, resulting ability, +/// damage source / redirect filters, AND their `mode`'s optional/pay-cost +/// `decline` continuation (see [`walk_replacement_definition`] / +/// [`walk_replacement_mode`]). +/// CR 611.2b: a "for as long as [condition]" `Duration` (on `AbilityDefinition` +/// and on the duration-bearing `Effect` variants) is walked via [`walk_duration`]. +/// +/// CR 603.7 + CR 611.2: cross-referenced wrapper enums that embed a walked carrier +/// are recursed too — a delayed trigger's firing `condition` +/// (`DelayedTriggerCondition`, on `CreateDelayedTrigger` and the Feather-style +/// `ExiledSpellRider` return timing) and a counter effect's `source_rider` +/// (`CounterSourceRider::LosesAbilities` — the installed `StaticDefinition` + +/// `Duration`) name their object class in a filter / granted static. +/// +/// Every reachable word-bearing carrier field on a battlefield `GameObject`'s +/// live characteristics is now recursed (a mechanical per-carrier-type sweep of +/// the AST — see the module test-suite). The surfaces below are the ONLY roots +/// left un-walked, and each is genuinely wordless (a pip / marker / core-type, +/// not a color/land/creature WORD used as such), structurally excluded by CR 612, +/// unreachable for battlefield permanents, or a deliberately-red secondary filter: +/// - STRUCTURAL (CR 612.2): the object's name / base name, its Layer-5 `color` +/// field, and its mana cost / mana-symbol pips — not descended into; +/// - PIPS, not color WORDS (CR 107.4): `QuantityRef::ManaSymbolsInManaCost`, +/// `FilterProp::ManaSymbolCount`, `StaticMode::PayLifeAsColoredMana`, the +/// replacement `mana_modification`, and every `AbilityCost::Mana` / keyword +/// mana-symbol cost — a `{R}` symbol is not the word "red"; +/// - MARKER / KIND enums, not printed words: `KeywordKind` +/// (`FilterProp::HasKeywordKind`, `AbilityCost::KeywordCostOfCastSpell`, +/// `StaticMode::AlternativeKeywordCost`), `CoreType` +/// (`TriggerCondition::WasType`, `ReplacementCondition::TokenCoreTypeMatches`), +/// counter kinds (CR 122.1), `SubtypeSet` / `ChosenSubtypeKind` (bulk "all +/// creature types" markers, no single spelled subtype); +/// - the `base_*` printed baselines (`base_abilities`, `base_keywords`, +/// `base_card_types`, `base_trigger/replacement/static_definitions`, …): the +/// layer system re-seeds the live roots from these each pass and re-applies the +/// swap on the live copy (CR 613.1c), so walking the baselines would double- +/// apply — they are deliberately not descended into; +/// - alternate-face / alternate-cast characteristic sets that are NOT the object's +/// current battlefield characteristics: `back_face` (DFC other face), +/// `specialize_faces` (Alchemy specialize faces), `cleave_variant` (spell-only +/// alternate ability set, always `None` on a battlefield permanent) — CR 612 +/// changes the current characteristics only; +/// - `perpetual_mods` (digital-only Alchemy perpetual edits), `stickers` +/// (name/art/P-T stickers — no color/land/creature WORD), and +/// `token_rules_text` (display-only alt text); +/// - EVERY leaf-`Effect` word carrier IS now walked ([`walk_effect`] is an +/// exhaustive `_`-free per-variant match): a leaf effect's own declared +/// target/source filter (via `Effect::target_filter_mut`) AND all its SECONDARY +/// carriers — a secondary `TargetFilter` (`Fight.subject`, `Attach.attachment`, +/// `Behold.filter`, `SearchLibrary.filter`, `MoveCounters.source`, `PayCost.payer`, +/// `ExchangeControl.target_a/b`, `ReturnAsAura.enchant_filter`, …), a subtype +/// `String`/`Vec` (`Amass.subtype`, `Animate.types`/`remove_types`), a +/// `Keyword` (`Animate.keywords`), a `Vec` +/// (`CopySpell.additional_modifications`, `ReturnAsAura.grants`, +/// `AddPendingEntersModifications`, `EachPlayerCopyChosen.copy_modifications`), +/// an `AbilityCost` (`PayCost.cost` — "Sacrifice a Goblin"), a `PlayerFilter` +/// (`StartYourEngines`/`ChangeSpeed.player_scope`, `DamageEachPlayer.player_filter`, +/// `Conjure.library_players`, `ChooseOneOf.chooser`), a `QuantityExpr` +/// count/amount (`Draw.count`, `DealDamage.amount`, `Discover.mana_value_limit`, +/// …) or `PtValue`-wrapped quantity (`Pump.power/toughness`, `Animate.power`), +/// and `UntilCondition::NextMatches` (`ExileFromTopUntil.until`); +/// - the ONLY deliberately-red `Effect` surfaces (coverage stays red rather than +/// silently mis-substituting; no covered card changes a word inside one): +/// the mass-population object `target`/`filter` of every `*All` / +/// population effect (`DestroyAll`/`PumpAll`/`DamageAll.target`/ +/// `ChangeZoneAll.target`/`BounceAll.target`/`CounterAll`/`GainControlAll`/ +/// `GoadAll`/`ExploreAll`/… — but a non-object carrier on the SAME effect, e.g. +/// `PumpAll.power`/`DamageAll.amount`/`ChangeZoneAll.enter_with_counters`, IS +/// walked), `PreventDamage.damage_source_filter`, `CastFromZone.alt_ability_cost`, +/// the token / `CopyTokenOf` / face-down (`FaceDownProfile`) creation-spec fields, +/// the `EpicCopy` resolved-spell snapshot, the specialized non-listed sub-enums +/// (`DamageTargetFilter`/`DamageRedirectTarget`, `GuessSubject`, +/// `PerpetualModification`, `IntensityScope`, `ForEachCategoryAction`, +/// name/label `String`s), and the replacement token-spec / `runtime_execute` +/// fields (see [`walk_replacement_definition`]); +/// - alternative / additional CAST-cost riders on keywords and statics +/// (`AbilityCost` on Evoke/Bestow/…, `StaticMode::CastWithAlternativeCost` / +/// `ImposeAdditionalCost.cost` / `AlternativeKeywordCost.cost` / permission +/// `alt_cost` / `extra_cost`) — a casting cost no covered card text-changes; +/// - the static `attack_defended` field and `StaticCondition::UnlessPay.defended` +/// (an `AttackTargetFilter` — a player / planeswalker / battle defended-scope, +/// no color/land/creature WORD). The static `mode` (`StaticMode`) IS walked +/// (see [`walk_static_mode`]), so its evasion / protection / cost-filter / color +/// params — including a granted `AddStaticMode` and a dynamic `MaximumHandSize` +/// — are covered; +/// - the `CastingRestriction` / `SpellCastingOption` / `CastingPermission` +/// `ParsedCondition` / `Duration` roots, which live on card-level casting +/// options rather than on any battlefield-`GameObject` ability/trigger/static/ +/// replacement walked here (unreachable for this class). pub fn walk_object_words( obj: &mut GameObject, category: TextWordCategory, @@ -189,6 +301,18 @@ pub fn walk_object_words( walk_static_definition(static_def, category, cursor); } } + // Root 6: replacement effects (CR 614). Their event filter (`valid_card`), + // applicability `condition` (`ReplacementCondition`), resulting `execute` + // ability, and damage source / redirect filters are all rules-text carriers + // ("if you control a Forest", "unless you control a Plains", "whenever a + // Goblin would enter"). Re-seeded from `base_replacement_definitions` on each + // layer pass (`GameObject::revert_layered_characteristics_to_base`), exactly + // like the other live roots. + for i in 0..obj.replacement_definitions.len() { + if let Some(replacement) = obj.replacement_definitions.get_mut(i) { + walk_replacement_definition(replacement, category, cursor); + } + } } fn walk_keyword(keyword: &mut Keyword, category: TextWordCategory, cursor: &mut WordCursor) { @@ -199,7 +323,39 @@ fn walk_keyword(keyword: &mut Keyword, category: TextWordCategory, cursor: &mut Keyword::HexproofFrom(filter) => walk_hexproof_filter(filter, category, cursor), // CR 702.14: landwalk names a land type. Keyword::Landwalk(land) => cursor.landwalk(category, land), - // Every other keyword carries no color/land/creature WORD used as such. + // CR 702.5a: "Enchant [quality]" names the object class the Aura attaches + // to — a `TargetFilter` that can carry a creature/land type or color word. + Keyword::Enchant(filter) => walk_target_filter(filter, category, cursor), + // CR 702.167b: "Craft with [type]" — the materials filter names a type. + Keyword::Craft { materials, .. } => walk_target_filter(materials, category, cursor), + // CR 702.41a: "Affinity for [type]" names a permanent type/subtype + // (e.g. Affinity for Plains — a basic land type). + Keyword::Affinity(filter) => walk_typed_filter(filter, category, cursor), + // CR 702.29 / 702.47a / 702.72a / 702.22 / 702.48a: keyword parameters that + // ARE a subtype word used as such — "{subtype}cycling" (Plainscycling names + // a basic land type, Slivercycling a creature type), "Splice onto [subtype]", + // "Champion a [type]", "Bands with other [quality]", "[creature type] + // offering" (Offering — CR 702.48a, "Fox offering" names a creature type). + // All route through the category-disambiguating subtype cursor (the same one + // used for type-line subtypes and `Landwalk`). + Keyword::Typecycling { subtype, .. } + | Keyword::Splice { subtype, .. } + | Keyword::Champion(subtype) + | Keyword::BandsWithOther(subtype) + | Keyword::Offering(subtype) => cursor.subtype(category, subtype), + // CR 702.181a / CR 702.189: Mobilize N / Firebending N carry a dynamic + // count quantity (usually `Fixed`, but a granted "where X is its power" + // form embeds a `QuantityExpr` whose typed `ObjectCount` filter can name a + // creature/land/color word). + Keyword::Mobilize(count) | Keyword::Firebending(count) => { + walk_quantity_expr(count, category, cursor) + } + // The remaining keywords carry no color/land/creature WORD used as such in + // any parameter. A keyword's activation / alternative *cost* (mana pips, or + // an embedded `AbilityCost` on Evoke/Echo/Bestow/Escalate/Cumulative + // upkeep/…) is a secondary carrier left intentionally red — consistent with + // the secondary-cost/filter exclusion documented on [`walk_object_words`]; + // no covered card changes a color/land/creature word inside a keyword cost. Keyword::Flying | Keyword::FirstStrike | Keyword::DoubleStrike @@ -258,7 +414,6 @@ fn walk_keyword(keyword: &mut Keyword, category: TextWordCategory, cursor: &mut | Keyword::Unleash | Keyword::Riot | Keyword::Afterlife(..) - | Keyword::Enchant(..) | Keyword::EtbCounter { .. } | Keyword::Reconfigure(..) | Keyword::LivingWeapon @@ -311,14 +466,11 @@ fn walk_keyword(keyword: &mut Keyword, category: TextWordCategory, cursor: &mut | Keyword::Fortify(..) | Keyword::Prototype { .. } | Keyword::Plot(..) - | Keyword::Craft { .. } | Keyword::Offspring(..) | Keyword::Impending { .. } | Keyword::LevelUp(..) - | Keyword::Affinity(..) | Keyword::CumulativeUpkeep(..) | Keyword::Banding - | Keyword::BandsWithOther(..) | Keyword::Epic | Keyword::Fuse | Keyword::Gravestorm @@ -340,7 +492,6 @@ fn walk_keyword(keyword: &mut Keyword, category: TextWordCategory, cursor: &mut | Keyword::Warp(..) | Keyword::Sneak(..) | Keyword::WebSlinging(..) - | Keyword::Mobilize(..) | Keyword::Gift(..) | Keyword::Discover(..) | Keyword::Spree @@ -366,12 +517,8 @@ fn walk_keyword(keyword: &mut Keyword, category: TextWordCategory, cursor: &mut | Keyword::Soulshift(..) | Keyword::Backup(..) | Keyword::Squad(..) - | Keyword::Typecycling { .. } - | Keyword::Firebending(..) - | Keyword::Splice { .. } | Keyword::Bargain | Keyword::Sunburst - | Keyword::Champion(..) | Keyword::Training | Keyword::Assist | Keyword::Augment @@ -393,7 +540,6 @@ fn walk_keyword(keyword: &mut Keyword, category: TextWordCategory, cursor: &mut | Keyword::Freerunning(..) | Keyword::Increment | Keyword::Specialize(..) - | Keyword::Offering(..) | Keyword::Unknown(..) => {} } } @@ -443,6 +589,18 @@ fn walk_target_filter( walk_target_filter(f, category, cursor); } } + // CR 609.7b: "a [color] source of your choice" — the optional legality + // filter can name a color / type word used as such. + TargetFilter::ChosenDamageSource { filter } => { + if let Some(f) = filter { + walk_target_filter(f, category, cursor); + } + } + // CR 612.2: a tracked set refined by a typed filter (`Typed(Land)`, …) + // carries that nested filter's type word used as such. + TargetFilter::TrackedSetFiltered { filter, .. } => { + walk_target_filter(filter, category, cursor) + } TargetFilter::None | TargetFilter::Any | TargetFilter::Player @@ -453,6 +611,7 @@ fn walk_target_filter( | TargetFilter::StackAbility { .. } | TargetFilter::StackSpell | TargetFilter::SpecificObject { .. } + // NB: `ChosenDamageSource` / `TrackedSetFiltered` are carriers above. | TargetFilter::SpecificPlayer { .. } | TargetFilter::PlayerWhoChoseLabel { .. } | TargetFilter::Neighbor { .. } @@ -463,7 +622,6 @@ fn walk_target_filter( | TargetFilter::CostPaidObject | TargetFilter::ChosenCard | TargetFilter::TrackedSet { .. } - | TargetFilter::TrackedSetFiltered { .. } | TargetFilter::ExiledBySource | TargetFilter::ExiledCardByIndex { .. } | TargetFilter::TriggeringSpellController @@ -484,7 +642,6 @@ fn walk_target_filter( | TargetFilter::PostReplacementDamageTargetOwner | TargetFilter::DefendingPlayer | TargetFilter::HasChosenName - | TargetFilter::ChosenDamageSource { .. } | TargetFilter::Named { .. } | TargetFilter::Owner | TargetFilter::AllPlayers => {} @@ -543,13 +700,34 @@ fn walk_filter_prop(prop: &mut FilterProp, category: TextWordCategory, cursor: & FilterProp::Counters { count, .. } => walk_quantity_expr(count, category, cursor), FilterProp::Cmc { value, .. } => walk_quantity_expr(value, category, cursor), FilterProp::PtComparison { value, .. } => walk_quantity_expr(value, category, cursor), + // CR 109.5: "controlled by a player who controls a Goblin" nests a + // `PlayerFilter` whose control sub-filter can name a type/color word. + FilterProp::ControllerMatches { player } => { + walk_player_filter(player, category, cursor) + } + // CR 609.7: "shares a [quality] with [reference]" — the optional reference + // filter can name a creature type / land type / color word. + FilterProp::SharesQuality { reference, .. } => { + if let Some(f) = reference { + walk_target_filter(f, category, cursor); + } + } + // CR 115.x: "that targets only [filter]" / "that targets [filter]" nests a + // spell-target filter that can name a type. + FilterProp::TargetsOnly { filter } | FilterProp::Targets { filter } => { + walk_target_filter(filter, category, cursor) + } + // CR 201.5 + CR 609.7: the object-identity duals of `SharesQuality` — the + // referenced filter ("with a different name than target Goblin", "distinct + // from target Forest") can name a creature type / land type / color word. + FilterProp::DifferentNameFrom { filter } => walk_target_filter(filter, category, cursor), + FilterProp::DistinctFrom { reference } => walk_target_filter(reference, category, cursor), // CR 612.2 + CR 107.4: `ColorCount` / `ManaSymbolCount` measure set size or // mana pips, not color WORDS — not text-changed. `IsChosenColor` reads a // chosen ref, not a printed word. FilterProp::Token | FilterProp::NonToken | FilterProp::ControllerChoseLabel { .. } - | FilterProp::ControllerMatches { .. } | FilterProp::WasPlayed | FilterProp::Attacking { .. } | FilterProp::Blocking @@ -601,10 +779,7 @@ fn walk_filter_prop(prop: &mut FilterProp, category: TextWordCategory, cursor: & | FilterProp::Modified | FilterProp::Historic | FilterProp::NotHistoric - | FilterProp::DifferentNameFrom { .. } - | FilterProp::DistinctFrom { .. } | FilterProp::InAnyZone { .. } - | FilterProp::SharesQuality { .. } | FilterProp::WasDealtDamageThisTurn | FilterProp::EnteredThisTurn | FilterProp::ControlledContinuouslySinceTurnBegan @@ -615,8 +790,6 @@ fn walk_filter_prop(prop: &mut FilterProp, category: TextWordCategory, cursor: & | FilterProp::CountersPutOnThisTurn { .. } | FilterProp::FaceDown | FilterProp::Transformed - | FilterProp::TargetsOnly { .. } - | FilterProp::Targets { .. } | FilterProp::CouldBeTargetedByTriggeringSpell | FilterProp::HasXInManaCost | FilterProp::HasXInActivationCost @@ -669,8 +842,10 @@ fn walk_static_condition( | StaticCondition::RecipientMatchesFilter { filter } => { walk_target_filter(filter, category, cursor) } - StaticCondition::ChosenColorIs { .. } - | StaticCondition::ChosenLabelIs { .. } + // CR 105 + CR 612.2: "the chosen color is [color]" spells a color WORD used + // as such (sibling to `DevotionGE.colors` / `ManaColorSpent.color`). + StaticCondition::ChosenColorIs { color } => cursor.color(category, color), + StaticCondition::ChosenLabelIs { .. } | StaticCondition::HasMaxSpeed | StaticCondition::SpeedGE { .. } | StaticCondition::DayNightIs { .. } @@ -746,6 +921,44 @@ fn walk_quantity_expr( } } +/// CR 613.4 + CR 612.1: Walk the word-bearing children of a `PtValue`. Only the +/// `Quantity` variant wraps a `QuantityExpr` whose typed `ObjectCount` / +/// `Devotion` reference can name a creature/land/color word ("gets +X/+X where X +/// is the number of Goblins you control"). `Fixed`/`Variable` carry a scalar / an +/// X marker, not a printed word. No `_` wildcard — a future word-bearing `PtValue` +/// variant fails to compile until classified. +fn walk_pt_value(value: &mut PtValue, category: TextWordCategory, cursor: &mut WordCursor) { + match value { + PtValue::Quantity(q) => walk_quantity_expr(q, category, cursor), + PtValue::Fixed(_) | PtValue::Variable(_) => {} + } +} + +/// CR 701.13a + CR 612.1: Walk the word-bearing children of an `UntilCondition` +/// (the `until` axis of `Effect::ExileFromTopUntil`). `NextMatches` carries a +/// `TargetFilter` that can name a creature/land/color word ("exile ... until you +/// exile a Goblin card"); `CumulativeThreshold` carries a `QuantityExpr` threshold +/// and an `ObjectProperty` (the CR-612.2 no-op — power/toughness/mana value). +/// No `_` wildcard — a future word-bearing `UntilCondition` variant fails to +/// compile until classified. +fn walk_until_condition( + until: &mut UntilCondition, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + match until { + UntilCondition::NextMatches { filter } => walk_target_filter(filter, category, cursor), + UntilCondition::CumulativeThreshold { + property, + threshold, + .. + } => { + walk_object_property(property, category, cursor); + walk_quantity_expr(threshold, category, cursor); + } + } +} + fn walk_quantity_ref(qty: &mut QuantityRef, category: TextWordCategory, cursor: &mut WordCursor) { match qty { // CR 700.5: devotion to fixed colors spells color WORDS. @@ -813,13 +1026,30 @@ fn walk_quantity_ref(qty: &mut QuantityRef, category: TextWordCategory, cursor: walk_target_filter(f, category, cursor); } } + // CR 101.2 + CR 109.5: "the number of players who control a Goblin" nests a + // `PlayerFilter` whose control sub-filter can name a type/color word. + QuantityRef::PlayerCount { filter } => walk_player_filter(filter, category, cursor), + // CR 612.2: "unspent [color] mana" spells the color WORD used as such — + // consistent with `StaticMode::StepEndUnspentMana` (`None` is the any-color + // form; contrast the `ManaSymbolsInManaCost` pip-count no-op below). + QuantityRef::UnspentMana { color } => { + if let Some(c) = color { + cursor.color(category, c); + } + } + // CR 205.2a / CR 205.3: distinct-card-type / distinct-subtype counts scan a + // parameterized `CardTypeSetSource` whose `Objects` variant nests a + // `TargetFilter` that can name a type/color word (mirrors `ZoneCardCount`). + QuantityRef::DistinctCardTypes { source } + | QuantityRef::DistinctSubtypes { source, .. } => { + walk_card_type_set_source(source, category, cursor) + } QuantityRef::HandSize { .. } | QuantityRef::LifeTotal { .. } | QuantityRef::GraveyardSize { .. } | QuantityRef::LifeAboveStarting | QuantityRef::StartingLifeTotal | QuantityRef::TriggeringDiscoverValue - | QuantityRef::PlayerCount { .. } | QuantityRef::CountersOn { .. } | QuantityRef::PlayerCounter { .. } | QuantityRef::TargetControllerCounter { .. } @@ -834,8 +1064,6 @@ fn walk_quantity_ref(qty: &mut QuantityRef, category: TextWordCategory, cursor: | QuantityRef::ManaSymbolsInManaCost { .. } | QuantityRef::SelfManaValue | QuantityRef::TargetZoneCardCount { .. } - | QuantityRef::DistinctCardTypes { .. } - | QuantityRef::DistinctSubtypes { .. } | QuantityRef::CardsExiledBySource | QuantityRef::ExiledCardPower { .. } | QuantityRef::BasicLandTypeCount { .. } @@ -844,7 +1072,6 @@ fn walk_quantity_ref(qty: &mut QuantityRef, category: TextWordCategory, cursor: | QuantityRef::PreviousEffectAmount { .. } | QuantityRef::LifeLostThisTurn { .. } | QuantityRef::PartySize { .. } - | QuantityRef::UnspentMana { .. } | QuantityRef::Speed { .. } | QuantityRef::EventContextAmount | QuantityRef::AttachmentsOnLeavingObject { .. } @@ -876,6 +1103,25 @@ fn walk_quantity_ref(qty: &mut QuantityRef, category: TextWordCategory, cursor: } } +/// CR 205.2a / CR 205.3 + CR 612.1: Walk the word-bearing children of a +/// `CardTypeSetSource` scan axis. Only the `Objects` variant nests a battlefield +/// `TargetFilter` that can name a creature type / land type / color word; the +/// zone / linked-exile / tracked-set axes carry no printed word. No `_` wildcard — +/// a future word-bearing `CardTypeSetSource` variant fails to compile until +/// classified. +fn walk_card_type_set_source( + source: &mut CardTypeSetSource, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + match source { + CardTypeSetSource::Objects { filter } => walk_target_filter(filter, category, cursor), + CardTypeSetSource::Zone { .. } + | CardTypeSetSource::ExiledBySource + | CardTypeSetSource::TrackedSet { .. } => {} + } +} + /// CR 612.2 + CR 107.4: object properties reference power/toughness/mana value or /// a mana SYMBOL count — none is a color/land/creature WORD. All no-op; exists so /// a future word-bearing `ObjectProperty` variant must be classified. @@ -892,12 +1138,639 @@ fn walk_object_property( } } +/// CR 612.1 + CR 612.2: Walk the word-bearing children of an activated / additional +/// ability *cost*. A creature type / basic land type / color word can appear in a +/// cost's object filter (CR 701.21 "Sacrifice a Goblin" — Goblin Chirurgeon), in a +/// dynamic quantity (`ManaDynamic` / `PayLife` over a typed `ObjectCount`), or in a +/// nested effect / sub-cost. Filters recurse through the shared [`walk_target_filter`]; +/// quantities through [`walk_quantity_expr`]; nested effects through [`walk_effect`]; +/// the aggregate `ObjectProperty` through the CR-612.2 no-op [`walk_object_property`]. +/// Every variant is classified with no `_` wildcard — a future word-bearing cost +/// variant fails to compile until handled. +fn walk_ability_cost(cost: &mut AbilityCost, category: TextWordCategory, cursor: &mut WordCursor) { + match cost { + // CR 701.21: "Sacrifice a [creature type]" — the sacrifice filter names a + // creature-type / land-type / color word used as such (Goblin Chirurgeon). + AbilityCost::Sacrifice(sac) => walk_target_filter(&mut sac.target, category, cursor), + AbilityCost::Discard { count, filter, .. } => { + walk_quantity_expr(count, category, cursor); + if let Some(f) = filter { + walk_target_filter(f, category, cursor); + } + } + // Optional-filter costs (`RemoveCounter`'s `target` is the same Option shape). + AbilityCost::Exile { filter, .. } + | AbilityCost::ReturnToHand { filter, .. } + | AbilityCost::Reveal { filter, .. } + | AbilityCost::RemoveCounter { target: filter, .. } => { + if let Some(f) = filter { + walk_target_filter(f, category, cursor); + } + } + // Required-filter costs. + AbilityCost::ExileMaterials { + materials: filter, .. + } + | AbilityCost::TapCreatures { filter, .. } + | AbilityCost::UnattachFrom { filter, .. } + | AbilityCost::Behold { filter, .. } => walk_target_filter(filter, category, cursor), + AbilityCost::ExileWithAggregate { + filter, property, .. + } => { + walk_target_filter(filter, category, cursor); + walk_object_property(property, category, cursor); + } + AbilityCost::ManaDynamic { quantity } => walk_quantity_expr(quantity, category, cursor), + AbilityCost::PayLife { amount } + | AbilityCost::PayEnergy { amount } + | AbilityCost::PaySpeed { amount } => walk_quantity_expr(amount, category, cursor), + AbilityCost::Composite { costs } | AbilityCost::OneOf { costs } => { + for c in costs.iter_mut() { + walk_ability_cost(c, category, cursor); + } + } + AbilityCost::PerCounter { target, base, .. } => { + walk_target_filter(target, category, cursor); + walk_ability_cost(base, category, cursor); + } + AbilityCost::EffectCost { effect } => walk_effect(effect, category, cursor), + // CR 612.2 + CR 107.4: mana pips ({R}), tap/untap, loyalty, evidence value, + // a keyword-derived cost, and Waterbend/Ninjutsu mana carry no color / land / + // creature WORD used as such. + AbilityCost::Mana { .. } + | AbilityCost::Tap + | AbilityCost::Untap + | AbilityCost::Loyalty { .. } + | AbilityCost::CollectEvidence { .. } + | AbilityCost::Unattach + | AbilityCost::Mill { .. } + | AbilityCost::Exert + | AbilityCost::Blight { .. } + | AbilityCost::Waterbend { .. } + | AbilityCost::NinjutsuFamily { .. } + | AbilityCost::KeywordCostOfCastSpell { .. } + | AbilityCost::Unimplemented { .. } => {} + } +} + +/// CR 612.1 + CR 612.2: Walk the word-bearing children of an ability's resolution +/// `condition`. A creature type / land type / color word can live inside an anaphoric +/// filter ("if this permanent is a Goblin", "if you control a Goblin"), a keyword +/// param ("if it has islandwalk"), a color-spent gate ("if white mana was spent"), or +/// a nested compound. All filters recurse through [`walk_target_filter`]; keywords +/// through [`walk_keyword`]; quantities through [`walk_quantity_expr`]. No `_` +/// wildcard — a future word-bearing condition variant fails to compile until handled. +fn walk_ability_condition( + condition: &mut AbilityCondition, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + match condition { + // CR 612.2: "if white mana was spent to cast this spell" spells a color WORD. + AbilityCondition::ManaColorSpent { color, .. } => cursor.color(category, color), + // Anaphoric / control / zone-change object filters that can name a type. + AbilityCondition::TargetSharesNameWithOtherExiledThisWay { target: filter } + | AbilityCondition::TargetMatchesFilter { filter, .. } + | AbilityCondition::TriggeringSpellTargetsFilter { filter } + | AbilityCondition::SourceMatchesFilter { filter } + | AbilityCondition::ZoneChangeObjectMatchesFilter { filter, .. } + | AbilityCondition::ControllerControlsMatching { filter } + | AbilityCondition::ControllerControlledMatchingAsCast { filter } + | AbilityCondition::ZoneChangedThisWay { filter } + | AbilityCondition::CostPaidObjectMatchesFilter { filter } => { + walk_target_filter(filter, category, cursor) + } + AbilityCondition::ObjectsShareQuality { + subject, reference, .. + } => { + walk_target_filter(subject, category, cursor); + walk_target_filter(reference, category, cursor); + } + // CR 205.3m: the revealed-card gate can carry a subtype filter and an extra + // filter prop (e.g. a "Kraken … creature card" constraint). + AbilityCondition::RevealedHasCardType { + additional_filter, + subtype_filter, + .. + } => { + if let Some(fp) = additional_filter { + walk_filter_prop(fp, category, cursor); + } + if let Some(f) = subtype_filter { + walk_target_filter(f, category, cursor); + } + } + // CR 702.14 / 702.16 / 702.11d: a keyword param may name a land type / color. + AbilityCondition::TargetHasKeywordInstead { keyword } + | AbilityCondition::SourceLacksKeyword { keyword } => { + walk_keyword(keyword, category, cursor) + } + AbilityCondition::QuantityCheck { lhs, rhs, .. } => { + walk_quantity_expr(lhs, category, cursor); + walk_quantity_expr(rhs, category, cursor); + } + AbilityCondition::PreviousEffectAmount { rhs, .. } => { + walk_quantity_expr(rhs, category, cursor) + } + AbilityCondition::ConditionInstead { inner } => { + walk_ability_condition(inner, category, cursor) + } + // CR 101.2 + CR 109.5: the scoped `PlayerFilter` can embed a + // controls-count / player-attribute sub-filter naming a type/color word + // ("each opponent who controls a Goblin"). + AbilityCondition::ScopedPlayerMatches { filter } => { + walk_player_filter(filter, category, cursor) + } + AbilityCondition::Not { condition } => walk_ability_condition(condition, category, cursor), + AbilityCondition::And { conditions } | AbilityCondition::Or { conditions } => { + for c in conditions.iter_mut() { + walk_ability_condition(c, category, cursor); + } + } + // No color / land / creature WORD used as such: payment flags, phase / timing, + // controller designations, mana-symbol (`ManaCost`) kicker cost, and coin / + // outcome signals. + AbilityCondition::AdditionalCostPaid { .. } + | AbilityCondition::AdditionalCostPaidInstead + | AbilityCondition::AlternativeManaCostPaid + | AbilityCondition::EffectOutcome { .. } + | AbilityCondition::EventOutcomeWon + | AbilityCondition::CoinFlipOutcome { .. } + | AbilityCondition::WhenYouDo + | AbilityCondition::WasCast { .. } + | AbilityCondition::CastDuringPhase { .. } + | AbilityCondition::CurrentPhaseIs { .. } + | AbilityCondition::CastTimingPermission { .. } + | AbilityCondition::SourceEnteredThisTurn + | AbilityCondition::CastVariantPaid { .. } + | AbilityCondition::CastVariantPaidInstead { .. } + | AbilityCondition::HasMaxSpeed + | AbilityCondition::IsMonarch + | AbilityCondition::IsInitiative + | AbilityCondition::HasCityBlessing + | AbilityCondition::IsRingBearer + | AbilityCondition::CompletedDungeon { .. } + | AbilityCondition::HasObjectTarget + | AbilityCondition::IsYourTurn + | AbilityCondition::WasStartingPlayer { .. } + | AbilityCondition::SpellCastWithVariantThisTurn { .. } + | AbilityCondition::FirstCombatPhaseOfTurn + | AbilityCondition::FirstEndStepOfTurn + | AbilityCondition::SourceIsTapped + | AbilityCondition::SourceAttachedToCreature + | AbilityCondition::DayNightIsNeither + | AbilityCondition::DayNightIs { .. } + | AbilityCondition::NthResolutionThisTurn { .. } => {} + } +} + +/// CR 118.12: an "unless [player] pays [cost]" modifier bundles a payment cost +/// (whose object filter can name a type) and a `payer` filter — both walked. +fn walk_unless_pay( + modifier: &mut UnlessPayModifier, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + walk_ability_cost(&mut modifier.cost, category, cursor); + walk_target_filter(&mut modifier.payer, category, cursor); +} + +/// CR 608.2c: a "repeat this process" loop predicate. Only the `WhileCondition` +/// shape carries a filter word (via its `AbilityCondition`); the others hold none. +fn walk_repeat_continuation( + cont: &mut RepeatContinuation, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + match cont { + RepeatContinuation::WhileCondition { condition, .. } => { + walk_ability_condition(condition, category, cursor) + } + RepeatContinuation::ControllerChoice | RepeatContinuation::UntilStopConditions { .. } => {} + } +} + +/// CR 612.1 + CR 612.2: Walk the word-bearing children of a `PlayerFilter` root. +/// CR 109.5: a player filter's control-count / player-attribute sub-filters can +/// name a creature type / land type / color word ("each opponent who controls a +/// Goblin", "each player who controls more Elves than you"); its damage-source +/// gate and its self-composed `AllExcept` anchor are recursion points too. Every +/// non-filter designation (controller / opponent / attacking / triggering +/// anchors) is an explicit no-op. No `_` wildcard — a future word-bearing +/// `PlayerFilter` variant fails to compile until classified. +fn walk_player_filter(pf: &mut PlayerFilter, category: TextWordCategory, cursor: &mut WordCursor) { + match pf { + // CR 120.9: the damage-source qualifier is a `TargetFilter` and can name a type. + PlayerFilter::OpponentDealtDamage { source, .. } => { + if let Some(f) = source { + walk_target_filter(f, category, cursor); + } + } + // CR 608.2h: the exclusion anchor is itself a `PlayerFilter`. + PlayerFilter::AllExcept { exclude } => walk_player_filter(exclude, category, cursor), + // CR 109.5: "each player who controls [comparator] [filter]" — the filter + // and the comparison count both carry word-bearing sub-structure. + PlayerFilter::ControlsCount { filter, count, .. } => { + walk_target_filter(filter, category, cursor); + walk_quantity_expr(count, category, cursor); + } + // CR 119.1 / CR 402.1: the scalar attribute ref and its threshold value + // are quantities that may embed a typed `ObjectCount`. + PlayerFilter::PlayerAttribute { attr, value, .. } => { + walk_quantity_ref(attr, category, cursor); + walk_quantity_expr(value, category, cursor); + } + // No color / land / creature WORD used as such: controller / opponent / + // defending designations, attack / trigger / vote / chosen-player anchors. + PlayerFilter::Controller + | PlayerFilter::Opponent + | PlayerFilter::DefendingPlayer + | PlayerFilter::OpponentLostLife + | PlayerFilter::OpponentGainedLife + | PlayerFilter::HasLostTheGame + | PlayerFilter::OpponentAttacked { .. } + | PlayerFilter::OpponentAttackingEnchantedPlayer + | PlayerFilter::All + | PlayerFilter::HighestSpeed + | PlayerFilter::ZoneChangedThisWay + | PlayerFilter::PerformedActionThisWay { .. } + | PlayerFilter::OwnersOfCardsExiledBySource + | PlayerFilter::TriggeringPlayer + | PlayerFilter::OpponentOtherThanTriggering + | PlayerFilter::OpponentOfTriggeringPlayer + | PlayerFilter::OpponentOfTriggeringPlayerNotAttacked + | PlayerFilter::VotedFor { .. } + | PlayerFilter::ParentObjectTargetController + | PlayerFilter::ChosenPlayer { .. } + | PlayerFilter::ParentObjectTargetOwner => {} + } +} + +/// CR 612.1 + CR 603.4: Walk the word-bearing children of a trigger's +/// intervening-if `TriggerCondition`. A creature type / land type / color word +/// can live in a control / event-subject / cast-history filter +/// ("if you control a Goblin", "if it targets a Goblin", "if white mana was +/// spent"), a nested `PlayerFilter` ("during that player's turn"), a quantity +/// comparison, or a composite And/Or/Not. Every filter recurses via +/// [`walk_target_filter`]; `ManaColorSpent` is a color-word carrier; player and +/// quantity references delegate to their walkers. No `_` wildcard — a future +/// word-bearing `TriggerCondition` variant fails to compile until classified. +fn walk_trigger_condition( + cond: &mut TriggerCondition, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + match cond { + // CR 603.4: control / defending-player presence gates naming a type. + TriggerCondition::ControlsType { filter } + | TriggerCondition::ControlCount { filter, .. } + | TriggerCondition::ControlsNone { filter } + | TriggerCondition::DefendingPlayerControlsNone { filter } => { + walk_target_filter(filter, category, cursor) + } + // CR 120.1: damage-source / event-subject / cast-spell gates naming a type. + TriggerCondition::DealtDamageThisTurnBySource { source } => { + walk_target_filter(source, category, cursor) + } + TriggerCondition::ZoneChangeObjectMatchesFilter { filter, .. } + | TriggerCondition::SourceMatchesFilter { filter } + | TriggerCondition::EventDamageSourceMatchesFilter { filter } + | TriggerCondition::EventObjectMatchesFilter { filter } + | TriggerCondition::TriggeringSpellTargetsFilter { filter } + | TriggerCondition::TriggeringSpellMatchesFilter { filter } => { + walk_target_filter(filter, category, cursor) + } + // CR 506.5 / CR 603.4: optional co-attacker and cast-spell filters. + TriggerCondition::MinCoAttackers { filter, .. } + | TriggerCondition::CastSpellThisTurn { filter } => { + if let Some(f) = filter { + walk_target_filter(f, category, cursor); + } + } + // CR 102.1: "if it's [player]'s turn" nests a `PlayerFilter`. + TriggerCondition::DuringPlayersTurn { player } => { + walk_player_filter(player, category, cursor) + } + // CR 207.2c: "if [N] mana of [color] was spent" spells a color WORD. + TriggerCondition::ManaColorSpent { color, .. } => cursor.color(category, color), + // CR 508.1: the attackers-declared count subject carries an optional + // condition-level type filter naming a creature type used as such — "if + // two or more Pirates attacked this combat" counts only Pirate attackers. + // Both subject axes (`Controller` / `AttackTarget`) hold the same + // `Option`, so both are recursion points. + TriggerCondition::AttackersDeclaredCount { subject, .. } => match subject { + AttackersDeclaredCountSubject::Controller { filter, .. } + | AttackersDeclaredCountSubject::AttackTarget { filter, .. } => { + if let Some(f) = filter { + walk_target_filter(f, category, cursor); + } + } + }, + TriggerCondition::QuantityComparison { lhs, rhs, .. } => { + walk_quantity_expr(lhs, category, cursor); + walk_quantity_expr(rhs, category, cursor); + } + TriggerCondition::And { conditions } | TriggerCondition::Or { conditions } => { + for c in conditions.iter_mut() { + walk_trigger_condition(c, category, cursor); + } + } + TriggerCondition::Not { condition } => walk_trigger_condition(condition, category, cursor), + // CR 400.7 / CR 111.1: `WasType`/`HadCounters` name a CORE type / counter + // kind, not a color/land/creature WORD (CR 612.2). Every remaining variant + // is a phase / timing / designation / event-shape / ordinal predicate with + // no printed color/land/creature word used as such. + TriggerCondition::GainedLife { .. } + | TriggerCondition::LostLife + | TriggerCondition::Descended + | TriggerCondition::NoSpellsCastLastTurn + | TriggerCondition::TwoOrMoreSpellsCastLastTurn + | TriggerCondition::SourceEnteredThisTurn + | TriggerCondition::EchoDue + | TriggerCondition::SolveConditionMet + | TriggerCondition::ClassLevelGE { .. } + | TriggerCondition::SourceIsHarnessed + | TriggerCondition::AttractionVisitRoll { .. } + | TriggerCondition::WasCast { .. } + | TriggerCondition::WasPlayed + | TriggerCondition::AdditionalCostPaid { .. } + | TriggerCondition::SourceIsAttacking + | TriggerCondition::CastVariantPaid { .. } + | TriggerCondition::CastVariantPaidPersistent { .. } + | TriggerCondition::ActivatedAbilityIsNonMana + | TriggerCondition::DealtDamageBySourceThisTurn + | TriggerCondition::FirstTimeObjectTappedThisTurn + | TriggerCondition::FirstTimeObjectCountersAddedThisTurn + | TriggerCondition::WasType { .. } + | TriggerCondition::LifeTotalGE { .. } + | TriggerCondition::AttackedThisTurn + | TriggerCondition::FirstCombatPhaseOfTurn + | TriggerCondition::HasMaxSpeed + | TriggerCondition::IsMonarch + | TriggerCondition::IsInitiative + | TriggerCondition::NoMonarch + | TriggerCondition::WasStartingPlayer { .. } + | TriggerCondition::SpellCastWithVariantThisTurn { .. } + | TriggerCondition::HasCityBlessing + | TriggerCondition::CompletedDungeon { .. } + | TriggerCondition::SourceIsTapped + | TriggerCondition::SourceIsTransformed + | TriggerCondition::SourceIsFaceUp + | TriggerCondition::SourceIsFaceDown + | TriggerCondition::SourceInZone { .. } + | TriggerCondition::CounterAddedThisTurn + | TriggerCondition::LostLifeLastTurn + | TriggerCondition::TributeNotPaid + | TriggerCondition::CastDuringPhase { .. } + | TriggerCondition::CastTimingPermission { .. } + | TriggerCondition::ManaSpentCondition { .. } + | TriggerCondition::HadCounters { .. } + | TriggerCondition::ControlsCommander { .. } + | TriggerCondition::IsRenowned { .. } + | TriggerCondition::HasCounters { .. } + | TriggerCondition::ZoneChangeObjectIsTapped + | TriggerCondition::DamagedPlayerIsEventSourceOwner + | TriggerCondition::ChosenLabelIs { .. } + | TriggerCondition::ExceptFirstDrawInDrawStep + | TriggerCondition::PlacedByAbilitySource => {} + } +} + +/// CR 612.1 + CR 603.4: Walk the word-bearing children of a `TriggerConstraint` +/// rate-limiter. Only `NthSpellThisTurn` carries an optional spell `TargetFilter` +/// ("your Nth noncreature spell"); every other constraint is a pure count / +/// timing / controller gate. No `_` wildcard. +fn walk_trigger_constraint( + c: &mut TriggerConstraint, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + match c { + TriggerConstraint::NthSpellThisTurn { filter, .. } => { + if let Some(f) = filter { + walk_target_filter(f, category, cursor); + } + } + TriggerConstraint::OncePerTurn + | TriggerConstraint::OncePerGame + | TriggerConstraint::OnlyDuringYourTurn + | TriggerConstraint::NthDrawThisTurn { .. } + | TriggerConstraint::OnlyDuringOpponentsTurn + | TriggerConstraint::OnlyDuringYourMainPhase + | TriggerConstraint::AtClassLevel { .. } + | TriggerConstraint::MaxTimesPerTurn { .. } + | TriggerConstraint::OncePerOpponentPerTurn + | TriggerConstraint::EventSourceControlledBy { .. } => {} + } +} + +/// CR 612.1 + CR 601.3 / CR 602.5: Walk the word-bearing children of a parsed +/// cast/activation restriction condition (`StaticDefinition.per_player_condition`, +/// `CostReduction.condition`, `ActivationRestriction::RequiresCondition`). A +/// creature type / land type / color word can live in a subtype/color/keyword +/// leaf ("you control a Forest", "an artifact creature", "a creature with +/// flying"), a `TargetFilter`, a nested `PlayerFilter`, a quantity comparison, or +/// a composite And/Or/Not. No `_` wildcard — a future word-bearing +/// `ParsedCondition` variant fails to compile until classified. +fn walk_parsed_condition( + condition: &mut ParsedCondition, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + match condition { + // CR 205.3: land / creature subtype leaves naming a type WORD. + ParsedCondition::ZoneSubtypeCardCountAtLeast { subtype, .. } + | ParsedCondition::YouControlSubtypeCountAtLeast { subtype, .. } + | ParsedCondition::YouControlSubtypeOrGraveyardCardSubtype { subtype } => { + cursor.subtype(category, subtype) + } + ParsedCondition::YouControlLandSubtypeAny { subtypes } => { + for s in subtypes.iter_mut() { + cursor.subtype(category, s); + } + } + // CR 105: color-word leaves. + ParsedCondition::SourceIsColor { color } + | ParsedCondition::YouControlColorPermanentCountAtLeast { color, .. } => { + cursor.color(category, color) + } + // CR 702.x: keyword params can name a land type (landwalk) / color (protection). + ParsedCondition::SourceLacksKeyword { keyword } + | ParsedCondition::ControlsCreatureWithKeyword { keyword, .. } => { + walk_keyword(keyword, category, cursor) + } + // Required / optional object filters. + ParsedCondition::BattlefieldEntriesThisTurn { filter, .. } + | ParsedCondition::SpellTargetsFilter { filter } => { + walk_target_filter(filter, category, cursor) + } + ParsedCondition::YouAttackedWithAtLeast { filter, .. } + | ParsedCondition::YouCastSpellThisTurn { filter } => { + if let Some(f) = filter { + walk_target_filter(f, category, cursor); + } + } + // CR 602.5b: a nested player filter can name a controlled type. + ParsedCondition::PlayerCountAtLeast { filter, .. } => { + walk_player_filter(filter, category, cursor) + } + // Quantity comparisons — `QuantityVsEachOpponent` compares two refs. + ParsedCondition::QuantityVsEachOpponent { lhs, rhs, .. } => { + walk_quantity_ref(lhs, category, cursor); + walk_quantity_ref(rhs, category, cursor); + } + ParsedCondition::QuantityComparison { lhs, rhs, .. } => { + walk_quantity_expr(lhs, category, cursor); + walk_quantity_expr(rhs, category, cursor); + } + ParsedCondition::And { conditions } | ParsedCondition::Or { conditions } => { + for c in conditions.iter_mut() { + walk_parsed_condition(c, category, cursor); + } + } + ParsedCondition::Not { condition } => walk_parsed_condition(condition, category, cursor), + // CR 612.2: core-type / zone / count / timing / power / life / named + // predicates carry no color/land/creature WORD used as such. (`ZoneCore*`, + // `YouControl*CoreType*`, `*NamedPlaneswalker`, `*NamedCreature` name a + // CORE type or a card NAME, not a subtype word — CR 205.2/CR 201.) + ParsedCondition::SourceInZone { .. } + | ParsedCondition::SourceIsAttacking + | ParsedCondition::SourceIsAttackingOrBlocking + | ParsedCondition::SourceIsBlocked + | ParsedCondition::SourcePowerAtLeast { .. } + | ParsedCondition::SourceHasCounterAtLeast { .. } + | ParsedCondition::SourceHasNoCounter { .. } + | ParsedCondition::SourceEnteredThisTurn + | ParsedCondition::SourceAttackedThisTurn + | ParsedCondition::SourceIsCreature + | ParsedCondition::SourceAttachedTo { .. } + | ParsedCondition::SourceUntappedAttachedTo { .. } + | ParsedCondition::FirstSpellThisGame + | ParsedCondition::OpponentSearchedLibraryThisTurn + | ParsedCondition::BeenAttackedThisStep + | ParsedCondition::ZoneCardCountAtLeast { .. } + | ParsedCondition::ZoneCardTypeCountAtLeast { .. } + | ParsedCondition::ZoneCoreTypeCardCountAtLeast { .. } + | ParsedCondition::OpponentPoisonAtLeast { .. } + | ParsedCondition::HandSizeExact { .. } + | ParsedCondition::HandSizeOneOf { .. } + | ParsedCondition::CreaturesYouControlTotalPowerAtLeast { .. } + | ParsedCondition::YouControlCoreTypeCountAtLeast { .. } + | ParsedCondition::YouControlLegendaryCreature + | ParsedCondition::YouControlNamedPlaneswalker { .. } + | ParsedCondition::YouControlCreatureWithPowerAtLeast { .. } + | ParsedCondition::YouControlCreatureWithPt { .. } + | ParsedCondition::YouControlAnotherColorlessCreature + | ParsedCondition::YouControlSnowPermanentCountAtLeast { .. } + | ParsedCondition::YouControlDifferentPowerCreatureCountAtLeast { .. } + | ParsedCondition::YouControlLandsWithSameNameAtLeast { .. } + | ParsedCondition::YouControlNoCreatures + | ParsedCondition::YouAttackedThisTurn + | ParsedCondition::YouAttackedSourceControllerThisTurn + | ParsedCondition::YouPlayedLandThisTurn + | ParsedCondition::YouCastNoncreatureSpellThisTurn + | ParsedCondition::YouCastSpellCountAtLeast { .. } + | ParsedCondition::YouGainedLifeThisTurn + | ParsedCondition::YouCreatedTokenThisTurn + | ParsedCondition::YouDiscardedCardThisTurn + | ParsedCondition::YouSacrificedArtifactThisTurn + | ParsedCondition::CreatureDiedThisTurn + | ParsedCondition::YouHadCreatureEnterThisTurn + | ParsedCondition::YouHadAngelOrBerserkerEnterThisTurn + | ParsedCondition::YouHadArtifactEnterThisTurn + | ParsedCondition::CardsLeftYourGraveyardThisTurnAtLeast { .. } + | ParsedCondition::HasCityBlessing + | ParsedCondition::IsYourTurn => {} + } +} + +/// CR 601.2f + CR 602.2b: Walk a `CostReduction`'s word-bearing children — the +/// counted quantity (a typed `ObjectCount` filter) and the optional conditional +/// gate (`ParsedCondition`). +fn walk_cost_reduction( + reduction: &mut CostReduction, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + walk_quantity_expr(&mut reduction.count, category, cursor); + if let Some(condition) = &mut reduction.condition { + walk_parsed_condition(condition, category, cursor); + } +} + +/// CR 602.5 + CR 612.1: Walk an activation restriction's word-bearing children. +/// Only `RequiresCondition` carries a `ParsedCondition`; every other restriction +/// is a count / timing / designation gate. No `_` wildcard. +fn walk_activation_restriction( + restriction: &mut ActivationRestriction, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + match restriction { + ActivationRestriction::RequiresCondition { condition } => { + if let Some(cond) = condition { + walk_parsed_condition(cond, category, cursor); + } + } + ActivationRestriction::AsSorcery + | ActivationRestriction::AsInstant + | ActivationRestriction::DuringYourTurn + | ActivationRestriction::DuringYourUpkeep + | ActivationRestriction::DuringCombat + | ActivationRestriction::BeforeAttackersDeclared + | ActivationRestriction::BeforeCombatDamage + | ActivationRestriction::OnlyOnceEachTurn + | ActivationRestriction::OnlyOnce + | ActivationRestriction::MaxTimesEachTurn { .. } + | ActivationRestriction::IsSolved + | ActivationRestriction::SourceIsHarnessed + | ActivationRestriction::ClassLevelIs { .. } + | ActivationRestriction::LevelCounterRange { .. } + | ActivationRestriction::CounterThreshold { .. } + | ActivationRestriction::MatchesCardCastTiming => {} + } +} + +/// CR 611.2b + CR 612.1: Walk the word-bearing children of an effect/ability +/// `Duration`. Only the `ForAsLongAs` shape carries a rules-text condition +/// (`StaticCondition`) that can name a creature type / land type / color word +/// used as such ("for as long as you control a Goblin", "as long as you control +/// a Forest"); every other duration is a pure turn / phase / permanence marker +/// with no printed word. No `_` wildcard — a future word-bearing `Duration` +/// variant fails to compile until classified. +fn walk_duration(dur: &mut Duration, category: TextWordCategory, cursor: &mut WordCursor) { + match dur { + Duration::ForAsLongAs { condition } => walk_static_condition(condition, category, cursor), + Duration::UntilEndOfTurn + | Duration::UntilEndOfCombat + | Duration::UntilNextTurnOf { .. } + | Duration::UntilEndOfNextTurnOf { .. } + | Duration::UntilHostLeavesPlay + | Duration::UntilNextStepOf { .. } + | Duration::UntilSourceExilesAnotherCard + | Duration::Permanent => {} + } +} + fn walk_ability_definition( ability: &mut AbilityDefinition, category: TextWordCategory, cursor: &mut WordCursor, ) { walk_effect(&mut ability.effect, category, cursor); + // CR 612.1 + CR 118.12: an ability's activation/additional cost, its + // resolution condition, and its unless-pay modifier are rules-text carriers — + // "Sacrifice a Goblin" / "if you control a Goblin" / "unless you sacrifice a + // Goblin" all name a creature type used as a creature type (CR 612.2). + if let Some(cost) = &mut ability.cost { + walk_ability_cost(cost, category, cursor); + } + if let Some(condition) = &mut ability.condition { + walk_ability_condition(condition, category, cursor); + } + if let Some(unless) = &mut ability.unless_pay { + walk_unless_pay(unless, category, cursor); + } if let Some(sub) = &mut ability.sub_ability { walk_ability_definition(sub, category, cursor); } @@ -910,6 +1783,44 @@ fn walk_ability_definition( if let Some(repeat) = &mut ability.repeat_for { walk_quantity_expr(repeat, category, cursor); } + // CR 608.2c: a "repeat this process while " predicate may gate on a + // typed filter ("if the exiled card is a land card, repeat this process"). + if let Some(repeat_until) = &mut ability.repeat_until { + walk_repeat_continuation(repeat_until, category, cursor); + } + // CR 601.2f + CR 602.2b: a self-referential cost reduction ("costs {N} less + // for each [filter]" / "... if [condition]") carries a typed count and a + // `ParsedCondition` gate. + if let Some(reduction) = &mut ability.cost_reduction { + walk_cost_reduction(reduction, category, cursor); + } + // CR 602.5: an "activate only if [condition]" restriction carries a + // `ParsedCondition` naming a type/color/keyword. + for restriction in ability.activation_restrictions.iter_mut() { + walk_activation_restriction(restriction, category, cursor); + } + // CR 602.2a + CR 109.5: the activator and per-player scope roots are + // `PlayerFilter`s whose control-count / attribute sub-filters can name a type. + if let Some(activator) = &mut ability.activator_filter { + walk_player_filter(activator, category, cursor); + } + if let Some(scope) = &mut ability.player_scope { + walk_player_filter(scope, category, cursor); + } + // CR 601.2c + CR 601.2b: the target-chooser filter and the announced-X + // quantity are word-bearing roots (a "target chooser" or measured-X + // expression can embed a typed filter). + if let Some(chooser) = &mut ability.target_chooser { + walk_target_filter(chooser, category, cursor); + } + if let Some(announced_x) = &mut ability.announced_x { + walk_quantity_expr(announced_x, category, cursor); + } + // CR 611.2b: a "for as long as [condition]" duration gates the ability's + // continuous effect on a word-bearing `StaticCondition`. + if let Some(duration) = &mut ability.duration { + walk_duration(duration, category, cursor); + } } fn walk_trigger_definition( @@ -920,56 +1831,496 @@ fn walk_trigger_definition( if let Some(execute) = &mut trigger.execute { walk_ability_definition(execute, category, cursor); } + // CR 603.2: the trigger's event-shape filters name the object classes that fire + // it — a creature type / land type / color word ("whenever a Goblin you control + // dies", "whenever a red creature attacks") lives in `valid_card` and its + // sibling target/source/subject filters. if let Some(valid_card) = &mut trigger.valid_card { walk_target_filter(valid_card, category, cursor); } -} - -fn walk_static_definition( - static_def: &mut StaticDefinition, - category: TextWordCategory, - cursor: &mut WordCursor, -) { - if let Some(affected) = &mut static_def.affected { - walk_target_filter(affected, category, cursor); + if let Some(valid_target) = &mut trigger.valid_target { + walk_target_filter(valid_target, category, cursor); } - if let Some(condition) = &mut static_def.condition { - walk_static_condition(condition, category, cursor); + if let Some(valid_source) = &mut trigger.valid_source { + walk_target_filter(valid_source, category, cursor); } - for modification in static_def.modifications.iter_mut() { - walk_continuous_modification(modification, category, cursor); + if let Some(valid_subject_player) = &mut trigger.valid_subject_player { + walk_target_filter(valid_subject_player, category, cursor); + } + // CR 603.2: a disjunctive zone-change trigger ("whenever a Goblin dies or a + // Goblin card is put into a graveyard from anywhere") names its object class + // in each clause's `valid_card`, not only the top-level filter. + for clause in trigger.zone_change_clauses.iter_mut() { + if let Some(valid_card) = &mut clause.valid_card { + walk_target_filter(valid_card, category, cursor); + } + } + // CR 118.12: a tax trigger's unless-pay bundles a cost + payer filter. + if let Some(unless) = &mut trigger.unless_pay { + walk_unless_pay(unless, category, cursor); + } + // CR 603.4: the intervening-if `TriggerCondition` ("... if you control a + // Goblin, ...") carries word-bearing control / event-subject / color filters. + if let Some(condition) = &mut trigger.condition { + walk_trigger_condition(condition, category, cursor); + } + // CR 603.4: a rate-limit `TriggerConstraint` ("... your Nth [type] spell ...") + // can carry a spell filter naming a type. + if let Some(constraint) = &mut trigger.constraint { + walk_trigger_constraint(constraint, category, cursor); } } -fn walk_continuous_modification( - modification: &mut ContinuousModification, - category: TextWordCategory, - cursor: &mut WordCursor, -) { - match modification { - ContinuousModification::SetColor { colors } => { - for c in colors.iter_mut() { - cursor.color(category, c); - } - } - ContinuousModification::AddColor { color } => cursor.color(category, color), - ContinuousModification::SetBasicLandType { land_type } => { - cursor.basic_land_type(category, land_type) - } - ContinuousModification::AddSubtype { subtype } - | ContinuousModification::RemoveSubtype { subtype } => cursor.subtype(category, subtype), - ContinuousModification::AddKeyword { keyword } - | ContinuousModification::RemoveKeyword { keyword } => { - walk_keyword(keyword, category, cursor) +/// CR 612.1 + CR 612.2: Walk the word-bearing children of a `StaticMode`. A +/// creature type / land type / color word can live in an evasion / block / +/// attachment filter ("can't be blocked by Goblins", "can be attached only to a +/// legendary creature"), a protection quality ("protection from the chosen +/// color"), a keyword parameter ("can't have flying"/landwalk), a cost-scope +/// spell filter ("black permanent spells cost less", "creature spells you cast +/// have convoke"), a spelled-out color word ("green permanent spells", "unspent +/// green mana"), a landwalk qualifier, or a nested quantity / player filter. +/// Filters recurse via [`walk_target_filter`]; keywords via [`walk_keyword`]; +/// colors via [`WordCursor::color`]; the landwalk qualifier via +/// [`WordCursor::subtype`]. No `_` wildcard — a future word-bearing `StaticMode` +/// variant fails to compile until classified. +/// +/// Intentionally NOT walked (coverage stays red, consistent with the keyword / +/// secondary-cost exclusion on [`walk_object_words`]): a static's alternative / +/// additional CAST-cost riders (`CastWithAlternativeCost.cost`, +/// `ImposeAdditionalCost.cost`, `AlternativeKeywordCost.cost`, the permission +/// `alt_cost` / `extra_cost` fields) — a mana-symbol / keyword casting cost that +/// no covered card text-changes into a creature/land/color word. `PayLifeAsColoredMana` +/// names a mana SYMBOL ({B}), not a color word (CR 612.2 + CR 107.4). +fn walk_static_mode(mode: &mut StaticMode, category: TextWordCategory, cursor: &mut WordCursor) { + match mode { + // -- Evasion / block / attachment filters (CR 509.1b / 301.5 / 303.4) -- + StaticMode::CantBeActivated { source_filter, .. } + | StaticMode::AttachmentRestriction { + filter: source_filter, } - ContinuousModification::GrantAbility { definition } => { - walk_ability_definition(definition, category, cursor) + | StaticMode::CantBeBlockedBy { + filter: source_filter, } - ContinuousModification::GrantStaticAbility { definition } => { - walk_static_definition(definition, category, cursor) + | StaticMode::BlockRestriction { + filter: source_filter, } - ContinuousModification::GrantTrigger { trigger } => { - walk_trigger_definition(trigger, category, cursor) + | StaticMode::SuppressTriggers { source_filter, .. } + | StaticMode::MaxUntapPerType { + filter: source_filter, + .. + } => walk_target_filter(source_filter, category, cursor), + // CR 509.1b: "can't be blocked except by [quality]" nests a `TargetFilter`. + StaticMode::CantBeBlockedExceptBy { kind } => match kind { + BlockExceptionKind::Quality(filter) => walk_target_filter(filter, category, cursor), + BlockExceptionKind::MinBlockers { .. } => {} + }, + // CR 509.1c: positive block requirements carry an optional blocker filter. + StaticMode::MustBeBlocked { by: filter } + | StaticMode::MustBeBlockedByAll { blockers: filter } + | StaticMode::PerTurnCastLimit { + spell_filter: filter, + .. + } => { + if let Some(f) = filter { + walk_target_filter(f, category, cursor); + } + } + // CR 601.2f: cost-scope spell filter + dynamic multiplier. + StaticMode::ModifyCost { + spell_filter, + dynamic_count, + .. + } => { + if let Some(f) = spell_filter { + walk_target_filter(f, category, cursor); + } + if let Some(q) = dynamic_count { + walk_quantity_ref(q, category, cursor); + } + } + // CR 601.2f + CR 118.8: additional-cost tax carries a spell filter (the + // AbilityCost rider is a secondary cost left red, see the doc note above). + StaticMode::ImposeAdditionalCost { spell_filter, .. } => { + if let Some(f) = spell_filter { + walk_target_filter(f, category, cursor); + } + } + // CR 601.2f + CR 602.2: ability-cost reduction carries a dynamic multiplier + // and an optional activator `PlayerFilter`. + StaticMode::ReduceAbilityCost { + dynamic_count, + activator, + .. + } => { + if let Some(q) = dynamic_count { + walk_quantity_ref(q, category, cursor); + } + if let Some(p) = activator { + walk_player_filter(p, category, cursor); + } + } + // CR 118.3 + CR 601.2h: "can't sacrifice [filter] to pay costs" nests a filter. + StaticMode::CantPayCost { cost, .. } => match cost { + CostPaymentProhibition::Sacrifice { filter } => { + walk_target_filter(filter, category, cursor) + } + CostPaymentProhibition::PayLife => {} + }, + // CR 609.4b: any-color spend permission carries two scope filters. + StaticMode::SpendManaAsAnyColor { + spell_filter, + activation_source_filter, + } => { + if let Some(f) = spell_filter { + walk_target_filter(f, category, cursor); + } + if let Some(f) = activation_source_filter { + walk_target_filter(f, category, cursor); + } + } + // CR 702.51a / CR 702.x: keyword parameters (landwalk land type, protection + // color, "can't have [keyword]"). + StaticMode::CastWithKeyword { keyword } | StaticMode::CantHaveKeyword { keyword } => { + walk_keyword(keyword, category, cursor) + } + // CR 702.16: player protection quality can name a color. + StaticMode::PlayerProtection(target) => walk_protection_target(target, category, cursor), + // CR 118.12a: "[color] permanent spells cost less" spells a color WORD. + StaticMode::DefilerCostReduction { color, .. } => cursor.color(category, color), + // CR 612.2: "unspent [color] mana" (Omnath) spells a color WORD used as + // such (contrast the {B} mana-symbol no-op below); `None` is the any-color + // form. + StaticMode::StepEndUnspentMana { filter, .. } => { + if let Some(c) = filter { + cursor.color(category, c); + } + } + // CR 702.14d: landwalk-cancel qualifier is a basic-land-type name ("Swamp"). + StaticMode::IgnoreLandwalkForBlocking { qualifier } => { + if let Some(q) = qualifier { + cursor.subtype(category, q); + } + } + // CR 508 + CR 612.1: "your maximum hand size is equal to [quantity]" — the + // dynamic `EqualTo` quantity can embed a typed `ObjectCount` filter naming a + // type/color word. The `SetTo`/`AdjustedBy` forms are constant scalars. + StaticMode::MaximumHandSize { modification } => { + if let HandSizeModification::EqualTo(qty) = modification { + walk_quantity_expr(qty, category, cursor); + } + } + // No color / land / creature WORD used as such: nullary keyword/evasion + // markers, count / timing / designation gates, cast-permission scaffolding + // (its cost riders are red — see doc), core-type / counter-type / name / + // mana-symbol carriers, and player-scope prohibitions. + StaticMode::Continuous + | StaticMode::DamageNotRemovedDuringCleanup + | StaticMode::CantAttack + | StaticMode::CantBlock + | StaticMode::CantAttackOrBlock + | StaticMode::AttackOnlyNeighbor + | StaticMode::CantBecomeSuspected + | StaticMode::MaxAttackersEachCombat { .. } + | StaticMode::MaxBlockersEachCombat { .. } + | StaticMode::CantBeTargeted + | StaticMode::CantBeCast { .. } + | StaticMode::CantSearchLibrary { .. } + | StaticMode::RestrictLibrarySearchToTop { .. } + | StaticMode::CantCauseSacrificeOrExile { .. } + | StaticMode::CastWithFlash + | StaticMode::GrantsExtraVote + | StaticMode::GrantsExtraVillainousChoice + | StaticMode::CastWithAlternativeCost { .. } + | StaticMode::AlternativeKeywordCost { .. } + | StaticMode::ReduceActionCost { .. } + | StaticMode::ModifyActivationLimit { .. } + | StaticMode::ActivateAsInstant { .. } + | StaticMode::CantGainLife + | StaticMode::CantLoseLife + | StaticMode::MustAttack + | StaticMode::MustAttackPlayer { .. } + | StaticMode::MustBlock + | StaticMode::MustBlockAttacker { .. } + | StaticMode::CantDraw { .. } + | StaticMode::DrawFromBottom { .. } + | StaticMode::DoubleTriggers { .. } + | StaticMode::IgnoreHexproof + | StaticMode::ExtraBlockers { .. } + | StaticMode::RevealTopOfLibrary { .. } + | StaticMode::RevealHand { .. } + | StaticMode::GraveyardCastPermission { .. } + | StaticMode::TopOfLibraryCastPermission { .. } + | StaticMode::TopOfLibraryHasPlot + | StaticMode::TopOfLibraryPlotPermission + | StaticMode::CastFromHandFree { .. } + | StaticMode::ExileCastPermission { .. } + | StaticMode::LinkedCollectionCounterPlayPermission + | StaticMode::CountersPersistAcrossZones { .. } + | StaticMode::CantBeCountered + | StaticMode::CantBeCopied + | StaticMode::CantEnterBattlefieldFrom + | StaticMode::CantCastFrom { .. } + | StaticMode::CantCastDuring { .. } + | StaticMode::CantActivateDuring { .. } + | StaticMode::PerTurnDrawLimit { .. } + | StaticMode::CantBeBlocked + | StaticMode::CantBeBlockedByMoreThan { .. } + | StaticMode::CantBeBlockedUnlessAllBlock + | StaticMode::Protection + | StaticMode::Indestructible + | StaticMode::CantBeDestroyed + | StaticMode::CantBeRegenerated + | StaticMode::FlashBack + | StaticMode::Shroud + | StaticMode::Hexproof + | StaticMode::Vigilance + | StaticMode::Menace + | StaticMode::Reach + | StaticMode::Flying + | StaticMode::Trample + | StaticMode::Deathtouch + | StaticMode::Lifelink + | StaticMode::CantTap + | StaticMode::CantUntap + | StaticMode::Goaded + | StaticMode::CombatAlone { .. } + | StaticMode::CantCrew + | StaticMode::CantPhaseIn + | StaticMode::CrewContribution { .. } + | StaticMode::MayLookAtTopOfLibrary + | StaticMode::MayLookAtFaceDown + | StaticMode::CantBeTurnedFaceUp + | StaticMode::MayChooseNotToUntap + | StaticMode::AdditionalLandDrop { .. } + | StaticMode::EmblemStatic + | StaticMode::NoMaximumHandSize + | StaticMode::MayPlayAdditionalLand + | StaticMode::CantWinTheGame + | StaticMode::CantLoseTheGame + | StaticMode::LegendRuleDoesntApply + | StaticMode::SpeedCanIncreaseBeyondFour + | StaticMode::SkipStep { .. } + | StaticMode::PayLifeAsColoredMana { .. } + | StaticMode::CanAttackWithDefender + | StaticMode::CanActivateAbilitiesAsThoughHaste + | StaticMode::CanBlockShadow + | StaticMode::AssignNoCombatDamage + | StaticMode::UntapsDuringEachOtherPlayersUntapStep + | StaticMode::EntersWithAdditionalCounters { .. } + | StaticMode::CountersCantBeRemoved { .. } + | StaticMode::CountsAsNamed { .. } + | StaticMode::Other(..) => {} + } +} + +fn walk_static_definition( + static_def: &mut StaticDefinition, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + // CR 612.1: the static's `mode` carries word-bearing evasion / protection / + // cost-filter / color parameters ("can't be blocked by Goblins", "protection + // from the chosen color", "black permanent spells cost less"). + walk_static_mode(&mut static_def.mode, category, cursor); + if let Some(affected) = &mut static_def.affected { + walk_target_filter(affected, category, cursor); + } + if let Some(condition) = &mut static_def.condition { + walk_static_condition(condition, category, cursor); + } + // CR 101.2 + CR 109.5: the per-affected-player gate is a `ParsedCondition` + // ("each opponent who controls a Goblin can't ...") naming a type/color/keyword. + if let Some(per_player) = &mut static_def.per_player_condition { + walk_parsed_condition(per_player, category, cursor); + } + for modification in static_def.modifications.iter_mut() { + walk_continuous_modification(modification, category, cursor); + } +} + +/// CR 614.1 + CR 612.1: Walk the word-bearing children of a replacement effect. +/// A creature type / land type / color word can live in the event-shape filter +/// (`valid_card` — "whenever a Goblin would enter"), the applicability +/// `condition` (`ReplacementCondition` — "unless you control a Plains"), the +/// resulting `execute` ability (its effects/filters), the damage source / +/// redirect filters, or the additional-token replacement's own subtypes. +/// +/// Intentionally NOT walked (coverage stays red rather than silently +/// mis-substituting): `runtime_execute` (a resolution-time `ResolvedAbility` +/// continuation snapshot, not printed rules text); the token-spec creation +/// fields (`additional_token_spec` / `ensure_token_specs` — token subtypes, +/// consistent with the `Effect::Token` subtype exclusion in [`walk_effect`]); +/// `mana_modification` (a mana SYMBOL, CR 612.2 + CR 107.4); `counter_match` +/// (a counter kind, CR 122.1, not a color/land/creature word); and the scalar +/// scope / expiry / player-axis fields. +fn walk_replacement_definition( + replacement: &mut ReplacementDefinition, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + // CR 603.2 / CR 614.1: the event-shape filter names the object class the + // replacement watches. + if let Some(valid_card) = &mut replacement.valid_card { + walk_target_filter(valid_card, category, cursor); + } + // CR 614.1c/d: the applicability condition. + if let Some(condition) = &mut replacement.condition { + walk_replacement_condition(condition, category, cursor); + } + // CR 614.1: the resulting effect (an ability with its own effects/filters). + if let Some(execute) = &mut replacement.execute { + walk_ability_definition(execute, category, cursor); + } + // CR 120.1 + CR 614.1a: damage source / redirect filters can name a type/color. + if let Some(source) = &mut replacement.damage_source_filter { + walk_target_filter(source, category, cursor); + } + if let Some(redirect) = &mut replacement.redirect_target { + walk_target_filter(redirect, category, cursor); + } + // CR 614.10 + CR 118.12a: an optional / pay-cost replacement carries a + // `decline` continuation ability (and a `MayCost` payment) that are word- + // bearing children — walked via [`walk_replacement_mode`]. + walk_replacement_mode(&mut replacement.mode, category, cursor); +} + +/// CR 614.10 + CR 612.1: Walk the word-bearing children of a `ReplacementMode`. +/// The `Optional` / `MayCost` decline continuation is a full `AbilityDefinition` +/// (its effects/filters can name a type/color); `MayCost` additionally bundles an +/// `AbilityCost` whose object filter can name a type. `Mandatory` carries none. +/// No `_` wildcard — a future word-bearing `ReplacementMode` variant fails to +/// compile until classified. +fn walk_replacement_mode( + mode: &mut ReplacementMode, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + match mode { + ReplacementMode::Optional { decline } => { + if let Some(d) = decline { + walk_ability_definition(d, category, cursor); + } + } + ReplacementMode::MayCost { cost, decline } => { + walk_ability_cost(cost, category, cursor); + if let Some(d) = decline { + walk_ability_definition(d, category, cursor); + } + } + ReplacementMode::Mandatory => {} + } +} + +/// CR 614.1c/d + CR 612.1: Walk the word-bearing children of a +/// `ReplacementCondition`. A creature type / land type can live in a control / +/// token / damage-source subtype leaf ("unless you control a Plains", "if you +/// control a Goblin", "if you would create a Treasure token"); a color word in a +/// filter's color predicate. Subtype string sets route through the +/// category-disambiguating [`WordCursor::subtype`]; filters recurse via +/// [`walk_target_filter`] / [`walk_typed_filter`]; quantity gates via +/// [`walk_quantity_expr`]. No `_` wildcard — a future word-bearing +/// `ReplacementCondition` variant fails to compile until classified. +fn walk_replacement_condition( + condition: &mut ReplacementCondition, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + match condition { + ReplacementCondition::And { conditions } => { + for c in conditions.iter_mut() { + walk_replacement_condition(c, category, cursor); + } + } + // CR 205.3: subtype-string leaves naming a land / creature type WORD. + ReplacementCondition::UnlessControlsSubtype { subtypes } + | ReplacementCondition::TokenSubtypeMatches { subtypes } => { + for s in subtypes.iter_mut() { + cursor.subtype(category, s); + } + } + // CR 614.1d: control-count / control-presence gates carry a `TargetFilter`. + ReplacementCondition::UnlessControlsMatching { filter } + | ReplacementCondition::UnlessControlsCountMatching { filter, .. } + | ReplacementCondition::IfControlsMatching { filter, .. } + // CR 120.1 + CR 614.1a: damage-source gate naming a type/color. + | ReplacementCondition::DealtDamageThisTurnBySource { source: filter } => { + walk_target_filter(filter, category, cursor) + } + // CR 614.1c: fast-land control gate carries a `TypedFilter`. + ReplacementCondition::UnlessControlsOtherLeq { filter, .. } => { + walk_typed_filter(filter, category, cursor) + } + ReplacementCondition::UnlessQuantity { lhs, rhs, .. } + | ReplacementCondition::OnlyIfQuantity { lhs, rhs, .. } => { + walk_quantity_expr(lhs, category, cursor); + walk_quantity_expr(rhs, category, cursor); + } + // CR 612.2: no color/land/creature WORD used as such — life / turn / + // player-count / speed / cast-variant / zone-origin / kicker / counter-type + // / core-type / draw-step / class-level / control-of-source / free-text + // gates. (`TokenCoreTypeMatches` names a CORE type per CR 111.1, not a + // subtype word; `Unrecognized` is opaque deferred text.) + ReplacementCondition::UnlessPlayerLifeAtMost { .. } + | ReplacementCondition::UnlessMultipleOpponents + | ReplacementCondition::UnlessYourTurn + | ReplacementCondition::HasMaxSpeed + | ReplacementCondition::CastViaEscape + | ReplacementCondition::CastVariantPaid { .. } + | ReplacementCondition::CastFromZone { .. } + | ReplacementCondition::EnteredFromZone { .. } + | ReplacementCondition::YouAttackedThisTurn + | ReplacementCondition::OpponentDamagedThisTurn + | ReplacementCondition::CastViaKicker { .. } + | ReplacementCondition::SourceTappedState { .. } + | ReplacementCondition::EventSourceControlledBy { .. } + | ReplacementCondition::EffectCausedDiscard + | ReplacementCondition::OnlyExtraTurn + | ReplacementCondition::TokenCoreTypeMatches { .. } + | ReplacementCondition::FirstTokenCreationEachTurn { .. } + | ReplacementCondition::ExceptFirstDrawInDrawStep + | ReplacementCondition::ClassLevelGE { .. } + | ReplacementCondition::DuringUntapStep + | ReplacementCondition::DuringDrawStep { .. } + | ReplacementCondition::ControllerControlsSource { .. } + | ReplacementCondition::Unrecognized { .. } => {} + } +} + +fn walk_continuous_modification( + modification: &mut ContinuousModification, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + match modification { + ContinuousModification::SetColor { colors } => { + for c in colors.iter_mut() { + cursor.color(category, c); + } + } + ContinuousModification::AddColor { color } => cursor.color(category, color), + ContinuousModification::SetBasicLandType { land_type } => { + cursor.basic_land_type(category, land_type) + } + ContinuousModification::AddSubtype { subtype } + | ContinuousModification::RemoveSubtype { subtype } => cursor.subtype(category, subtype), + ContinuousModification::AddKeyword { keyword } + | ContinuousModification::RemoveKeyword { keyword } => { + walk_keyword(keyword, category, cursor) + } + ContinuousModification::GrantAbility { definition } => { + walk_ability_definition(definition, category, cursor) + } + ContinuousModification::GrantStaticAbility { definition } => { + walk_static_definition(definition, category, cursor) + } + // CR 613.1f + CR 612.1: a granted rule-modification static `mode` carries + // the same word-bearing evasion / protection / cost-filter / color params as + // any static's mode (e.g. a granted "can't be blocked by Goblins"). + ContinuousModification::AddStaticMode { mode } => { + walk_static_mode(mode, category, cursor) + } + ContinuousModification::GrantTrigger { trigger } => { + walk_trigger_definition(trigger, category, cursor) } ContinuousModification::GrantAllActivatedAbilitiesOf { source, .. } | ContinuousModification::GrantAllTriggeredAbilitiesOf { source } => { @@ -984,6 +2335,12 @@ fn walk_continuous_modification( | ContinuousModification::AddDynamicKeyword { value, .. } => { walk_quantity_expr(value, category, cursor) } + // CR 707.9f + CR 612.1: the enters-with counter `count` is a `QuantityExpr` + // whose typed `ObjectCount` filter can name a type/color word (usually a + // `Fixed` scalar). + ContinuousModification::AddCounterOnEnter { count, .. } => { + walk_quantity_expr(count, category, cursor) + } ContinuousModification::CopyValues { .. } | ContinuousModification::SetName { .. } | ContinuousModification::AddPower { .. } @@ -1003,7 +2360,6 @@ fn walk_continuous_modification( | ContinuousModification::AddChosenColor { .. } | ContinuousModification::RemoveChosenKeyword | ContinuousModification::AddChosenKeyword - | ContinuousModification::AddStaticMode { .. } | ContinuousModification::SwitchPowerToughness | ContinuousModification::AssignDamageFromToughness | ContinuousModification::AssignDamageAsThoughUnblocked @@ -1018,12 +2374,65 @@ fn walk_continuous_modification( | ContinuousModification::RetainPrintedAbilityFromSource { .. } | ContinuousModification::AddSupertype { .. } | ContinuousModification::RemoveSupertype { .. } - | ContinuousModification::AddCounterOnEnter { .. } | ContinuousModification::SetStartingLoyalty { .. } | ContinuousModification::RemoveManaCost => {} } } +/// CR 603.7 + CR 612.1: Walk the word-bearing children of a +/// `DelayedTriggerCondition`. A creature type / land type / color word can live in +/// a filtered firing gate ("when a Goblin dies", "when a Forest enters") or an +/// embedded event trigger (`WheneverEvent` / `WhenNextEvent`). Filters recurse via +/// [`walk_target_filter`]; embedded triggers via [`walk_trigger_definition`]. The +/// phase / object-id / player timing markers carry no printed word. No `_` +/// wildcard — a future word-bearing variant fails to compile until classified. +fn walk_delayed_trigger_condition( + condition: &mut DelayedTriggerCondition, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + match condition { + DelayedTriggerCondition::WhenDies { filter } + | DelayedTriggerCondition::WhenLeavesPlayFiltered { filter } + | DelayedTriggerCondition::WhenEntersBattlefield { filter } + | DelayedTriggerCondition::WhenDiesOrExiled { filter } => { + walk_target_filter(filter, category, cursor) + } + DelayedTriggerCondition::WheneverEvent { trigger } => { + walk_trigger_definition(trigger, category, cursor) + } + DelayedTriggerCondition::WhenNextEvent { + trigger, + or_trigger, + .. + } => { + walk_trigger_definition(trigger, category, cursor); + if let Some(or_t) = or_trigger { + walk_trigger_definition(or_t, category, cursor); + } + } + DelayedTriggerCondition::AtNextPhase { .. } + | DelayedTriggerCondition::AtNextPhaseForPlayer { .. } + | DelayedTriggerCondition::WhenLeavesPlay { .. } => {} + } +} + +/// CR 603.7a + CR 612.1: Walk the word-bearing children of an `ExiledSpellRider`. +/// Only the `ReturnTo` timing (a `DelayedTriggerCondition`) can carry a filtered +/// firing gate; `BecomePlotted` carries none. No `_` wildcard. +fn walk_exiled_spell_rider( + rider: &mut ExiledSpellRider, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + match rider { + ExiledSpellRider::ReturnTo { timing, .. } => { + walk_delayed_trigger_condition(timing, category, cursor) + } + ExiledSpellRider::BecomePlotted => {} + } +} + /// CR 612.1: Walk the word-bearing children of an ability's effect. Descends into /// nested-ability composites (so granted statics/keywords/subtype filters are /// reached) and the two nested-effect replacement builders. @@ -1051,71 +2460,244 @@ fn walk_effect(effect: &mut Effect, category: TextWordCategory, cursor: &mut Wor | Effect::CreatePlaneswalkReplacement { replacement_effect } => { walk_effect(replacement_effect, category, cursor) } - Effect::CreateDelayedTrigger { effect, .. } => { - walk_ability_definition(effect, category, cursor) + // CR 603.7: a delayed trigger carries both its firing `condition` + // (`DelayedTriggerCondition` — its object filter / embedded trigger can + // name a type) and its resulting `effect` ability. + Effect::CreateDelayedTrigger { + condition, effect, .. + } => { + walk_delayed_trigger_condition(condition, category, cursor); + walk_ability_definition(effect, category, cursor); } + // CR 603.7a + CR 608.2n: a Feather-style exile-instead-of-graveyard rider + // can arm a filtered delayed return trigger whose timing carries a filter. + Effect::ExileResolvingSpellInsteadOfGraveyard { on_exile, .. } => { + if let Some(rider) = on_exile { + walk_exiled_spell_rider(rider, category, cursor); + } + } + // CR 701.6a + CR 611.2: a counter effect's `source_rider` can install a + // static ability on the countered source ("that permanent loses all + // abilities for as long as ~"), a full `StaticDefinition` + `Duration`. + Effect::Counter { + source_rider: Some(rider), + .. + } => match rider { + CounterSourceRider::LosesAbilities { + static_def, + duration, + } => { + walk_static_definition(static_def, category, cursor); + walk_duration(duration, category, cursor); + } + CounterSourceRider::Destroy => {} + }, + // CR 614.1 + CR 612.1: "the next [filter] you cast this turn gains + // [replacement]" installs a full `ReplacementDefinition` on a chosen target + // — both the installed replacement (its event filter / condition / effect) + // and the `target` filter naming the recipient class are word-bearing + // carriers. Not surfaced by `target_filter_mut` (returns `None`), so both + // are walked here. + Effect::AddTargetReplacement { + replacement, + target, + } => { + walk_replacement_definition(replacement, category, cursor); + walk_target_filter(target, category, cursor); + } + // CR 705.2: `flipper` (who flips) is a `TargetFilter` player ref not + // surfaced by `target_filter_mut` (returns `None`); walking it keeps the + // per-carrier sweep total (player refs are wordless leaves, but a future + // typed variant is reached). `FlipCoins` additionally carries a `count`. Effect::FlipCoin { win_effect, lose_effect, - .. + flipper, + } => { + if let Some(w) = win_effect { + walk_ability_definition(w, category, cursor); + } + if let Some(l) = lose_effect { + walk_ability_definition(l, category, cursor); + } + walk_target_filter(flipper, category, cursor); } - | Effect::FlipCoins { + Effect::FlipCoins { + count, win_effect, lose_effect, - .. + flipper, } => { + walk_quantity_expr(count, category, cursor); if let Some(w) = win_effect { walk_ability_definition(w, category, cursor); } if let Some(l) = lose_effect { walk_ability_definition(l, category, cursor); } + walk_target_filter(flipper, category, cursor); } Effect::FlipCoinUntilLose { win_effect } => { walk_ability_definition(win_effect, category, cursor) } - Effect::RollDie { results, .. } => { + // CR 706.1: `count` ("roll X dice") is a `QuantityExpr` carrier alongside + // each result branch's effect ability. + Effect::RollDie { count, results, .. } => { + walk_quantity_expr(count, category, cursor); for branch in results.iter_mut() { walk_ability_definition(&mut branch.effect, category, cursor); } } - Effect::ChooseOneOf { branches, .. } => { + // CR 701.55: `chooser` is a `PlayerFilter` whose control-count sub-filter + // can name a type; each branch is a full sub-ability. + Effect::ChooseOneOf { chooser, branches } => { + walk_player_filter(chooser, category, cursor); for branch in branches.iter_mut() { walk_ability_definition(branch, category, cursor); } } + // CR 701.38b: a `Named` vote resolves `per_choice_effect` per ballot; an + // `Objects` vote enumerates candidates via `candidate_filter` and resolves + // `outcome_template` per winner (Council's Judgment). Both the candidate + // filter and the outcome template are word-bearing carriers naming the + // voted-on object class / its resulting effect. Effect::Vote { - per_choice_effect, .. + per_choice_effect, + subject, + .. } => { for sub in per_choice_effect.iter_mut() { walk_ability_definition(sub, category, cursor); } + match subject { + VoteSubject::Objects { + candidate_filter, + outcome_template, + } => { + walk_target_filter(candidate_filter, category, cursor); + walk_ability_definition(outcome_template, category, cursor); + } + VoteSubject::Named => {} + } } + // CR 700.3: `object_filter` constrains each subject's eligible set (a typed + // "creatures" / "Goblins" filter) — a word-bearing carrier not surfaced by + // `target_filter_mut` (`SeparateIntoPiles` has no targeting slot). Effect::SeparateIntoPiles { + object_filter, chosen_pile_effect, unchosen_pile_effect, .. } => { + walk_target_filter(object_filter, category, cursor); walk_ability_definition(chosen_pile_effect, category, cursor); if let Some(unchosen) = unchosen_pile_effect { walk_ability_definition(unchosen, category, cursor); } } - Effect::RevealFromHand { on_decline, .. } => { + // CR 701.20a: `filter` restricts the self-reveal ("reveal a [type] card from + // your hand") — a word-bearing carrier not surfaced by `target_filter_mut`. + Effect::RevealFromHand { filter, on_decline } => { + walk_target_filter(filter, category, cursor); if let Some(sub) = on_decline { walk_ability_definition(sub, category, cursor); } } + // CR 611.2b: `GenericEffect` additionally carries a "for as long as" + // `duration` whose `StaticCondition` can name a type/color word. Effect::GenericEffect { - static_abilities, .. + static_abilities, + duration, + .. + } => { + for static_def in static_abilities.iter_mut() { + walk_static_definition(static_def, category, cursor); + } + if let Some(dur) = duration { + walk_duration(dur, category, cursor); + } } - | Effect::Token { + Effect::Token { static_abilities, .. } => { for static_def in static_abilities.iter_mut() { walk_static_definition(static_def, category, cursor); } } + // CR 611.2b: effects that install a continuous modification carry a + // `duration` (`Option`/`Duration`) whose `ForAsLongAs` shape + // gates on a word-bearing `StaticCondition`. Their primary target filter + // is already reached above via `target_filter_mut`; here the duration is + // the additional word-bearing child. (`PreventDamage.damage_source_filter` + // and `CastFromZone.alt_ability_cost` are secondary effect filters left + // intentionally red, consistent with the mass-filter exclusion documented + // below.) + // `ChangeTextWords.excluded_to` holds concrete `TextWord` operands (not + // words used as words on this object — sibling to `ReplaceTextWord`); + // `CastFromZone.alt_ability_cost` is the intentionally-red secondary cast + // cost (see [`walk_object_words`]). Only the `duration` is walked. + Effect::ChangeTextWords { duration, .. } | Effect::CastFromZone { duration, .. } => { + if let Some(dur) = duration { + walk_duration(dur, category, cursor); + } + } + // CR 707.2 + CR 611.2c: `recipient` (the object(s) that become the copy — + // "Shards you control") and the `additional_modifications` "except …" + // exceptions are word-bearing carriers alongside the `duration`; the copy + // *source* `target` is reached via `target_filter_mut`. + Effect::BecomeCopy { + recipient, + duration, + additional_modifications, + .. + } => { + walk_target_filter(recipient, category, cursor); + for modification in additional_modifications.iter_mut() { + walk_continuous_modification(modification, category, cursor); + } + if let Some(dur) = duration { + walk_duration(dur, category, cursor); + } + } + // CR 611.2c: `recipient` (who receives the abilities — a typed group filter + // "each Horror you control") is a word-bearing carrier alongside `duration`; + // the donor `target` is reached via `target_filter_mut`. + Effect::GainActivatedAbilitiesOfTarget { + recipient, + duration, + .. + } => { + walk_target_filter(recipient, category, cursor); + if let Some(dur) = duration { + walk_duration(dur, category, cursor); + } + } + // CR 615.11: `amount_dynamic` ("prevent X … where X is ") is a + // word-bearing `QuantityExpr` carrier alongside the shield `duration`. The + // `damage_source_filter` stays intentionally red (see [`walk_object_words`]). + Effect::PreventDamage { + amount_dynamic, + prevention_duration, + .. + } => { + if let Some(amount) = amount_dynamic { + walk_quantity_expr(amount, category, cursor); + } + if let Some(dur) = prevention_duration { + walk_duration(dur, category, cursor); + } + } + // CR 508.1d: `required_player` (whom the creature must attack) is a + // `TargetFilter` carrier alongside the `duration`; the attacker `target` + // is reached via `target_filter_mut`. + Effect::ForceAttack { + required_player, + duration, + .. + } => { + walk_target_filter(required_player, category, cursor); + walk_duration(duration, category, cursor); + } Effect::CreateEmblem { statics, triggers } => { for static_def in statics.iter_mut() { walk_static_definition(static_def, category, cursor); @@ -1124,45 +2706,477 @@ fn walk_effect(effect: &mut Effect, category: TextWordCategory, cursor: &mut Wor walk_trigger_definition(trigger, category, cursor); } } - Effect::ChangeTextWords { .. } - | Effect::StartYourEngines { .. } - | Effect::ChangeSpeed { .. } - | Effect::DealDamage { .. } - | Effect::ApplyPostReplacementDamage { .. } - | Effect::EachDealsDamageEqualToPower { .. } - | Effect::EachSourceDealsDamage { .. } - | Effect::Draw { .. } - | Effect::Pump { .. } + // ================= CR 612.1: secondary word-bearing carriers ================= + // Every arm below walks the carrier fields NOT already reached through the + // `target_filter_mut()` primary-target walk above. Effects whose only + // carrier is that primary target (or which are genuinely wordless / hold an + // intentionally-red mass or creation-spec field) fall through to the final + // no-op group. + + // ---- PlayerFilter roots (CR 109.5 — control-count / attribute sub-filters) ---- + Effect::StartYourEngines { player_scope } => { + walk_player_filter(player_scope, category, cursor) + } + Effect::ChangeSpeed { + player_scope, + amount, + .. + } => { + walk_player_filter(player_scope, category, cursor); + walk_quantity_expr(amount, category, cursor); + } + Effect::DamageEachPlayer { + amount, + player_filter, + } => { + walk_quantity_expr(amount, category, cursor); + walk_player_filter(player_filter, category, cursor); + } + // CR 120.3: DamageAll's `target` is a mass object-population filter (left + // red like DestroyAll), but its `amount` and `player_filter` are ordinary + // carriers ("N damage where N is the number of Goblins", "each opponent"). + Effect::DamageAll { + amount, + player_filter, + .. + } => { + walk_quantity_expr(amount, category, cursor); + if let Some(pf) = player_filter { + walk_player_filter(pf, category, cursor); + } + } + // CR 101.2: Conjure's `library_players` scopes which players' libraries + // receive the conjured cards (an "each player" `PlayerFilter`). + Effect::Conjure { + library_players, .. + } => { + if let Some(pf) = library_players { + walk_player_filter(pf, category, cursor); + } + } + + // ---- Single-`amount` QuantityExpr carriers (CR 107.3 + CR 608.2c) ---- + Effect::DealDamage { amount, .. } + | Effect::GainLife { amount, .. } + | Effect::GainEnergy { amount } + | Effect::SetLifeTotal { amount, .. } + | Effect::GrantExtraLoyaltyActivations { amount, .. } + | Effect::Intensify { amount, .. } => walk_quantity_expr(amount, category, cursor), + Effect::LoseLife { amount, .. } => walk_quantity_expr(amount, category, cursor), + Effect::Discover { + mana_value_limit, .. + } => walk_quantity_expr(mana_value_limit, category, cursor), + + // ---- Single-`count` QuantityExpr carriers (CR 107.3 + CR 608.2c) ---- + // The `target` (or `player`) each carries is reached via `target_filter_mut` + // (or is a mass filter left red); here the count is the added carrier. + Effect::Draw { count, .. } + | Effect::RemoveCounter { count, .. } + | Effect::Sacrifice { count, .. } + | Effect::Mill { count, .. } + | Effect::Scry { count, .. } + | Effect::Surveil { count, .. } + | Effect::Connive { count, .. } + | Effect::PutCounter { count, .. } + | Effect::PutChosenCounter { count, .. } + | Effect::PutCounterAll { count, .. } + | Effect::ChooseCounterAdjustment { count, .. } + | Effect::ExileTop { count, .. } + | Effect::GivePlayerCounter { count, .. } + | Effect::AddPendingETBCounters { count, .. } + | Effect::SkipNextTurn { count, .. } + | Effect::SkipNextStep { count, .. } + | Effect::PutAtLibraryPosition { count, .. } + | Effect::AssembleContraptions { count } + | Effect::Incubate { count, .. } + | Effect::Monstrosity { count } + | Effect::Renown { count } + | Effect::Bolster { count, .. } + | Effect::Adapt { count, .. } => walk_quantity_expr(count, category, cursor), + + // ---- PtValue quantities (CR 613.4) ---- + Effect::Pump { + power, toughness, .. + } + | Effect::PumpAll { + power, toughness, .. + } => { + walk_pt_value(power, category, cursor); + walk_pt_value(toughness, category, cursor); + } + // CR 613.4 / CR 205.1a: Animate grants base P/T (`Option`), a + // typed-subtype `types`/`remove_types` set, and granted `keywords`. + Effect::Animate { + power, + toughness, + types, + remove_types, + keywords, + .. + } => { + if let Some(p) = power { + walk_pt_value(p, category, cursor); + } + if let Some(t) = toughness { + walk_pt_value(t, category, cursor); + } + for subtype in types.iter_mut().chain(remove_types.iter_mut()) { + cursor.subtype(category, subtype); + } + for keyword in keywords.iter_mut() { + walk_keyword(keyword, category, cursor); + } + } + + // ---- Multi-carrier damage effects ---- + // CR 120.1: both source groups and the shared recipient are word-bearing + // filters (none surfaced by `target_filter_mut`). + Effect::EachDealsDamageEqualToPower { + sources, + recipient, + extra_source, + } => { + walk_target_filter(sources, category, cursor); + walk_target_filter(recipient, category, cursor); + if let Some(extra) = extra_source { + walk_target_filter(extra, category, cursor); + } + } + // CR 120.1 + CR 608.2: the source class and per-batch amount; the `Shared` + // recipient is reached via `target_filter_mut`, `EachController` is wordless. + Effect::EachSourceDealsDamage { + sources, amount, .. + } => { + walk_target_filter(sources, category, cursor); + walk_quantity_expr(amount, category, cursor); + } + + // ---- Secondary TargetFilter carriers (CR 612.2) ---- + Effect::Attach { attachment, .. } | Effect::UnattachAll { attachment, .. } => { + walk_target_filter(attachment, category, cursor) + } + Effect::Fight { subject, .. } => walk_target_filter(subject, category, cursor), + // CR 701.63a: the enduring permanent (`subject`) plus the N/N counter count. + Effect::Endure { amount, subject } => { + walk_quantity_expr(amount, category, cursor); + walk_target_filter(subject, category, cursor); + } + Effect::Behold { filter } | Effect::FreeCastFromZones { filter, .. } => { + walk_target_filter(filter, category, cursor) + } + // `ChooseAugmentAndCombineWithHost.filter` is a `Box`; the + // `host` is reached via `target_filter_mut`. + Effect::ChooseAugmentAndCombineWithHost { filter, .. } => { + walk_target_filter(filter, category, cursor) + } + Effect::ChooseDamageSource { source_filter } => { + walk_target_filter(source_filter, category, cursor) + } + Effect::GiveControl { recipient, .. } => walk_target_filter(recipient, category, cursor), + Effect::TurnFaceUp { target } => walk_target_filter(target, category, cursor), + Effect::ExchangeControl { target_a, target_b } + | Effect::ExchangeLifeTotals { + player_a: target_a, + player_b: target_b, + } => { + walk_target_filter(target_a, category, cursor); + walk_target_filter(target_b, category, cursor); + } + Effect::Meld { + source_filter, + partner_filter, + .. + } => { + walk_target_filter(source_filter, category, cursor); + walk_target_filter(partner_filter, category, cursor); + } + Effect::CopyTokenBlockingAttacker { + source_filter, + owner, + } => { + walk_target_filter(source_filter, category, cursor); + walk_target_filter(owner, category, cursor); + } + // CR 701.9a: the format-pool copy source's owner + type filter and its + // mana-value bound / token count (inventory carriers, not a token + // creation-spec exclusion). + Effect::CreateTokenCopyFromPool { + owner, + type_filter, + mv_bound, + count, + .. + } => { + walk_target_filter(owner, category, cursor); + walk_target_filter(type_filter, category, cursor); + walk_quantity_expr(mv_bound, category, cursor); + walk_quantity_expr(count, category, cursor); + } + Effect::ChangeTargets { forced_to, .. } => { + if let Some(f) = forced_to { + walk_target_filter(f, category, cursor); + } + } + Effect::ReduceNextSpellCost { spell_filter, .. } + | Effect::GrantNextSpellAbility { spell_filter, .. } => { + if let Some(f) = spell_filter { + walk_target_filter(f, category, cursor); + } + } + Effect::ChooseFromZone { filter, .. } => { + if let Some(f) = filter { + walk_target_filter(f, category, cursor); + } + } + Effect::ChooseObjectsIntoTrackedSet { + chooser, filter, .. + } => { + walk_target_filter(chooser, category, cursor); + walk_target_filter(filter, category, cursor); + } + Effect::ChooseAndSacrificeRest { + choose_filter, + sacrifice_filter, + total_power_cap, + .. + } => { + walk_target_filter(choose_filter, category, cursor); + walk_target_filter(sacrifice_filter, category, cursor); + if let Some(cap) = total_power_cap { + walk_quantity_expr(cap, category, cursor); + } + } + // CR 614.9 + CR 615: ordinary source/recipient object filters are walked; + // the specialized `DamageTargetFilter` / `DamageRedirectTarget` axes are + // not among the listed carrier types (left red). + Effect::CreateDamageReplacement { + source_filter, + redirect_object_filter, + recipient_object_filter, + .. + } => { + for f in [ + source_filter, + redirect_object_filter, + recipient_object_filter, + ] + .into_iter() + .flatten() + { + walk_target_filter(f, category, cursor); + } + } + Effect::AssembleContraptionOnSprocket { target, .. } => { + walk_target_filter(target, category, cursor) + } + + // ---- Effects with a `target`/`player` primary NOT surfaced by + // `target_filter_mut` (in its `None` group) plus a count / filter ---- + Effect::Manifest { target, count, .. } => { + walk_target_filter(target, category, cursor); + walk_quantity_expr(count, category, cursor); + } + Effect::Cloak { + target, + count, + object_source, + } => { + walk_target_filter(target, category, cursor); + walk_quantity_expr(count, category, cursor); + if let Some(f) = object_source { + walk_target_filter(f, category, cursor); + } + } + Effect::ExileFromTopUntil { until, .. } => walk_until_condition(until, category, cursor), + Effect::RevealUntil { filter, count, .. } | Effect::Seek { filter, count, .. } => { + walk_target_filter(filter, category, cursor); + walk_quantity_expr(count, category, cursor); + } + Effect::SearchLibrary { filter, count, .. } + | Effect::SearchOutsideGame { filter, count, .. } => { + walk_target_filter(filter, category, cursor); + walk_quantity_expr(count, category, cursor); + } + Effect::Dig { + count, + keep_count_expr, + filter, + .. + } => { + walk_quantity_expr(count, category, cursor); + if let Some(k) = keep_count_expr { + walk_quantity_expr(k, category, cursor); + } + walk_target_filter(filter, category, cursor); + } + Effect::RevealHand { + card_filter, count, .. + } => { + walk_target_filter(card_filter, category, cursor); + if let Some(c) = count { + walk_quantity_expr(c, category, cursor); + } + } + Effect::Discard { + count, + unless_filter, + filter, + .. + } => { + walk_quantity_expr(count, category, cursor); + if let Some(f) = unless_filter { + walk_target_filter(f, category, cursor); + } + if let Some(f) = filter { + walk_target_filter(f, category, cursor); + } + } + Effect::MoveCounters { source, count, .. } => { + walk_target_filter(source, category, cursor); + if let Some(c) = count { + walk_quantity_expr(c, category, cursor); + } + } + Effect::CastCopyOfCard { count, .. } | Effect::BounceAll { count, .. } => { + if let Some(c) = count { + walk_quantity_expr(c, category, cursor); + } + } + Effect::PutSticker { + count, + max_ticket_cost, + .. + } => { + walk_quantity_expr(count, category, cursor); + if let Some(m) = max_ticket_cost { + walk_quantity_expr(m, category, cursor); + } + } + Effect::ChooseDrawnThisTurnPayOrTopdeck { + count, + life_payment, + .. + } => { + walk_quantity_expr(count, category, cursor); + walk_quantity_expr(life_payment, category, cursor); + } + // CR 508.1c: the additional combat's `attacker_restriction` filter plus the + // count of scheduled phases. + Effect::AdditionalPhase { + count, + attacker_restriction, + .. + } => { + walk_quantity_expr(count, category, cursor); + if let Some(f) = attacker_restriction { + walk_target_filter(f, category, cursor); + } + } + // CR 400.7 + CR 614.1c: enters-with counter quantities and the conditional + // enters-with gate filter (the mass `ChangeZoneAll` counterpart walks only + // the counter quantities; its object `target` is a mass filter left red). + Effect::ChangeZone { + enter_with_counters, + conditional_enter_with_counters, + enters_modified_if, + .. + } => { + for (_, count) in enter_with_counters.iter_mut() { + walk_quantity_expr(count, category, cursor); + } + for (gate, _, count) in conditional_enter_with_counters.iter_mut() { + walk_target_filter(gate, category, cursor); + walk_quantity_expr(count, category, cursor); + } + if let Some(f) = enters_modified_if { + walk_target_filter(f, category, cursor); + } + } + Effect::ChangeZoneAll { + enter_with_counters, + .. + } => { + for (_, count) in enter_with_counters.iter_mut() { + walk_quantity_expr(count, category, cursor); + } + } + + // ---- Vec secondary carriers (CR 707.9) ---- + Effect::CopySpell { + additional_modifications, + .. + } + | Effect::AddPendingEntersModifications { + modifications: additional_modifications, + } => { + for modification in additional_modifications.iter_mut() { + walk_continuous_modification(modification, category, cursor); + } + } + // CR 707.2 + CR 707.9: the eligible object class plus the "except …" + // modifications applied to each created copy. + Effect::EachPlayerCopyChosen { + choose_filter, + copy_modifications, + .. + } => { + walk_target_filter(choose_filter, category, cursor); + for modification in copy_modifications.iter_mut() { + walk_continuous_modification(modification, category, cursor); + } + } + // CR 702.5a + CR 604.1: the Aura enchant filter and the granted body's + // continuous modifications. + Effect::ReturnAsAura { + enchant_filter, + grants, + } => { + walk_target_filter(enchant_filter, category, cursor); + for modification in grants.iter_mut() { + walk_continuous_modification(modification, category, cursor); + } + } + + // ---- AbilityCost / subtype carriers ---- + // CR 118.1: a resolution-time `PayCost` bundles a full `AbilityCost` (whose + // object filter can name a type — "Sacrifice a Goblin"), an optional scale + // quantity, and the payer filter. + Effect::PayCost { cost, scale, payer } => { + walk_ability_cost(cost, category, cursor); + if let Some(s) = scale { + walk_quantity_expr(s, category, cursor); + } + walk_target_filter(payer, category, cursor); + } + // CR 701.47a: Amass names a creature subtype used as such, plus a count. + Effect::Amass { subtype, count } => { + cursor.subtype(category, subtype); + walk_quantity_expr(count, category, cursor); + } + + // ================= Genuinely wordless / primary-only / red ================= + // Every effect below either (a) exposes its only word carrier through the + // `target_filter_mut()` walk above, (b) carries no color/land/creature WORD + // (fixed counts, markers, ids, name/label strings, mana pips), or (c) holds + // a deliberately-red carrier: a mass-population `*All` object filter, a + // token / CopyTokenOf / face-down / perpetual creation-spec, a resolved-spell + // snapshot (`EpicCopy`), a secondary cast cost, or a specialized non-listed + // sub-enum (`FaceDownProfile`, `GuessSubject`, `PerpetualModification`, + // `IntensityScope`, `ForEachCategoryAction`, `DamageTargetFilter`, etc.). + Effect::ApplyPostReplacementDamage { .. } | Effect::PairWith { .. } | Effect::Destroy { .. } | Effect::Regenerate { .. } | Effect::RemoveAllDamage { .. } | Effect::Counter { .. } | Effect::CounterAll { .. } - | Effect::GainLife { .. } - | Effect::LoseLife { .. } | Effect::SetTapState { .. } - | Effect::RemoveCounter { .. } - | Effect::Sacrifice { .. } | Effect::DiscardCard { .. } - | Effect::Mill { .. } - | Effect::Scry { .. } - | Effect::PumpAll { .. } - | Effect::DamageAll { .. } - | Effect::DamageEachPlayer { .. } | Effect::DestroyAll { .. } - | Effect::ChangeZone { .. } - | Effect::ChangeZoneAll { .. } - | Effect::Dig { .. } | Effect::GainControl { .. } | Effect::GainControlAll { .. } | Effect::ControlNextTurn { .. } - | Effect::Attach { .. } - | Effect::UnattachAll { .. } - | Effect::Surveil { .. } - | Effect::Fight { .. } | Effect::Bounce { .. } - | Effect::BounceAll { .. } | Effect::Explore | Effect::ExploreAll { .. } | Effect::Investigate @@ -1174,78 +3188,43 @@ fn walk_effect(effect: &mut Effect, category: TextWordCategory, cursor: &mut Wor | Effect::ProliferateTarget { .. } | Effect::Populate | Effect::Clash - | Effect::Behold { .. } | Effect::EndTheTurn | Effect::EndCombatPhase | Effect::SwitchPT { .. } - | Effect::CopySpell { .. } | Effect::EpicCopy { .. } - | Effect::CastCopyOfCard { .. } | Effect::CopyTokenOf { .. } - | Effect::CreateTokenCopyFromPool { .. } | Effect::Myriad | Effect::Encore | Effect::CombineHost { .. } - | Effect::ChooseAugmentAndCombineWithHost { .. } - | Effect::Meld { .. } | Effect::ExileHaunting { .. } | Effect::HideawayConceal { .. } - | Effect::CopyTokenBlockingAttacker { .. } - | Effect::BecomeCopy { .. } - | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::ChooseCard { .. } - | Effect::PutCounter { .. } | Effect::ChooseCounterKind { .. } - | Effect::PutChosenCounter { .. } - | Effect::PutCounterAll { .. } | Effect::MultiplyCounter { .. } - | Effect::ChooseCounterAdjustment { .. } | Effect::DoublePT { .. } | Effect::DoublePTAll { .. } - | Effect::MoveCounters { .. } - | Effect::Animate { .. } - | Effect::ReturnAsAura { .. } | Effect::RegisterBending { .. } | Effect::Cleanup { .. } | Effect::Mana { .. } - | Effect::Discard { .. } | Effect::Shuffle { .. } | Effect::Transform { .. } - | Effect::SearchLibrary { .. } - | Effect::SearchOutsideGame { .. } - | Effect::RevealHand { .. } | Effect::Reveal { .. } | Effect::RevealTop { .. } - | Effect::ExileTop { .. } | Effect::TargetOnly { .. } | Effect::Choose { .. } | Effect::OpponentGuess { .. } | Effect::SwapChosenLabels { .. } - | Effect::ChooseDamageSource { .. } | Effect::Suspect { .. } | Effect::Unsuspect { .. } - | Effect::Connive { .. } | Effect::PhaseOut { .. } | Effect::PhaseIn { .. } | Effect::ForceBlock { .. } - | Effect::ForceAttack { .. } | Effect::SolveCase | Effect::BecomePrepared { .. } | Effect::BecomeUnprepared { .. } | Effect::BecomeSaddled { .. } | Effect::SetClassLevel { .. } - | Effect::AddTargetReplacement { .. } | Effect::AddRestriction { .. } - | Effect::ReduceNextSpellCost { .. } - | Effect::GrantNextSpellAbility { .. } - | Effect::AddPendingETBCounters { .. } - | Effect::AddPendingEntersModifications { .. } - | Effect::PayCost { .. } - | Effect::CastFromZone { .. } - | Effect::FreeCastFromZones { .. } - | Effect::ExileResolvingSpellInsteadOfGraveyard { .. } - | Effect::PreventDamage { .. } - | Effect::CreateDamageReplacement { .. } | Effect::LoseTheGame { .. } | Effect::WinTheGame { .. } | Effect::RingTemptsYou @@ -1258,81 +3237,45 @@ fn walk_effect(effect: &mut Effect, category: TextWordCategory, cursor: &mut Wor | Effect::RedistributeLifeTotals | Effect::OpenAttractions { .. } | Effect::RollToVisitAttractions - | Effect::AssembleContraptions { .. } | Effect::AssembleContraptionsFromRollDifference | Effect::CrankContraptions { .. } | Effect::ReassembleContraption { .. } - | Effect::AssembleContraptionOnSprocket { .. } | Effect::ReassembleContraptionOnSprocket { .. } - | Effect::PutSticker { .. } | Effect::ApplySticker { .. } | Effect::ProcessRadCounters | Effect::GrantCastingPermission { .. } - | Effect::ChooseFromZone { .. } | Effect::RememberCard { .. } | Effect::ForEachCategory { .. } - | Effect::ChooseObjectsIntoTrackedSet { .. } - | Effect::ChooseAndSacrificeRest { .. } - | Effect::EachPlayerCopyChosen { .. } - | Effect::Exploit { .. } - | Effect::GainEnergy { .. } - | Effect::GivePlayerCounter { .. } | Effect::LoseAllPlayerCounters { .. } - | Effect::ExileFromTopUntil { .. } - | Effect::RevealUntil { .. } - | Effect::Discover { .. } + | Effect::Exploit { .. } | Effect::Heist { .. } | Effect::HeistExile | Effect::Cascade | Effect::Ripple { .. } | Effect::MiracleCast { .. } | Effect::MadnessCast { .. } - | Effect::PutAtLibraryPosition { .. } - | Effect::ChooseDrawnThisTurnPayOrTopdeck { .. } | Effect::PutOnTopOrBottom { .. } | Effect::GiftDelivery { .. } | Effect::Goad { .. } | Effect::GoadAll { .. } | Effect::Detain { .. } | Effect::SetRoomDoorLock { .. } - | Effect::ExchangeControl { .. } - | Effect::ChangeTargets { .. } - | Effect::Manifest { .. } | Effect::ManifestDread - | Effect::Cloak { .. } - | Effect::TurnFaceUp { .. } | Effect::TurnFaceDown { .. } | Effect::ExtraTurn { .. } - | Effect::GrantExtraLoyaltyActivations { .. } - | Effect::SkipNextTurn { .. } - | Effect::SkipNextStep { .. } - | Effect::AdditionalPhase { .. } | Effect::Double { .. } | Effect::RuntimeHandled { .. } - | Effect::Incubate { .. } - | Effect::Amass { .. } - | Effect::Monstrosity { .. } | Effect::Specialize - | Effect::Renown { .. } - | Effect::Bolster { .. } - | Effect::Adapt { .. } | Effect::Learn | Effect::Forage | Effect::Harness | Effect::CollectEvidence { .. } - | Effect::Endure { .. } | Effect::BlightEffect { .. } - | Effect::Seek { .. } - | Effect::SetLifeTotal { .. } | Effect::ExchangeLifeWithStat { .. } - | Effect::ExchangeLifeTotals { .. } | Effect::SetDayNight { .. } - | Effect::GiveControl { .. } | Effect::RemoveFromCombat { .. } | Effect::BecomeBlocked { .. } - | Effect::Conjure { .. } | Effect::ApplyPerpetual { .. } - | Effect::Intensify { .. } | Effect::DraftFromSpellbook { .. } | Effect::Unimplemented { .. } => {} } diff --git a/crates/engine/tests/integration/text_changing_effects.rs b/crates/engine/tests/integration/text_changing_effects.rs index 66bfac4753..0ce31b84e0 100644 --- a/crates/engine/tests/integration/text_changing_effects.rs +++ b/crates/engine/tests/integration/text_changing_effects.rs @@ -2,19 +2,23 @@ //! the real cast pipeline (parse → cast → resolve → `WaitingFor::TextWordReplacement` //! → `GameAction::ChooseTextWordReplacement` → Layer-3 continuous effect). +use engine::game::combat::AttackTarget; use engine::game::layers::{flush_layers, prune_end_of_turn_effects}; -use engine::game::scenario::{GameScenario, P0}; +use engine::game::scenario::{GameScenario, P0, P1}; use engine::game::text_substitution::collect_present_words; use engine::parser::oracle::parse_oracle_text; use engine::types::ability::{ - AbilityDefinition, BasicLandType, Duration, Effect, TextWord, TextWordCategory, + AbilityDefinition, AbilityKind, BasicLandType, ContinuousModification, Duration, Effect, + StaticCondition, StaticDefinition, TargetFilter, TextWord, TextWordCategory, TriggerCondition, + TypeFilter, TypedFilter, }; use engine::types::actions::GameAction; -use engine::types::game_state::{TextWordReplacementOption, WaitingFor}; +use engine::types::game_state::{PayCostKind, TextWordReplacementOption, WaitingFor}; use engine::types::identifiers::ObjectId; use engine::types::keywords::{Keyword, ProtectionTarget}; use engine::types::mana::ManaColor; use engine::types::phase::Phase; +use engine::types::statics::StaticMode; const SLEIGHT_OF_MIND: &str = "Change the text of target spell or permanent by replacing all instances of one color word with another."; const ARTIFICIAL_EVOLUTION: &str = "Change the text of target permanent by replacing all instances of one creature type with another."; @@ -414,393 +418,1794 @@ fn color_word_in_effect_target_filter_is_replaced() { ); } -/// CR 612.2 + CR 702.14 (plan 4): a basic land type in a landwalk keyword is -/// text-changed (Magical Hack: Mountain → Island). Revert guard: dropping the -/// `walk_keyword` `Landwalk` arm leaves Mountainwalk. +/// Recursively collect every `TypeFilter::Subtype` string reachable from a filter. +/// Shared by the cost / condition regression tests below. +fn filter_subtypes(filter: &engine::types::ability::TargetFilter, out: &mut Vec) { + use engine::types::ability::TargetFilter; + match filter { + TargetFilter::Typed(typed) => { + for tf in &typed.type_filters { + type_filter_subtypes(tf, out); + } + } + TargetFilter::Not { filter } => filter_subtypes(filter, out), + TargetFilter::Or { filters } | TargetFilter::And { filters } => { + for f in filters { + filter_subtypes(f, out); + } + } + _ => {} + } +} + +fn type_filter_subtypes(tf: &engine::types::ability::TypeFilter, out: &mut Vec) { + use engine::types::ability::TypeFilter; + match tf { + TypeFilter::Subtype(s) => out.push(s.clone()), + TypeFilter::Non(inner) => type_filter_subtypes(inner, out), + TypeFilter::AnyOf(inner) => { + for f in inner { + type_filter_subtypes(f, out); + } + } + _ => {} + } +} + +/// Build a `TargetFilter` naming a single creature subtype (a buried creature-type +/// carrier). Used by the HIGH-carrier regression tests below. +fn creature_subtype_filter(subtype: &str) -> TargetFilter { + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Subtype(subtype.to_string())], + controller: None, + properties: vec![], + }) +} + +/// CR 702.48a + CR 612.2 (HIGH carrier #1): the creature type spelled by an +/// `Offering` keyword ("Fox offering") is text-changed. The buried "Fox" is the +/// object's SOLE creature-type word, so pre-fix `collect_present_words` is empty. +/// +/// Revert guard: with the `Keyword::Offering` subtype-cursor arm dropped, Fox is +/// neither collected (the `before` reach-guard flips) nor offered as a `from` +/// word (no `WaitingFor::TextWordReplacement` is raised, so `apply_replacement` +/// panics). The final assertion — the live keyword now reads `Offering("Elf")` — +/// additionally fails if only the collect side were wired. #[test] -fn basic_land_type_in_landwalk_is_replaced() { +fn creature_type_in_offering_keyword_is_replaced() { let mut scenario = GameScenario::new(); scenario.at_phase(Phase::PreCombatMain); let creature = scenario - .add_creature(P0, "Mountain Strider", 2, 2) - .with_keyword(Keyword::Landwalk("Mountain".to_string())) + .add_creature(P0, "Offering Patron", 3, 3) + .with_keyword(Keyword::Offering("Fox".to_string())) .id(); let spell = scenario - .add_spell_to_hand_from_oracle(P0, "Magical Hack", true, MAGICAL_HACK) + .add_spell_to_hand_from_oracle(P0, "Artificial Evolution", true, ARTIFICIAL_EVOLUTION) .id(); let mut runner = scenario.build(); + runner.state_mut().all_creature_types = + vec!["Fox".to_string(), "Elf".to_string(), "Wall".to_string()]; + + // Reach-guard: the only creature-type word is inside the Offering keyword. + let before = collect_present_words( + &runner.state().objects[&creature], + TextWordCategory::CreatureType, + ); + assert!( + before.contains(&TextWord::CreatureType("Fox".to_string())), + "the Offering keyword's creature type should be collected: {before:?}" + ); + runner.cast(spell).target_object(creature).resolve(); apply_replacement( &mut runner, - TextWord::BasicLandType(BasicLandType::Mountain), - TextWord::BasicLandType(BasicLandType::Island), + TextWord::CreatureType("Fox".to_string()), + TextWord::CreatureType("Elf".to_string()), ); - let keywords = &runner.state().objects[&creature].keywords; + + let obj = &runner.state().objects[&creature]; assert!( - keywords.contains(&Keyword::Landwalk("Island".to_string())), - "landwalk should now be Islandwalk: {keywords:?}" + obj.keywords.contains(&Keyword::Offering("Elf".to_string())), + "Offering should now name Elf: {:?}", + obj.keywords ); assert!( - !keywords.contains(&Keyword::Landwalk("Mountain".to_string())), - "Mountainwalk must be gone: {keywords:?}" + !obj.keywords.contains(&Keyword::Offering("Fox".to_string())), + "Offering Fox must be gone: {:?}", + obj.keywords ); } -/// CR 612.2 category isolation (plan 4 NEGATIVE): a creature-type text change -/// must NOT touch a basic-land-type carrier. Artificial Evolution (creature -/// type) on a Zombie with Mountainwalk changes Zombie → Elf (positive reach -/// guard) but leaves the Mountain landwalk untouched. +/// CR 613.1f + CR 612.1 (HIGH carrier #2): a creature type buried in a granted +/// `AddStaticMode`'s inner filter ("can't be blocked by Goblins") is text-changed. +/// The buried "Goblin" is the object's SOLE creature-type word. +/// +/// Revert guard: with the `ContinuousModification::AddStaticMode` recursion +/// dropped, Goblin is neither collected (the `before` reach-guard flips) nor +/// offered, so `apply_replacement` panics. The final structural dig (the granted +/// mode's filter now names Elf, not Goblin) additionally fails if only the collect +/// side were wired. #[test] -fn creature_type_change_does_not_touch_basic_land_landwalk() { +fn creature_type_in_granted_add_static_mode_is_replaced() { let mut scenario = GameScenario::new(); scenario.at_phase(Phase::PreCombatMain); + let static_def = StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::AddStaticMode { + mode: StaticMode::CantBeBlockedBy { + filter: creature_subtype_filter("Goblin"), + }, + }]); let creature = scenario - .add_creature(P0, "Zombie Strider", 2, 2) - .with_subtypes(vec!["Zombie"]) - .with_keyword(Keyword::Landwalk("Mountain".to_string())) + .add_creature(P0, "Mode Grantor", 3, 3) + .with_static_definition(static_def) .id(); let spell = scenario .add_spell_to_hand_from_oracle(P0, "Artificial Evolution", true, ARTIFICIAL_EVOLUTION) .id(); let mut runner = scenario.build(); - runner.state_mut().all_creature_types = vec!["Zombie".to_string(), "Elf".to_string()]; + runner.state_mut().all_creature_types = + vec!["Goblin".to_string(), "Elf".to_string(), "Wall".to_string()]; + + let before = collect_present_words( + &runner.state().objects[&creature], + TextWordCategory::CreatureType, + ); + assert!( + before.contains(&TextWord::CreatureType("Goblin".to_string())), + "the granted AddStaticMode's filter should be collected: {before:?}" + ); + runner.cast(spell).target_object(creature).resolve(); apply_replacement( &mut runner, - TextWord::CreatureType("Zombie".to_string()), + TextWord::CreatureType("Goblin".to_string()), TextWord::CreatureType("Elf".to_string()), ); - let obj = &runner.state().objects[&creature]; - // Positive reach guard: the creature-type change DID apply. + + // Structural dig into the live granted mode's inner filter. + let mut subs = Vec::new(); + for sd in runner.state().objects[&creature] + .static_definitions + .iter_unchecked() + { + for m in &sd.modifications { + if let ContinuousModification::AddStaticMode { + mode: StaticMode::CantBeBlockedBy { filter }, + } = m + { + filter_subtypes(filter, &mut subs); + } + } + } assert!( - obj.card_types.subtypes.iter().any(|s| s == "Elf"), - "Zombie should have become Elf: {:?}", - obj.card_types.subtypes + subs.iter().any(|s| s == "Elf"), + "granted mode filter should now name Elf: {subs:?}" ); - // The basic-land-type landwalk is a DIFFERENT category — untouched. assert!( - obj.keywords - .contains(&Keyword::Landwalk("Mountain".to_string())), - "a creature-type change must not touch Mountainwalk: {:?}", - obj.keywords + !subs.iter().any(|s| s == "Goblin"), + "granted mode filter must no longer name Goblin: {subs:?}" ); } -/// CR 613.7 (plan 9): two sequential text changes on one permanent compose by -/// timestamp order — black → blue, then blue → red, yields red. Revert guard: if -/// each TCE's operands were not latched per-effect (or the timestamp order were -/// reversed), the final protection would read blue. +/// CR 614.1 + CR 612.1 (HIGH carrier #3): a creature type buried in an +/// `Effect::AddTargetReplacement`'s installed replacement (its `valid_card` event +/// filter — "the next Goblin you cast gains …") is text-changed. `AddTargetReplacement` +/// returns `None` from `target_filter_mut`, so only the dedicated walker arm reaches +/// it. The buried "Goblin" is the object's SOLE creature-type word. +/// +/// Revert guard: with the `Effect::AddTargetReplacement` arm dropped, Goblin is +/// neither collected (the `before` reach-guard flips) nor offered, so +/// `apply_replacement` panics. The final dig (the installed replacement's +/// `valid_card` now names Elf) additionally fails if only the collect side were wired. #[test] -fn sequential_text_changes_compose_by_timestamp() { +fn creature_type_in_add_target_replacement_is_replaced() { + use engine::types::replacements::ReplacementEvent; let mut scenario = GameScenario::new(); scenario.at_phase(Phase::PreCombatMain); + let replacement = + engine::types::ability::ReplacementDefinition::new(ReplacementEvent::ChangeZone) + .valid_card(creature_subtype_filter("Goblin")); + let ability = AbilityDefinition::new( + AbilityKind::Spell, + Effect::AddTargetReplacement { + replacement: Box::new(replacement), + target: TargetFilter::Any, + }, + ); let creature = scenario - .add_creature(P0, "Onyx Sentinel", 2, 2) - .with_keyword(Keyword::Protection(ProtectionTarget::Color( - ManaColor::Black, - ))) - .id(); - let first = scenario - .add_spell_to_hand_from_oracle(P0, "Sleight of Mind", true, SLEIGHT_OF_MIND) + .add_creature(P0, "Replacement Installer", 3, 3) + .with_ability_definition(ability) .id(); - let second = scenario - .add_spell_to_hand_from_oracle(P0, "Sleight of Mind", true, SLEIGHT_OF_MIND) + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Artificial Evolution", true, ARTIFICIAL_EVOLUTION) .id(); let mut runner = scenario.build(); + runner.state_mut().all_creature_types = + vec!["Goblin".to_string(), "Elf".to_string(), "Wall".to_string()]; - runner.cast(first).target_object(creature).resolve(); - apply_replacement( - &mut runner, - TextWord::Color(ManaColor::Black), - TextWord::Color(ManaColor::Blue), + let before = collect_present_words( + &runner.state().objects[&creature], + TextWordCategory::CreatureType, ); - // The second change reads the now-blue live word (proving per-TCE operands). - runner.cast(second).target_object(creature).resolve(); + assert!( + before.contains(&TextWord::CreatureType("Goblin".to_string())), + "the AddTargetReplacement's valid_card should be collected: {before:?}" + ); + + runner.cast(spell).target_object(creature).resolve(); apply_replacement( &mut runner, - TextWord::Color(ManaColor::Blue), - TextWord::Color(ManaColor::Red), + TextWord::CreatureType("Goblin".to_string()), + TextWord::CreatureType("Elf".to_string()), ); - let keywords = &runner.state().objects[&creature].keywords; + let mut subs = Vec::new(); + for ability in runner.state().objects[&creature].abilities.iter() { + if let Effect::AddTargetReplacement { replacement, .. } = ability.effect.as_ref() { + if let Some(vc) = &replacement.valid_card { + filter_subtypes(vc, &mut subs); + } + } + } assert!( - keywords.contains(&Keyword::Protection(ProtectionTarget::Color( - ManaColor::Red - ))), - "final protection must be red (CR 613.7 timestamp order): {keywords:?}" + subs.iter().any(|s| s == "Elf"), + "installed replacement valid_card should now name Elf: {subs:?}" ); assert!( - !keywords.contains(&Keyword::Protection(ProtectionTarget::Color( - ManaColor::Blue - ))), - "the intermediate blue must not survive the second change: {keywords:?}" + !subs.iter().any(|s| s == "Goblin"), + "installed replacement valid_card must no longer name Goblin: {subs:?}" ); } -/// CR 608.2c (plan 10): Crystal Spray's trailing "Draw a card" continuation -/// resolves after the replacement choice, and control returns to Priority. -/// Revert guard: if the choice handler dropped the parked continuation, the -/// hand-size delta would be zero and/or the game would remain stuck off Priority. +/// CR 105 + CR 612.2 (HIGH carrier #4): the color word in a +/// `StaticCondition::ChosenColorIs { color }` gate ("as long as the chosen color +/// is red") is text-changed. The buried red is the object's SOLE color word. +/// +/// Revert guard: with the `StaticCondition::ChosenColorIs` color-cursor arm +/// dropped, red is neither collected (the `before` reach-guard flips) nor offered, +/// so `apply_replacement` panics. The final dig (the static's condition now reads +/// blue) additionally fails if only the collect side were wired. #[test] -fn text_change_continuation_draws_and_returns_to_priority() { +fn color_word_in_chosen_color_is_condition_is_replaced() { let mut scenario = GameScenario::new(); scenario.at_phase(Phase::PreCombatMain); + let static_def = StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .condition(StaticCondition::ChosenColorIs { + color: ManaColor::Red, + }); let creature = scenario - .add_creature(P0, "Ruby Sentinel", 2, 2) - .with_keyword(Keyword::Protection(ProtectionTarget::Color(ManaColor::Red))) + .add_creature(P0, "Chosen Color Gate", 3, 3) + .with_static_definition(static_def) .id(); let spell = scenario - .add_spell_to_hand_from_oracle(P0, "Crystal Spray", true, CRYSTAL_SPRAY) + .add_spell_to_hand_from_oracle(P0, "Sleight of Mind", true, SLEIGHT_OF_MIND) .id(); - // A non-empty library so the trailing "Draw a card" succeeds (drawing from an - // empty library would deck the caster out — CR 104.3c). - scenario.with_library_top(P0, &["Plains", "Plains"]); let mut runner = scenario.build(); - runner.cast(spell).target_object(creature).resolve(); - let hand_before = runner - .state() - .players - .iter() - .find(|p| p.id == P0) - .map(|p| p.hand.len()) - .expect("P0 exists"); + let before = collect_present_words( + &runner.state().objects[&creature], + TextWordCategory::ColorWord, + ); + assert!( + before.contains(&TextWord::Color(ManaColor::Red)), + "the ChosenColorIs condition's color should be collected: {before:?}" + ); + runner.cast(spell).target_object(creature).resolve(); apply_replacement( &mut runner, TextWord::Color(ManaColor::Red), TextWord::Color(ManaColor::Blue), ); - let hand_after = runner - .state() - .players - .iter() - .find(|p| p.id == P0) - .map(|p| p.hand.len()) - .expect("P0 exists"); - assert_eq!( - hand_after, - hand_before + 1, - "Crystal Spray's 'Draw a card' continuation must draw exactly one card" - ); - assert!( - matches!(runner.state().waiting_for, WaitingFor::Priority { .. }), - "control must return to Priority after the continuation: {:?}", - runner.state().waiting_for - ); + let has_blue = runner.state().objects[&creature] + .static_definitions + .iter_unchecked() + .any(|sd| { + matches!( + sd.condition, + Some(StaticCondition::ChosenColorIs { + color: ManaColor::Blue + }) + ) + }); + let has_red = runner.state().objects[&creature] + .static_definitions + .iter_unchecked() + .any(|sd| { + matches!( + sd.condition, + Some(StaticCondition::ChosenColorIs { + color: ManaColor::Red + }) + ) + }); + assert!(has_blue, "ChosenColorIs should now gate on blue"); + assert!(!has_red, "ChosenColorIs must no longer gate on red"); } -/// Recursively collect every `Effect::ChangeTextWords` in an ability tree -/// (top-level, modal `ChooseOneOf` branches, mode abilities, sub/else chains). -fn collect_change_text<'a>(def: &'a AbilityDefinition, out: &mut Vec<&'a Effect>) { - if matches!(&*def.effect, Effect::ChangeTextWords { .. }) { - out.push(&def.effect); - } - if let Effect::ChooseOneOf { branches, .. } = &*def.effect { - for branch in branches { - collect_change_text(branch, out); +/// CR 612.1 + CR 612.2 + CR 701.21 (maintainer blocker): a creature type that +/// lives ONLY inside an activated ability's *cost* ("Sacrifice a Goblin", Goblin +/// Chirurgeon) is text-changed. This is the carrier the walker previously skipped: +/// `walk_ability_definition` walked `effect`/`sub`/`else`/`modes`/`repeat_for` but +/// never descended into `AbilityDefinition.cost`, so the cost silently stayed +/// "Goblin" after Artificial Evolution changed Goblin → Elf. +/// +/// Revert guard: without the new `walk_ability_cost` recursion the creature carries +/// no other creature-type word, so `collect_present_words` returns empty. The +/// positive reach-guard (`before` contains Goblin) then fails, and — because no +/// creature-type word is present — the cast raises no `WaitingFor::TextWordReplacement`, +/// so `apply_replacement` panics. The final assertion (the cost's sacrifice filter +/// now names Elf, not Goblin) additionally fails if only the collect side were wired. +#[test] +fn creature_type_in_activation_cost_is_replaced() { + use engine::game::game_object::GameObject; + use engine::types::ability::AbilityCost; + + /// Subtypes named by any (possibly composite) sacrifice cost on the object. + fn sacrifice_cost_subtypes(obj: &GameObject) -> Vec { + fn collect(cost: &AbilityCost, out: &mut Vec) { + match cost { + AbilityCost::Sacrifice(sac) => filter_subtypes(&sac.target, out), + AbilityCost::Composite { costs } | AbilityCost::OneOf { costs } => { + for c in costs { + collect(c, out); + } + } + _ => {} + } } + let mut out = Vec::new(); + for ability in obj.abilities.iter() { + if let Some(cost) = &ability.cost { + collect(cost, &mut out); + } + } + out } - if let Some(sub) = &def.sub_ability { - collect_change_text(sub, out); - } - if let Some(els) = &def.else_ability { - collect_change_text(els, out); - } - for mode in &def.mode_abilities { - collect_change_text(mode, out); - } -} -/// Parse `oracle` and return each `ChangeTextWords`'s -/// `(allowed_categories, excluded_to, duration)`. -#[allow(clippy::type_complexity)] -fn change_text_snapshots( - name: &str, - oracle: &str, -) -> Vec<(Vec, Vec, Option)> { - let parsed = parse_oracle_text(oracle, name, &[], &["Instant".to_string()], &[]); - let mut effects = Vec::new(); - for def in &parsed.abilities { - collect_change_text(def, &mut effects); - } - effects - .into_iter() - .map(|e| match e { - Effect::ChangeTextWords { - allowed_categories, - excluded_to, - duration, - .. - } => ( - allowed_categories.clone(), - excluded_to.clone(), - duration.clone(), - ), - _ => unreachable!("filtered to ChangeTextWords above"), - }) - .collect() -} + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + // Verbatim Oracle text; the ONLY creature-type word is inside the cost. + let chirurgeon = scenario + .add_creature(P0, "Goblin Chirurgeon", 1, 1) + .from_oracle_text("{0}, Sacrifice a Goblin: Regenerate target creature.") + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Artificial Evolution", true, ARTIFICIAL_EVOLUTION) + .id(); + let mut runner = scenario.build(); + runner.state_mut().all_creature_types = vec!["Goblin".to_string(), "Elf".to_string()]; -/// CR 612.1 + CR 612.2 (plan 11): every card in the text-changing class lowers to -/// `Effect::ChangeTextWords` with the correct `allowed_categories`, `excluded_to`, -/// and `duration`. Parser snapshot (shape) test — the runtime semantics are -/// covered by the cast-pipeline tests above; this pins the lowering surface. -#[test] -fn parser_snapshots_for_text_changing_class() { - use TextWordCategory::{BasicLandType as BLand, ColorWord, CreatureType}; + // Sanity: the cost really parsed to a Goblin sacrifice filter. + let cost_before = sacrifice_cost_subtypes(&runner.state().objects[&chirurgeon]); + assert!( + cost_before.iter().any(|s| s == "Goblin"), + "the activation cost should sacrifice a Goblin: {cost_before:?}" + ); - // Single-category, indefinite. - assert_eq!( - change_text_snapshots("Sleight of Mind", SLEIGHT_OF_MIND), - vec![(vec![ColorWord], vec![], None)] + // Positive reach-guard: the walker sees Goblin ONLY by descending into the cost. + let before = collect_present_words( + &runner.state().objects[&chirurgeon], + TextWordCategory::CreatureType, ); - assert_eq!( - change_text_snapshots( - "Glamerdye", - "Change the text of target spell or permanent by replacing all \ - instances of one color word with another." - ), - vec![(vec![ColorWord], vec![], None)] + assert!( + before.contains(&TextWord::CreatureType("Goblin".to_string())), + "the activation cost's sacrifice filter should carry the Goblin word: {before:?}" ); - assert_eq!( - change_text_snapshots( - "Alter Reality", - "Change the text of target spell or permanent by replacing all \ - instances of one color word with another." - ), - vec![(vec![ColorWord], vec![], None)] + + runner.cast(spell).target_object(chirurgeon).resolve(); + apply_replacement( + &mut runner, + TextWord::CreatureType("Goblin".to_string()), + TextWord::CreatureType("Elf".to_string()), ); - assert_eq!( - change_text_snapshots("Magical Hack", MAGICAL_HACK), - vec![(vec![BLand], vec![], None)] + + // Revert-failing assertion: the cost now requires sacrificing an Elf, not a Goblin. + let cost_after = sacrifice_cost_subtypes(&runner.state().objects[&chirurgeon]); + assert!( + cost_after.iter().any(|s| s == "Elf"), + "the activation cost must now require sacrificing an Elf: {cost_after:?}" + ); + assert!( + !cost_after.iter().any(|s| s == "Goblin"), + "Goblin must be gone from the sacrifice cost: {cost_after:?}" ); +} - // Two-category, indefinite. - assert_eq!( - change_text_snapshots( - "Mind Bend", - "Change the text of target permanent by replacing all instances of \ - one color word with another or one basic land type with another." - ), - vec![(vec![ColorWord, BLand], vec![], None)] +/// CR 612.1 + CR 612.2 + CR 608.2c (maintainer blocker): a creature type that +/// lives ONLY inside an ability's resolution *condition* ("If this creature is a +/// Goblin, …") is text-changed. Exercises the brand-new `walk_ability_condition` +/// via the `AbilityDefinition.condition` root (`SourceMatchesFilter`). +/// +/// The effect's own type words (Kithkin / Soldier) are filtered out of the offered +/// `from` set by the live creature-type intersection (`all_creature_types = +/// [Goblin, Elf]`), so the only legal `from` is the Goblin buried in the condition. +/// Revert guard: without `walk_ability_condition`, Goblin is neither collected nor +/// rewritten — `collect_present_words` (post-intersection) is empty, no +/// `WaitingFor::TextWordReplacement` is raised, and `apply_replacement` panics; the +/// final condition-filter assertion also fails. +#[test] +fn creature_type_in_ability_condition_is_replaced() { + use engine::game::game_object::GameObject; + use engine::types::ability::AbilityCondition; + + /// Subtypes named by any `SourceMatchesFilter` resolution condition on the object. + fn condition_subtypes(obj: &GameObject) -> Vec { + let mut out = Vec::new(); + for ability in obj.abilities.iter() { + if let Some(AbilityCondition::SourceMatchesFilter { filter }) = &ability.condition { + filter_subtypes(filter, &mut out); + } + } + out + } + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + // Proven parse: "If this creature is a [type], …" → `SourceMatchesFilter`. + let figure = scenario + .add_creature(P0, "Figure of Fable", 1, 1) + .from_oracle_text( + "If this creature is a Goblin, it becomes a Kithkin Soldier \ + with base power and toughness 4/5.", + ) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Artificial Evolution", true, ARTIFICIAL_EVOLUTION) + .id(); + let mut runner = scenario.build(); + runner.state_mut().all_creature_types = vec!["Goblin".to_string(), "Elf".to_string()]; + + // Sanity: the condition really parsed to a Goblin source gate. + let cond_before = condition_subtypes(&runner.state().objects[&figure]); + assert!( + cond_before.iter().any(|s| s == "Goblin"), + "the resolution condition should gate on a Goblin: {cond_before:?}" ); - // Two-category, until end of turn. - assert_eq!( - change_text_snapshots("Crystal Spray", CRYSTAL_SPRAY), - vec![( - vec![ColorWord, BLand], - vec![], - Some(Duration::UntilEndOfTurn) - )] + // Positive reach-guard: the walker sees Goblin only by descending into the + // condition (Kithkin/Soldier are excluded by the live creature-type set). + let before = collect_present_words( + &runner.state().objects[&figure], + TextWordCategory::CreatureType, ); - assert_eq!( - change_text_snapshots( - "Trait Doctoring", - "Change the text of target permanent by replacing all instances of \ - one color word with another or one basic land type with another \ - until end of turn." - ), - vec![( - vec![ColorWord, BLand], - vec![], - Some(Duration::UntilEndOfTurn) - )] + assert!( + before.contains(&TextWord::CreatureType("Goblin".to_string())), + "the condition's filter should carry the Goblin word: {before:?}" ); - assert_eq!( - change_text_snapshots( - "Whim of Volrath", - "Change the text of target permanent by replacing all instances of \ - one color word with another or one basic land type with another \ - until end of turn." - ), - vec![( - vec![ColorWord, BLand], - vec![], - Some(Duration::UntilEndOfTurn) - )] + + runner.cast(spell).target_object(figure).resolve(); + apply_replacement( + &mut runner, + TextWord::CreatureType("Goblin".to_string()), + TextWord::CreatureType("Elf".to_string()), ); - // Creature type with the Wall exclusion (Task-1 continuation absorber). - assert_eq!( - change_text_snapshots("Artificial Evolution", ARTIFICIAL_EVOLUTION_FULL), - vec![( - vec![CreatureType], - vec![TextWord::CreatureType("Wall".to_string())], - None - )] + // Revert-failing assertion: the condition now gates on Elf, not Goblin. + let cond_after = condition_subtypes(&runner.state().objects[&figure]); + assert!( + cond_after.iter().any(|s| s == "Elf"), + "the condition must now gate on an Elf: {cond_after:?}" + ); + assert!( + !cond_after.iter().any(|s| s == "Goblin"), + "Goblin must be gone from the condition: {cond_after:?}" ); +} - // Modal: each mode lowers to a single-category ChangeTextWords. - let spectral = change_text_snapshots( - "Spectral Shift", - "Choose one —\n\ - • Change the text of target spell or permanent by replacing all \ - instances of one basic land type with another.\n\ - • Change the text of target spell or permanent by replacing all \ - instances of one color word with another.", +/// CR 612.1 + CR 612.2 + CR 603.4 (maintainer blocker 1): a creature type that +/// lives ONLY inside a trigger's intervening-if `TriggerCondition::ControlsType` +/// ("Whenever this creature attacks, if you control a Goblin, ...") is +/// text-changed, and the change alters real TRIGGER FIRING. After Artificial +/// Evolution rewrites Goblin → Elf, the intervening-if reads "if you control an +/// Elf"; with an Elf (and no Goblin) in play the trigger now fires and its +/// `you gain 1 life` effect resolves. +/// +/// This drives the full pipeline: parse → cast → resolve → text-word replacement +/// → Layer-3 continuous effect → declare attackers → trigger fires → resolve. +/// +/// Revert guard (double): removing `walk_trigger_condition` means Goblin is never +/// collected from the trigger condition, so no `WaitingFor::TextWordReplacement` +/// offers Goblin → Elf and `apply_replacement` panics. Even if the collect side +/// still saw it, the live condition would stay `ControlsType { Goblin }`; with +/// only an Elf in play the intervening-if is false, the trigger never fires, and +/// the `+1 life` assertion fails. +#[test] +fn creature_type_in_trigger_intervening_if_changes_firing() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // The ONLY creature-type word on the scout is the Goblin buried in its + // trigger's intervening-if condition (no subtype on its type line, and the + // effect / event text carry no creature type). + let scout = scenario + .add_creature(P0, "Warband Scout", 2, 2) + .from_oracle_text( + "Whenever this creature attacks, if you control a Goblin, you gain 1 life.", + ) + .id(); + // A vanilla Elf that satisfies the post-change "control an Elf" gate. It is + // NOT a Goblin, so the pre-change gate would read false. + let _elf = scenario + .add_creature(P0, "Elvish Fodder", 1, 1) + .with_subtypes(vec!["Elf"]) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Artificial Evolution", true, ARTIFICIAL_EVOLUTION) + .id(); + let mut runner = scenario.build(); + runner.state_mut().all_creature_types = vec!["Goblin".to_string(), "Elf".to_string()]; + + // Sanity: the intervening-if really parsed to a Goblin control gate. + let cond = runner.state().objects[&scout] + .trigger_definitions + .iter_unchecked() + .find_map(|t| t.condition.clone()); + assert!( + matches!(&cond, Some(TriggerCondition::ControlsType { .. })), + "expected a ControlsType intervening-if on the attack trigger, got {cond:?}" + ); + + // Change Goblin → Elf on the scout; the intervening-if now reads "control an Elf". + runner.cast(spell).target_object(scout).resolve(); + apply_replacement( + &mut runner, + TextWord::CreatureType("Goblin".to_string()), + TextWord::CreatureType("Elf".to_string()), ); + + let life_before = runner.life(P0); + // Declare the scout attacking — the event the intervening-if trigger watches. + runner.advance_to_combat(); + runner + .declare_attackers(&[(scout, AttackTarget::Player(P1))]) + .expect("declare attackers should succeed"); + runner.advance_until_stack_empty(); + + // Revert-failing assertion: the trigger fired because it now reads "control an + // Elf" (an Elf is in play). Pre-fix the gate stays Goblin, no Goblin is in + // play, the trigger never fires, and life is unchanged. assert_eq!( - spectral.len(), - 2, - "Spectral Shift must lower to two ChangeTextWords modes: {spectral:?}" + runner.life(P0), + life_before + 1, + "the intervening-if trigger must fire and gain 1 life once it reads 'control an Elf'" ); - for (cats, excluded, dur) in &spectral { - assert_eq!( - cats.len(), - 1, - "each mode is a single-category change: {cats:?}" - ); - assert!(excluded.is_empty(), "no exclusion on Spectral Shift modes"); - assert_eq!(*dur, None, "Spectral Shift modes are indefinite"); +} + +/// CR 612.1 + CR 612.2 + CR 701.21 (maintainer blocker 2): after Artificial +/// Evolution rewrites Goblin → Elf on Goblin Chirurgeon, the "Sacrifice a Goblin" +/// activation cost is exercised through the REAL activation + cost-payment +/// pipeline: an Elf is offered as a legal sacrifice and a Goblin is not. +/// +/// Reach guard: the activation reaches `WaitingFor::PayCost { kind: Sacrifice }`, +/// proving the activated ability is available and its sacrifice cost is being +/// paid (not a vacuous inspection). Revert-failing assertion: with the cost +/// text-change in place the eligible `choices` contain the Elf and exclude the +/// Goblin; reverting the cost walk leaves the cost naming Goblin, so `choices` +/// would contain the Goblin and exclude the Elf — flipping both assertions. +#[test] +fn creature_type_in_activation_cost_drives_real_sacrifice() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let chirurgeon = scenario + .add_creature(P0, "Goblin Chirurgeon", 1, 1) + .from_oracle_text("{0}, Sacrifice a Goblin: Regenerate target creature.") + .id(); + // Sacrifice fodder of each type. After the change the cost sacrifices an Elf. + let elf = scenario + .add_creature(P0, "Elvish Fodder", 1, 1) + .with_subtypes(vec!["Elf"]) + .id(); + let goblin = scenario + .add_creature(P0, "Goblin Fodder", 1, 1) + .with_subtypes(vec!["Goblin"]) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Artificial Evolution", true, ARTIFICIAL_EVOLUTION) + .id(); + let mut runner = scenario.build(); + runner.state_mut().all_creature_types = vec!["Goblin".to_string(), "Elf".to_string()]; + + // Change Goblin → Elf on the Chirurgeon's activation cost. + runner.cast(spell).target_object(chirurgeon).resolve(); + apply_replacement( + &mut runner, + TextWord::CreatureType("Goblin".to_string()), + TextWord::CreatureType("Elf".to_string()), + ); + + // Locate the activated ability whose (possibly composite) cost includes a + // sacrifice — Goblin Chirurgeon's cost is `Composite { Mana{0}, Sacrifice }`. + fn cost_has_sacrifice(cost: &engine::types::ability::AbilityCost) -> bool { + use engine::types::ability::AbilityCost; + match cost { + AbilityCost::Sacrifice(_) => true, + AbilityCost::Composite { costs } | AbilityCost::OneOf { costs } => { + costs.iter().any(cost_has_sacrifice) + } + _ => false, + } } - let mode_cats: std::collections::BTreeSet = spectral + let ability_index = runner.state().objects[&chirurgeon] + .abilities .iter() - .flat_map(|(c, _, _)| c.iter().copied()) - .collect(); + .position(|a| a.cost.as_ref().is_some_and(cost_has_sacrifice)) + .expect("Goblin Chirurgeon must have a sacrifice-cost activated ability"); + + // Announce the activation and drive it to the sacrifice-cost payment window, + // targeting the Elf's regeneration (any creature is a legal target). + runner + .act(GameAction::ActivateAbility { + source_id: chirurgeon, + ability_index, + }) + .expect("ActivateAbility must be accepted"); + + for _ in 0..16 { + match &runner.state().waiting_for { + WaitingFor::TargetSelection { .. } => { + runner + .act(GameAction::ChooseTarget { + target: Some(engine::types::ability::TargetRef::Object(chirurgeon)), + }) + .expect("ChooseTarget (regenerate target) must be accepted"); + } + WaitingFor::ManaPayment { .. } => { + runner + .act(GameAction::PassPriority) + .expect("finalizing the {0} mana cost must be accepted"); + } + WaitingFor::PayCost { .. } => break, + other => panic!("unexpected waiting state before PayCost: {other:?}"), + } + } + + // Reach guard + revert-failing assertions: we are paying a Sacrifice cost, and + // the eligible set now contains the Elf and excludes the Goblin. + match &runner.state().waiting_for { + WaitingFor::PayCost { kind, choices, .. } => { + assert!( + matches!(kind, PayCostKind::Sacrifice), + "expected a Sacrifice cost payment, got {kind:?}" + ); + assert!( + choices.contains(&elf), + "an Elf must be a legal sacrifice after Goblin → Elf: {choices:?}" + ); + assert!( + !choices.contains(&goblin), + "a Goblin must NOT be a legal sacrifice after Goblin → Elf: {choices:?}" + ); + } + other => panic!("activation did not reach the sacrifice PayCost window: {other:?}"), + } + + // Complete the payment with the Elf to prove the pipeline accepts it. + runner + .act(GameAction::SelectCards { cards: vec![elf] }) + .expect("sacrificing the Elf must be accepted"); assert_eq!( - mode_cats, - [BLand, ColorWord].into_iter().collect(), - "Spectral Shift's modes cover the basic-land and color-word categories" + runner.state().objects.get(&elf).map(|o| o.zone), + Some(engine::types::zones::Zone::Graveyard), + "the sacrificed Elf must move to the graveyard" ); } -/// CR 612.1 (plan 12): the interactive `WaitingFor`/`GameAction` payloads and the -/// `Effect::ChangeTextWords` with a non-empty `excluded_to` round-trip through -/// serde (guards the `skip_serializing_if = "Vec::is_empty"` on `excluded_to`). +/// CR 612.2 + CR 702.14 (plan 4): a basic land type in a landwalk keyword is +/// text-changed (Magical Hack: Mountain → Island). Revert guard: dropping the +/// `walk_keyword` `Landwalk` arm leaves Mountainwalk. #[test] -fn serde_round_trip_text_word_types() { - let wf = WaitingFor::TextWordReplacement { - player: P0, - source: ObjectId(11), - target: ObjectId(22), - options: vec![TextWordReplacementOption { - category: TextWordCategory::ColorWord, - from: TextWord::Color(ManaColor::Red), - to: TextWord::Color(ManaColor::Blue), - label: "Red → Blue".to_string(), - }], - duration: Some(Duration::UntilEndOfTurn), - }; - let json = serde_json::to_string(&wf).expect("serialize WaitingFor"); - let back: WaitingFor = serde_json::from_str(&json).expect("deserialize WaitingFor"); - assert_eq!(wf, back); +fn basic_land_type_in_landwalk_is_replaced() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature(P0, "Mountain Strider", 2, 2) + .with_keyword(Keyword::Landwalk("Mountain".to_string())) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Magical Hack", true, MAGICAL_HACK) + .id(); + let mut runner = scenario.build(); + runner.cast(spell).target_object(creature).resolve(); + apply_replacement( + &mut runner, + TextWord::BasicLandType(BasicLandType::Mountain), + TextWord::BasicLandType(BasicLandType::Island), + ); + let keywords = &runner.state().objects[&creature].keywords; + assert!( + keywords.contains(&Keyword::Landwalk("Island".to_string())), + "landwalk should now be Islandwalk: {keywords:?}" + ); + assert!( + !keywords.contains(&Keyword::Landwalk("Mountain".to_string())), + "Mountainwalk must be gone: {keywords:?}" + ); +} - let action = GameAction::ChooseTextWordReplacement { index: 3 }; - let json = serde_json::to_string(&action).expect("serialize GameAction"); - let back: GameAction = serde_json::from_str(&json).expect("deserialize GameAction"); - assert_eq!(action, back); +/// CR 612.2 category isolation (plan 4 NEGATIVE): a creature-type text change +/// must NOT touch a basic-land-type carrier. Artificial Evolution (creature +/// type) on a Zombie with Mountainwalk changes Zombie → Elf (positive reach +/// guard) but leaves the Mountain landwalk untouched. +#[test] +fn creature_type_change_does_not_touch_basic_land_landwalk() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature(P0, "Zombie Strider", 2, 2) + .with_subtypes(vec!["Zombie"]) + .with_keyword(Keyword::Landwalk("Mountain".to_string())) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Artificial Evolution", true, ARTIFICIAL_EVOLUTION) + .id(); + let mut runner = scenario.build(); + runner.state_mut().all_creature_types = vec!["Zombie".to_string(), "Elf".to_string()]; + runner.cast(spell).target_object(creature).resolve(); + apply_replacement( + &mut runner, + TextWord::CreatureType("Zombie".to_string()), + TextWord::CreatureType("Elf".to_string()), + ); + let obj = &runner.state().objects[&creature]; + // Positive reach guard: the creature-type change DID apply. + assert!( + obj.card_types.subtypes.iter().any(|s| s == "Elf"), + "Zombie should have become Elf: {:?}", + obj.card_types.subtypes + ); + // The basic-land-type landwalk is a DIFFERENT category — untouched. + assert!( + obj.keywords + .contains(&Keyword::Landwalk("Mountain".to_string())), + "a creature-type change must not touch Mountainwalk: {:?}", + obj.keywords + ); +} - // Non-empty excluded_to must survive the round trip despite skip-if-empty. - let effect = Effect::ChangeTextWords { - target: engine::types::ability::TargetFilter::Any, - allowed_categories: vec![TextWordCategory::CreatureType], +/// CR 613.7 (plan 9): two sequential text changes on one permanent compose by +/// timestamp order — black → blue, then blue → red, yields red. Revert guard: if +/// each TCE's operands were not latched per-effect (or the timestamp order were +/// reversed), the final protection would read blue. +#[test] +fn sequential_text_changes_compose_by_timestamp() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature(P0, "Onyx Sentinel", 2, 2) + .with_keyword(Keyword::Protection(ProtectionTarget::Color( + ManaColor::Black, + ))) + .id(); + let first = scenario + .add_spell_to_hand_from_oracle(P0, "Sleight of Mind", true, SLEIGHT_OF_MIND) + .id(); + let second = scenario + .add_spell_to_hand_from_oracle(P0, "Sleight of Mind", true, SLEIGHT_OF_MIND) + .id(); + let mut runner = scenario.build(); + + runner.cast(first).target_object(creature).resolve(); + apply_replacement( + &mut runner, + TextWord::Color(ManaColor::Black), + TextWord::Color(ManaColor::Blue), + ); + // The second change reads the now-blue live word (proving per-TCE operands). + runner.cast(second).target_object(creature).resolve(); + apply_replacement( + &mut runner, + TextWord::Color(ManaColor::Blue), + TextWord::Color(ManaColor::Red), + ); + + let keywords = &runner.state().objects[&creature].keywords; + assert!( + keywords.contains(&Keyword::Protection(ProtectionTarget::Color( + ManaColor::Red + ))), + "final protection must be red (CR 613.7 timestamp order): {keywords:?}" + ); + assert!( + !keywords.contains(&Keyword::Protection(ProtectionTarget::Color( + ManaColor::Blue + ))), + "the intermediate blue must not survive the second change: {keywords:?}" + ); +} + +/// CR 608.2c (plan 10): Crystal Spray's trailing "Draw a card" continuation +/// resolves after the replacement choice, and control returns to Priority. +/// Revert guard: if the choice handler dropped the parked continuation, the +/// hand-size delta would be zero and/or the game would remain stuck off Priority. +#[test] +fn text_change_continuation_draws_and_returns_to_priority() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature(P0, "Ruby Sentinel", 2, 2) + .with_keyword(Keyword::Protection(ProtectionTarget::Color(ManaColor::Red))) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Crystal Spray", true, CRYSTAL_SPRAY) + .id(); + // A non-empty library so the trailing "Draw a card" succeeds (drawing from an + // empty library would deck the caster out — CR 104.3c). + scenario.with_library_top(P0, &["Plains", "Plains"]); + let mut runner = scenario.build(); + + runner.cast(spell).target_object(creature).resolve(); + let hand_before = runner + .state() + .players + .iter() + .find(|p| p.id == P0) + .map(|p| p.hand.len()) + .expect("P0 exists"); + + apply_replacement( + &mut runner, + TextWord::Color(ManaColor::Red), + TextWord::Color(ManaColor::Blue), + ); + + let hand_after = runner + .state() + .players + .iter() + .find(|p| p.id == P0) + .map(|p| p.hand.len()) + .expect("P0 exists"); + assert_eq!( + hand_after, + hand_before + 1, + "Crystal Spray's 'Draw a card' continuation must draw exactly one card" + ); + assert!( + matches!(runner.state().waiting_for, WaitingFor::Priority { .. }), + "control must return to Priority after the continuation: {:?}", + runner.state().waiting_for + ); +} + +/// Recursively collect every `Effect::ChangeTextWords` in an ability tree +/// (top-level, modal `ChooseOneOf` branches, mode abilities, sub/else chains). +fn collect_change_text<'a>(def: &'a AbilityDefinition, out: &mut Vec<&'a Effect>) { + if matches!(&*def.effect, Effect::ChangeTextWords { .. }) { + out.push(&def.effect); + } + if let Effect::ChooseOneOf { branches, .. } = &*def.effect { + for branch in branches { + collect_change_text(branch, out); + } + } + if let Some(sub) = &def.sub_ability { + collect_change_text(sub, out); + } + if let Some(els) = &def.else_ability { + collect_change_text(els, out); + } + for mode in &def.mode_abilities { + collect_change_text(mode, out); + } +} + +/// Parse `oracle` and return each `ChangeTextWords`'s +/// `(allowed_categories, excluded_to, duration)`. +#[allow(clippy::type_complexity)] +fn change_text_snapshots( + name: &str, + oracle: &str, +) -> Vec<(Vec, Vec, Option)> { + let parsed = parse_oracle_text(oracle, name, &[], &["Instant".to_string()], &[]); + let mut effects = Vec::new(); + for def in &parsed.abilities { + collect_change_text(def, &mut effects); + } + effects + .into_iter() + .map(|e| match e { + Effect::ChangeTextWords { + allowed_categories, + excluded_to, + duration, + .. + } => ( + allowed_categories.clone(), + excluded_to.clone(), + duration.clone(), + ), + _ => unreachable!("filtered to ChangeTextWords above"), + }) + .collect() +} + +/// CR 612.1 + CR 612.2 (plan 11): every card in the text-changing class lowers to +/// `Effect::ChangeTextWords` with the correct `allowed_categories`, `excluded_to`, +/// and `duration`. Parser snapshot (shape) test — the runtime semantics are +/// covered by the cast-pipeline tests above; this pins the lowering surface. +#[test] +fn parser_snapshots_for_text_changing_class() { + use TextWordCategory::{BasicLandType as BLand, ColorWord, CreatureType}; + + // Single-category, indefinite. + assert_eq!( + change_text_snapshots("Sleight of Mind", SLEIGHT_OF_MIND), + vec![(vec![ColorWord], vec![], None)] + ); + assert_eq!( + change_text_snapshots( + "Glamerdye", + "Change the text of target spell or permanent by replacing all \ + instances of one color word with another." + ), + vec![(vec![ColorWord], vec![], None)] + ); + assert_eq!( + change_text_snapshots( + "Alter Reality", + "Change the text of target spell or permanent by replacing all \ + instances of one color word with another." + ), + vec![(vec![ColorWord], vec![], None)] + ); + assert_eq!( + change_text_snapshots("Magical Hack", MAGICAL_HACK), + vec![(vec![BLand], vec![], None)] + ); + + // Two-category, indefinite. + assert_eq!( + change_text_snapshots( + "Mind Bend", + "Change the text of target permanent by replacing all instances of \ + one color word with another or one basic land type with another." + ), + vec![(vec![ColorWord, BLand], vec![], None)] + ); + + // Two-category, until end of turn. + assert_eq!( + change_text_snapshots("Crystal Spray", CRYSTAL_SPRAY), + vec![( + vec![ColorWord, BLand], + vec![], + Some(Duration::UntilEndOfTurn) + )] + ); + assert_eq!( + change_text_snapshots( + "Trait Doctoring", + "Change the text of target permanent by replacing all instances of \ + one color word with another or one basic land type with another \ + until end of turn." + ), + vec![( + vec![ColorWord, BLand], + vec![], + Some(Duration::UntilEndOfTurn) + )] + ); + assert_eq!( + change_text_snapshots( + "Whim of Volrath", + "Change the text of target permanent by replacing all instances of \ + one color word with another or one basic land type with another \ + until end of turn." + ), + vec![( + vec![ColorWord, BLand], + vec![], + Some(Duration::UntilEndOfTurn) + )] + ); + + // Creature type with the Wall exclusion (Task-1 continuation absorber). + assert_eq!( + change_text_snapshots("Artificial Evolution", ARTIFICIAL_EVOLUTION_FULL), + vec![( + vec![CreatureType], + vec![TextWord::CreatureType("Wall".to_string())], + None + )] + ); + + // Modal: each mode lowers to a single-category ChangeTextWords. + let spectral = change_text_snapshots( + "Spectral Shift", + "Choose one —\n\ + • Change the text of target spell or permanent by replacing all \ + instances of one basic land type with another.\n\ + • Change the text of target spell or permanent by replacing all \ + instances of one color word with another.", + ); + assert_eq!( + spectral.len(), + 2, + "Spectral Shift must lower to two ChangeTextWords modes: {spectral:?}" + ); + for (cats, excluded, dur) in &spectral { + assert_eq!( + cats.len(), + 1, + "each mode is a single-category change: {cats:?}" + ); + assert!(excluded.is_empty(), "no exclusion on Spectral Shift modes"); + assert_eq!(*dur, None, "Spectral Shift modes are indefinite"); + } + let mode_cats: std::collections::BTreeSet = spectral + .iter() + .flat_map(|(c, _, _)| c.iter().copied()) + .collect(); + assert_eq!( + mode_cats, + [BLand, ColorWord].into_iter().collect(), + "Spectral Shift's modes cover the basic-land and color-word categories" + ); +} + +/// CR 612.1 (plan 12): the interactive `WaitingFor`/`GameAction` payloads and the +/// `Effect::ChangeTextWords` with a non-empty `excluded_to` round-trip through +/// serde (guards the `skip_serializing_if = "Vec::is_empty"` on `excluded_to`). +#[test] +fn serde_round_trip_text_word_types() { + let wf = WaitingFor::TextWordReplacement { + player: P0, + source: ObjectId(11), + target: ObjectId(22), + options: vec![TextWordReplacementOption { + category: TextWordCategory::ColorWord, + from: TextWord::Color(ManaColor::Red), + to: TextWord::Color(ManaColor::Blue), + label: "Red → Blue".to_string(), + }], + duration: Some(Duration::UntilEndOfTurn), + }; + let json = serde_json::to_string(&wf).expect("serialize WaitingFor"); + let back: WaitingFor = serde_json::from_str(&json).expect("deserialize WaitingFor"); + assert_eq!(wf, back); + + let action = GameAction::ChooseTextWordReplacement { index: 3 }; + let json = serde_json::to_string(&action).expect("serialize GameAction"); + let back: GameAction = serde_json::from_str(&json).expect("deserialize GameAction"); + assert_eq!(action, back); + + // Non-empty excluded_to must survive the round trip despite skip-if-empty. + let effect = Effect::ChangeTextWords { + target: engine::types::ability::TargetFilter::Any, + allowed_categories: vec![TextWordCategory::CreatureType], excluded_to: vec![TextWord::CreatureType("Wall".to_string())], duration: None, }; - let json = serde_json::to_string(&effect).expect("serialize Effect"); - let back: Effect = serde_json::from_str(&json).expect("deserialize Effect"); - assert_eq!(effect, back); + let json = serde_json::to_string(&effect).expect("serialize Effect"); + let back: Effect = serde_json::from_str(&json).expect("deserialize Effect"); + assert_eq!(effect, back); +} + +/// CR 612.1 + CR 508.1 (review finding 1): a creature type that lives ONLY inside +/// a trigger's `TriggerCondition::AttackersDeclaredCount` subject filter ("if two +/// or more Pirates attacked this combat") is text-changed. The walker previously +/// classified `AttackersDeclaredCount` as a no-op, so the `Option` +/// carried by BOTH subject axes was neither collected nor rewritten. +/// +/// Revert guard: with `AttackersDeclaredCount` back in the no-op arm, Pirate is +/// never collected — no `WaitingFor::TextWordReplacement` offers Pirate → Elf and +/// `apply_replacement` panics. The final assertion (the subject filter now names +/// Elf) additionally fails if only the collect side were wired. +#[test] +fn creature_type_in_attackers_declared_count_is_replaced() { + use engine::game::game_object::GameObject; + use engine::types::ability::{ + AttackersDeclaredCountSubject, Comparator, ControllerRef, TargetFilter, TriggerDefinition, + TypedFilter, + }; + use engine::types::triggers::TriggerMode; + + fn subject_subtypes(obj: &GameObject) -> Vec { + let mut out = Vec::new(); + for t in obj.trigger_definitions.iter_unchecked() { + if let Some(TriggerCondition::AttackersDeclaredCount { subject, .. }) = &t.condition { + let filter = match subject { + AttackersDeclaredCountSubject::Controller { filter, .. } + | AttackersDeclaredCountSubject::AttackTarget { filter, .. } => filter, + }; + if let Some(f) = filter { + filter_subtypes(f, &mut out); + } + } + } + out + } + + let pirate_filter = TargetFilter::Typed( + TypedFilter::creature() + .controller(ControllerRef::You) + .subtype("Pirate".to_string()), + ); + let mut trigger = TriggerDefinition::new(TriggerMode::YouAttack); + trigger.condition = Some(TriggerCondition::AttackersDeclaredCount { + subject: AttackersDeclaredCountSubject::Controller { + scope: ControllerRef::You, + filter: Some(pirate_filter), + }, + comparator: Comparator::GE, + count: 2, + }); + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let warden = scenario + .add_creature(P0, "Pirate Warden", 2, 2) + .with_trigger_definition(trigger) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Artificial Evolution", true, ARTIFICIAL_EVOLUTION) + .id(); + let mut runner = scenario.build(); + runner.state_mut().all_creature_types = vec!["Pirate".to_string(), "Elf".to_string()]; + + // Positive reach-guard: the walker sees Pirate ONLY by descending into the + // AttackersDeclaredCount subject filter. + let before = collect_present_words( + &runner.state().objects[&warden], + TextWordCategory::CreatureType, + ); + assert!( + before.contains(&TextWord::CreatureType("Pirate".to_string())), + "the AttackersDeclaredCount subject filter should carry Pirate: {before:?}" + ); + + runner.cast(spell).target_object(warden).resolve(); + apply_replacement( + &mut runner, + TextWord::CreatureType("Pirate".to_string()), + TextWord::CreatureType("Elf".to_string()), + ); + + let after = subject_subtypes(&runner.state().objects[&warden]); + assert!( + after.iter().any(|s| s == "Elf"), + "the subject filter must now name Elf: {after:?}" + ); + assert!( + !after.iter().any(|s| s == "Pirate"), + "Pirate must be gone from the subject filter: {after:?}" + ); +} + +/// CR 612.1 + CR 614.1d (review finding 2): a creature type that lives ONLY inside +/// a replacement effect's applicability condition ("if you control a Goblin", +/// `ReplacementCondition::IfControlsMatching`) is text-changed. The entire +/// `replacement_definitions` root (Root 6) was previously unwalked. +/// +/// Revert guard: without Root 6 / `walk_replacement_condition`, Goblin is never +/// collected — no replacement choice offers Goblin → Elf and `apply_replacement` +/// panics; the live condition also stays Goblin. +#[test] +fn creature_type_in_replacement_condition_is_replaced() { + use engine::game::game_object::GameObject; + use engine::types::ability::{ + ControllerRef, ReplacementCondition, ReplacementDefinition, TargetFilter, TypedFilter, + }; + use engine::types::replacements::ReplacementEvent; + + fn condition_subtypes(obj: &GameObject) -> Vec { + let mut out = Vec::new(); + for r in obj.replacement_definitions.iter_unchecked() { + if let Some(ReplacementCondition::IfControlsMatching { filter, .. }) = &r.condition { + filter_subtypes(filter, &mut out); + } + } + out + } + + let goblin_filter = TargetFilter::Typed( + TypedFilter::creature() + .controller(ControllerRef::You) + .subtype("Goblin".to_string()), + ); + let mut rep = ReplacementDefinition::new(ReplacementEvent::DamageDone); + rep.condition = Some(ReplacementCondition::IfControlsMatching { + minimum: 1, + filter: goblin_filter, + }); + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let warden = scenario + .add_creature(P0, "Goblin Warden", 2, 2) + .with_replacement_definition(rep) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Artificial Evolution", true, ARTIFICIAL_EVOLUTION) + .id(); + let mut runner = scenario.build(); + runner.state_mut().all_creature_types = vec!["Goblin".to_string(), "Elf".to_string()]; + + let before = collect_present_words( + &runner.state().objects[&warden], + TextWordCategory::CreatureType, + ); + assert!( + before.contains(&TextWord::CreatureType("Goblin".to_string())), + "the replacement condition should carry Goblin: {before:?}" + ); + + runner.cast(spell).target_object(warden).resolve(); + apply_replacement( + &mut runner, + TextWord::CreatureType("Goblin".to_string()), + TextWord::CreatureType("Elf".to_string()), + ); + + let after = condition_subtypes(&runner.state().objects[&warden]); + assert!( + after.iter().any(|s| s == "Elf"), + "the replacement condition must now name Elf: {after:?}" + ); + assert!( + !after.iter().any(|s| s == "Goblin"), + "Goblin must be gone from the replacement condition: {after:?}" + ); +} + +/// CR 612.1 + CR 614.1c (review finding 2, subtype-string arm): a BASIC LAND TYPE +/// inside a check-land-style replacement condition ("unless you control a Plains", +/// `ReplacementCondition::UnlessControlsSubtype`) is text-changed via Magical +/// Hack. Exercises the `subtypes: Vec` cursor arm of +/// `walk_replacement_condition` (distinct from the `TargetFilter` arm above). +#[test] +fn basic_land_type_in_replacement_unless_controls_subtype_is_replaced() { + use engine::game::game_object::GameObject; + use engine::types::ability::{ReplacementCondition, ReplacementDefinition}; + use engine::types::replacements::ReplacementEvent; + + fn unless_subtypes(obj: &GameObject) -> Vec { + let mut out = Vec::new(); + for r in obj.replacement_definitions.iter_unchecked() { + if let Some(ReplacementCondition::UnlessControlsSubtype { subtypes }) = &r.condition { + out.extend(subtypes.iter().cloned()); + } + } + out + } + + let mut rep = ReplacementDefinition::new(ReplacementEvent::ChangeZone); + rep.condition = Some(ReplacementCondition::UnlessControlsSubtype { + subtypes: vec!["Plains".to_string()], + }); + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let checkland = scenario + .add_creature(P0, "Warden Retreat", 2, 2) + .with_replacement_definition(rep) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Magical Hack", true, MAGICAL_HACK) + .id(); + let mut runner = scenario.build(); + + let before = collect_present_words( + &runner.state().objects[&checkland], + TextWordCategory::BasicLandType, + ); + assert!( + before.contains(&TextWord::BasicLandType(BasicLandType::Plains)), + "the replacement condition should carry Plains: {before:?}" + ); + + runner.cast(spell).target_object(checkland).resolve(); + apply_replacement( + &mut runner, + TextWord::BasicLandType(BasicLandType::Plains), + TextWord::BasicLandType(BasicLandType::Island), + ); + + let after = unless_subtypes(&runner.state().objects[&checkland]); + assert!( + after.iter().any(|s| s == "Island"), + "the replacement condition must now name Island: {after:?}" + ); + assert!( + !after.iter().any(|s| s == "Plains"), + "Plains must be gone from the replacement condition: {after:?}" + ); +} + +/// CR 612.1 + CR 611.2b (review finding 3): a creature type that lives ONLY inside +/// an ability's `Duration::ForAsLongAs` condition ("for as long as you control a +/// Goblin") is text-changed. `Duration` was previously never walked from any root. +/// +/// Revert guard: without `walk_duration` on `AbilityDefinition.duration`, Goblin +/// is never collected — no choice offers Goblin → Elf and `apply_replacement` +/// panics; the live duration condition also stays Goblin. +#[test] +fn creature_type_in_for_as_long_as_duration_is_replaced() { + use engine::game::game_object::GameObject; + use engine::types::ability::{ + AbilityDefinition, AbilityKind, ControllerRef, StaticCondition, TargetFilter, TypedFilter, + }; + + fn duration_subtypes(obj: &GameObject) -> Vec { + let mut out = Vec::new(); + for a in obj.abilities.iter() { + if let Some(Duration::ForAsLongAs { + condition: StaticCondition::IsPresent { filter: Some(f) }, + }) = &a.duration + { + filter_subtypes(f, &mut out); + } + } + out + } + + let goblin_filter = TargetFilter::Typed( + TypedFilter::creature() + .controller(ControllerRef::You) + .subtype("Goblin".to_string()), + ); + let ability = AbilityDefinition::new(AbilityKind::Activated, Effect::NoOp).duration( + Duration::ForAsLongAs { + condition: StaticCondition::IsPresent { + filter: Some(goblin_filter), + }, + }, + ); + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature(P0, "Duration Warden", 2, 2) + .with_ability_definition(ability) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Artificial Evolution", true, ARTIFICIAL_EVOLUTION) + .id(); + let mut runner = scenario.build(); + runner.state_mut().all_creature_types = vec!["Goblin".to_string(), "Elf".to_string()]; + + let before = collect_present_words( + &runner.state().objects[&creature], + TextWordCategory::CreatureType, + ); + assert!( + before.contains(&TextWord::CreatureType("Goblin".to_string())), + "the ForAsLongAs duration should carry Goblin: {before:?}" + ); + + runner.cast(spell).target_object(creature).resolve(); + apply_replacement( + &mut runner, + TextWord::CreatureType("Goblin".to_string()), + TextWord::CreatureType("Elf".to_string()), + ); + + let after = duration_subtypes(&runner.state().objects[&creature]); + assert!( + after.iter().any(|s| s == "Elf"), + "the duration condition must now name Elf: {after:?}" + ); + assert!( + !after.iter().any(|s| s == "Goblin"), + "Goblin must be gone from the duration condition: {after:?}" + ); +} + +/// CR 612.1 + CR 509.1b (convergence audit): a creature type inside a static +/// ability's MODE ("can't be blocked by Goblins", `StaticMode::CantBeBlockedBy`) +/// is text-changed. `StaticDefinition.mode` was previously unwalked — its +/// word-bearing evasion / protection / cost filters were silently skipped despite +/// a (now-corrected) doc claim that `StaticMode` carries no word. +/// +/// Revert guard: without `walk_static_mode`, Goblin is never collected — no +/// choice offers Goblin → Elf and `apply_replacement` panics; the live mode +/// filter also stays Goblin. +#[test] +fn creature_type_in_static_mode_filter_is_replaced() { + use engine::game::game_object::GameObject; + use engine::types::ability::{TargetFilter, TypedFilter}; + use engine::types::statics::StaticMode; + + fn static_mode_subtypes(obj: &GameObject) -> Vec { + let mut out = Vec::new(); + for s in obj.static_definitions.iter_unchecked() { + if let StaticMode::CantBeBlockedBy { filter } = &s.mode { + filter_subtypes(filter, &mut out); + } + } + out + } + + let goblin_filter = TargetFilter::Typed(TypedFilter::creature().subtype("Goblin".to_string())); + let static_def = engine::types::ability::StaticDefinition::new(StaticMode::CantBeBlockedBy { + filter: goblin_filter, + }); + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature(P0, "Evasive Warden", 2, 2) + .with_static_definition(static_def) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Artificial Evolution", true, ARTIFICIAL_EVOLUTION) + .id(); + let mut runner = scenario.build(); + runner.state_mut().all_creature_types = vec!["Goblin".to_string(), "Elf".to_string()]; + + let before = collect_present_words( + &runner.state().objects[&creature], + TextWordCategory::CreatureType, + ); + assert!( + before.contains(&TextWord::CreatureType("Goblin".to_string())), + "the static mode filter should carry Goblin: {before:?}" + ); + + runner.cast(spell).target_object(creature).resolve(); + apply_replacement( + &mut runner, + TextWord::CreatureType("Goblin".to_string()), + TextWord::CreatureType("Elf".to_string()), + ); + + let after = static_mode_subtypes(&runner.state().objects[&creature]); + assert!( + after.iter().any(|s| s == "Elf"), + "the static mode filter must now name Elf: {after:?}" + ); + assert!( + !after.iter().any(|s| s == "Goblin"), + "Goblin must be gone from the static mode filter: {after:?}" + ); +} + +/// CR 612.1 + CR 702.29 (convergence audit): a basic land type inside a +/// `Keyword::Typecycling` subtype parameter ("Plainscycling") is text-changed via +/// Magical Hack. `Typecycling`/`Splice`/`Champion`/`BandsWithOther` were +/// previously in the keyword no-op group despite their subtype-string parameter. +/// +/// Revert guard: with `Typecycling` back in the no-op arm, Plains is never +/// collected — no choice offers Plains → Island and `apply_replacement` panics; +/// the live keyword also stays Plainscycling. +#[test] +fn basic_land_type_in_typecycling_keyword_is_replaced() { + use engine::types::mana::ManaCost; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature(P0, "Cycling Warden", 2, 2) + .with_keyword(Keyword::Typecycling { + cost: ManaCost::default(), + subtype: "Plains".to_string(), + }) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Magical Hack", true, MAGICAL_HACK) + .id(); + let mut runner = scenario.build(); + + let before = collect_present_words( + &runner.state().objects[&creature], + TextWordCategory::BasicLandType, + ); + assert!( + before.contains(&TextWord::BasicLandType(BasicLandType::Plains)), + "Plainscycling should carry the Plains basic-land-type word: {before:?}" + ); + + runner.cast(spell).target_object(creature).resolve(); + apply_replacement( + &mut runner, + TextWord::BasicLandType(BasicLandType::Plains), + TextWord::BasicLandType(BasicLandType::Island), + ); + + let keywords = &runner.state().objects[&creature].keywords; + assert!( + keywords.iter().any(|k| matches!( + k, + Keyword::Typecycling { subtype, .. } if subtype == "Island" + )), + "typecycling should now be Islandcycling: {keywords:?}" + ); + assert!( + !keywords.iter().any(|k| matches!( + k, + Keyword::Typecycling { subtype, .. } if subtype == "Plains" + )), + "Plainscycling must be gone: {keywords:?}" + ); +} + +// ============================================================================ +// walk_effect exhaustiveness: secondary word-bearing carriers on leaf effects. +// Each buries its target word as the SOLE word of its category, so pre-fix +// `collect_present_words` is empty → `apply_replacement` panics on revert +// (positive reach-guard), and the post-fix live-effect assertion flips too. +// ============================================================================ + +/// Pull the first ability effect matching `pred`, mapping it to a subtype list. +fn ability_effect_subtypes( + obj: &engine::game::game_object::GameObject, + mut extract: F, +) -> Vec +where + F: FnMut(&Effect, &mut Vec), +{ + let mut out = Vec::new(); + for a in obj.abilities.iter() { + extract(&a.effect, &mut out); + } + out +} + +/// CR 613.4 + CR 205.1a (walk_effect gap — `Effect::Animate.types`): the creature +/// type an animate effect grants ("becomes a Goblin") is text-changed. Buried in a +/// leaf effect's `types` `Vec`, previously dropped by the no-op catch-all. +/// +/// Revert guard: without the `Animate` walk arm, Goblin is never collected — no +/// choice offers Goblin → Elf and `apply_replacement` panics; the live `types` +/// vector also stays Goblin. +#[test] +fn creature_type_in_animate_effect_types_is_replaced() { + use engine::types::ability::{AbilityDefinition, AbilityKind}; + + let animate = Effect::Animate { + power: None, + toughness: None, + types: vec!["Goblin".to_string()], + remove_types: vec![], + target: TargetFilter::Any, + keywords: vec![], + }; + let ability = AbilityDefinition::new(AbilityKind::Activated, animate); + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature(P0, "Animate Warden", 2, 2) + .with_ability_definition(ability) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Artificial Evolution", true, ARTIFICIAL_EVOLUTION) + .id(); + let mut runner = scenario.build(); + runner.state_mut().all_creature_types = vec!["Goblin".to_string(), "Elf".to_string()]; + + let before = collect_present_words( + &runner.state().objects[&creature], + TextWordCategory::CreatureType, + ); + assert!( + before.contains(&TextWord::CreatureType("Goblin".to_string())), + "the Animate effect should carry Goblin: {before:?}" + ); + + runner.cast(spell).target_object(creature).resolve(); + apply_replacement( + &mut runner, + TextWord::CreatureType("Goblin".to_string()), + TextWord::CreatureType("Elf".to_string()), + ); + + let after = ability_effect_subtypes(&runner.state().objects[&creature], |e, out| { + if let Effect::Animate { types, .. } = e { + out.extend(types.iter().cloned()); + } + }); + assert!( + after.iter().any(|s| s == "Elf"), + "the Animate types must now name Elf: {after:?}" + ); + assert!( + !after.iter().any(|s| s == "Goblin"), + "Goblin must be gone from the Animate types: {after:?}" + ); +} + +/// CR 701.23a + CR 612.2 (walk_effect gap — `Effect::SearchLibrary.filter`): the +/// basic land type an in-effect tutor searches for ("search your library for a +/// Mountain card") is text-changed by Magical Hack. Buried in a leaf effect's +/// required `filter`, previously dropped by the no-op catch-all. +/// +/// Revert guard: without the `SearchLibrary` walk arm, Mountain is never collected +/// — no choice offers Mountain → Island and `apply_replacement` panics; the live +/// search filter also stays Mountain. +#[test] +fn basic_land_type_in_search_library_filter_is_replaced() { + use engine::types::ability::{ + AbilityDefinition, AbilityKind, QuantityExpr, SearchSelectionConstraint, + }; + + let mountain_filter = TargetFilter::Typed(TypedFilter { + type_filters: vec![ + TypeFilter::Land, + TypeFilter::Subtype("Mountain".to_string()), + ], + controller: None, + properties: vec![], + }); + let search = Effect::SearchLibrary { + source_zones: vec![engine::types::zones::Zone::Library], + filter: mountain_filter, + count: QuantityExpr::Fixed { value: 1 }, + reveal: false, + target_player: None, + selection_constraint: SearchSelectionConstraint::None, + split: None, + }; + let ability = AbilityDefinition::new(AbilityKind::Activated, search); + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature(P0, "Tutor Warden", 2, 2) + .with_ability_definition(ability) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Magical Hack", true, MAGICAL_HACK) + .id(); + let mut runner = scenario.build(); + + let before = collect_present_words( + &runner.state().objects[&creature], + TextWordCategory::BasicLandType, + ); + assert!( + before.contains(&TextWord::BasicLandType(BasicLandType::Mountain)), + "the SearchLibrary filter should carry Mountain: {before:?}" + ); + + runner.cast(spell).target_object(creature).resolve(); + apply_replacement( + &mut runner, + TextWord::BasicLandType(BasicLandType::Mountain), + TextWord::BasicLandType(BasicLandType::Island), + ); + + let after = ability_effect_subtypes(&runner.state().objects[&creature], |e, out| { + if let Effect::SearchLibrary { filter, .. } = e { + filter_subtypes(filter, out); + } + }); + assert!( + after.iter().any(|s| s == "Island"), + "the SearchLibrary filter must now name Island: {after:?}" + ); + assert!( + !after.iter().any(|s| s == "Mountain"), + "Mountain must be gone from the SearchLibrary filter: {after:?}" + ); +} + +/// CR 118.1 + CR 701.21 (walk_effect gap — `Effect::PayCost.cost`): a creature type +/// named in a resolution-time payment cost ("Sacrifice a Goblin" as an effect cost) +/// is text-changed. Buried in a leaf effect's `AbilityCost`, previously dropped by +/// the no-op catch-all. +/// +/// Revert guard: without the `PayCost` walk arm, Goblin is never collected — no +/// choice offers Goblin → Elf and `apply_replacement` panics; the live cost filter +/// also stays Goblin. +#[test] +fn creature_type_in_paycost_effect_cost_is_replaced() { + use engine::types::ability::{AbilityCost, AbilityDefinition, AbilityKind, SacrificeCost}; + + let pay = Effect::PayCost { + cost: AbilityCost::Sacrifice(SacrificeCost::count(creature_subtype_filter("Goblin"), 1)), + scale: None, + payer: TargetFilter::Controller, + }; + let ability = AbilityDefinition::new(AbilityKind::Activated, pay); + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature(P0, "Payment Warden", 2, 2) + .with_ability_definition(ability) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Artificial Evolution", true, ARTIFICIAL_EVOLUTION) + .id(); + let mut runner = scenario.build(); + runner.state_mut().all_creature_types = vec!["Goblin".to_string(), "Elf".to_string()]; + + let before = collect_present_words( + &runner.state().objects[&creature], + TextWordCategory::CreatureType, + ); + assert!( + before.contains(&TextWord::CreatureType("Goblin".to_string())), + "the PayCost sacrifice cost should carry Goblin: {before:?}" + ); + + runner.cast(spell).target_object(creature).resolve(); + apply_replacement( + &mut runner, + TextWord::CreatureType("Goblin".to_string()), + TextWord::CreatureType("Elf".to_string()), + ); + + let after = ability_effect_subtypes(&runner.state().objects[&creature], |e, out| { + if let Effect::PayCost { + cost: AbilityCost::Sacrifice(sac), + .. + } = e + { + filter_subtypes(&sac.target, out); + } + }); + assert!( + after.iter().any(|s| s == "Elf"), + "the PayCost sacrifice filter must now name Elf: {after:?}" + ); + assert!( + !after.iter().any(|s| s == "Goblin"), + "Goblin must be gone from the PayCost sacrifice filter: {after:?}" + ); +} + +/// CR 701.47a + CR 612.2 (walk_effect gap — `Effect::Amass.subtype`): the literal +/// creature subtype an amass effect names ("Amass Goblins") is text-changed. Buried +/// in a leaf effect's `subtype` `String`, previously dropped by the no-op catch-all. +/// +/// Revert guard: without the `Amass` walk arm, Goblin is never collected — no +/// choice offers Goblin → Elf and `apply_replacement` panics; the live `subtype` +/// string also stays Goblin. +#[test] +fn creature_type_in_amass_subtype_is_replaced() { + use engine::types::ability::{AbilityDefinition, AbilityKind, QuantityExpr}; + + let amass = Effect::Amass { + subtype: "Goblin".to_string(), + count: QuantityExpr::Fixed { value: 1 }, + }; + let ability = AbilityDefinition::new(AbilityKind::Activated, amass); + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature(P0, "Amass Warden", 2, 2) + .with_ability_definition(ability) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Artificial Evolution", true, ARTIFICIAL_EVOLUTION) + .id(); + let mut runner = scenario.build(); + runner.state_mut().all_creature_types = vec!["Goblin".to_string(), "Elf".to_string()]; + + let before = collect_present_words( + &runner.state().objects[&creature], + TextWordCategory::CreatureType, + ); + assert!( + before.contains(&TextWord::CreatureType("Goblin".to_string())), + "the Amass effect should carry Goblin: {before:?}" + ); + + runner.cast(spell).target_object(creature).resolve(); + apply_replacement( + &mut runner, + TextWord::CreatureType("Goblin".to_string()), + TextWord::CreatureType("Elf".to_string()), + ); + + let after = ability_effect_subtypes(&runner.state().objects[&creature], |e, out| { + if let Effect::Amass { subtype, .. } = e { + out.push(subtype.clone()); + } + }); + assert!( + after.iter().any(|s| s == "Elf"), + "the Amass subtype must now name Elf: {after:?}" + ); + assert!( + !after.iter().any(|s| s == "Goblin"), + "Goblin must be gone from the Amass subtype: {after:?}" + ); } From 97b4929128eab50e1cb9441c5642236babcf7533 Mon Sep 17 00:00:00 2001 From: real-venus Date: Tue, 21 Jul 2026 13:52:55 -0700 Subject: [PATCH 3/9] fix(engine): classify new upstream variants in the CR 612 text-change walker Main advanced under the branch; the walker's exhaustive (_-free) matches require every new enum variant to be explicitly classified as a word carrier or a CR 612.2 no-op. Covers the new TargetFilter, FilterProp, QuantityRef, AbilityCondition, StaticMode and ContinuousModification variants, and adds the ChangeTextWords / ReplaceTextWord / ChooseTextWordReplacement arms upstream's new match sites require. --- crates/engine/src/game/ability_scan.rs | 10 ++++ crates/engine/src/game/text_substitution.rs | 57 ++++++++++++++++++- crates/engine/src/types/ability.rs | 1 + .../engine/src/types/action_stable_order.rs | 9 +++ .../integration/text_changing_effects.rs | 6 +- 5 files changed, 79 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index afaa8a3746..06d34cfec4 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -5014,6 +5014,9 @@ fn scan_continuous_modification(m: &ContinuousModification, mode: ScanMode) -> A // CR 612.8 / CR 613.1c: a literal-name text-changing effect reads no board // aggregate or projected resource (sibling of `SetChosenName`). | ContinuousModification::SetTextName { .. } + // CR 612.2 / CR 613.1c: the Layer-3 word swap carries a fixed `from`/`to` + // word pair — it reads no board aggregate or projected resource either. + | ContinuousModification::ReplaceTextWord { .. } | ContinuousModification::AddSupertype { .. } | ContinuousModification::RemoveSupertype { .. } | ContinuousModification::SetStartingLoyalty { .. } @@ -5381,6 +5384,10 @@ fn effect_target_ctx(e: &Effect, mode: ScanMode) -> FilterReadContext { | Effect::RedistributeLifeTotals | Effect::ReverseTurnOrder | Effect::ChooseOneOf { .. } + // CR 612.1 + CR 115.1: `ChangeTextWords.target` is a single announced + // "target spell or permanent" slot (a2) read from `ResolvedAbility.targets` + // at resolution — an O(1) read that does not scale with the growing class. + | Effect::ChangeTextWords { .. } | Effect::Unimplemented { .. } => FilterReadContext::SnapshotOrEvent, } } @@ -5730,6 +5737,9 @@ fn effect_census_role(e: &Effect) -> CensusRole { | Effect::RedistributeLifeTotals | Effect::ReverseTurnOrder | Effect::ChooseOneOf { .. } + // CR 612.1 + CR 115.1: a single announced "target spell or permanent" slot + // — bounded, no battlefield population read (mirrors `effect_target_ctx`). + | Effect::ChangeTextWords { .. } | Effect::Unimplemented { .. } => CensusRole::Relax(RelaxReason::BoundedOrNoPopulation), } } diff --git a/crates/engine/src/game/text_substitution.rs b/crates/engine/src/game/text_substitution.rs index 358d662d80..8ed388d9ac 100644 --- a/crates/engine/src/game/text_substitution.rs +++ b/crates/engine/src/game/text_substitution.rs @@ -289,10 +289,13 @@ pub fn walk_object_words( for ability in Arc::make_mut(&mut obj.abilities).iter_mut() { walk_ability_definition(ability, category, cursor); } - // Root 4: triggered abilities. + // Root 4: triggered abilities. Live entries are identity-bearing + // (`TriggerEntry`): only the `definition` payload carries printed rules text, + // so the walk projects it and leaves the `occurrence` provenance ref alone + // (CR 612.2 — an occurrence id is a runtime identity marker, not a word). for i in 0..obj.trigger_definitions.len() { if let Some(trigger) = obj.trigger_definitions.get_mut(i) { - walk_trigger_definition(trigger, category, cursor); + walk_trigger_definition(&mut trigger.definition, category, cursor); } } // Root 5: static abilities (affected set, condition, layered modifications). @@ -601,10 +604,17 @@ fn walk_target_filter( TargetFilter::TrackedSetFiltered { filter, .. } => { walk_target_filter(filter, category, cursor) } + // CR 612.2 + CR 205.2a: `ControllerAndControlledPermanents.permanent_type` + // is an `Option` (a card TYPE, not a subtype or color word), and + // the player leg is a controller reference — no printed word to change. + // `Opponent` is a player-scope reference (CR 102.2). `PostReplacement*` are + // runtime event-context refs into the prevented damage event (CR 615.5). TargetFilter::None | TargetFilter::Any | TargetFilter::Player | TargetFilter::Controller + | TargetFilter::Opponent + | TargetFilter::ControllerAndControlledPermanents { .. } | TargetFilter::SelfRef | TargetFilter::GrantingObject | TargetFilter::SourceOrPaired @@ -638,6 +648,7 @@ fn walk_target_filter( | TargetFilter::OriginalController | TargetFilter::OriginalSource | TargetFilter::PostReplacementSourceController + | TargetFilter::PostReplacementDamageSource | TargetFilter::PostReplacementDamageTarget | TargetFilter::PostReplacementDamageTargetOwner | TargetFilter::DefendingPlayer @@ -773,6 +784,12 @@ fn walk_filter_prop(prop: &mut FilterProp, category: TextWordCategory, cursor: & | FilterProp::NotSupertype { .. } | FilterProp::Suspected | FilterProp::Renowned + // CR 701.15b/c: "goaded" is a designation marker, not a printed + // color/land/creature word. + | FilterProp::Goaded + // CR 715.2: "has an Adventure" is a structural card-shape predicate (an + // alternate face exists), not a subtype or color word. + | FilterProp::HasAdventure | FilterProp::ToughnessGTPower | FilterProp::PowerExceedsBase | FilterProp::InTrackedSet { .. } @@ -781,6 +798,9 @@ fn walk_filter_prop(prop: &mut FilterProp, category: TextWordCategory, cursor: & | FilterProp::NotHistoric | FilterProp::InAnyZone { .. } | FilterProp::WasDealtDamageThisTurn + // CR 120.1: the active-voice damage-role dual of `WasDealtDamageThisTurn` + // — an event-history predicate with no printed word. + | FilterProp::DealtDamageThisTurn | FilterProp::EnteredThisTurn | FilterProp::ControlledContinuouslySinceTurnBegan | FilterProp::ZoneChangedThisTurn { .. } @@ -1029,6 +1049,12 @@ fn walk_quantity_ref(qty: &mut QuantityRef, category: TextWordCategory, cursor: // CR 101.2 + CR 109.5: "the number of players who control a Goblin" nests a // `PlayerFilter` whose control sub-filter can name a type/color word. QuantityRef::PlayerCount { filter } => walk_player_filter(filter, category, cursor), + // CR 603.2c + CR 109.5: "for each opponent dealt damage" counts the event + // batch's players through a `PlayerFilter` whose control / attribute + // sub-filters can name a creature type / land type / color word. + QuantityRef::EventContextPlayerCount { filter } => { + walk_player_filter(filter, category, cursor) + } // CR 612.2: "unspent [color] mana" spells the color WORD used as such — // consistent with `StaticMode::StepEndUnspentMana` (`None` is the any-color // form; contrast the `ManaSymbolsInManaCost` pip-count no-op below). @@ -1076,6 +1102,9 @@ fn walk_quantity_ref(qty: &mut QuantityRef, category: TextWordCategory, cursor: | QuantityRef::EventContextAmount | QuantityRef::AttachmentsOnLeavingObject { .. } | QuantityRef::EventContextSourceCostX + // CR 700.2d: a count of modes chosen on the triggering spell — a runtime + // event-context snapshot, no printed word. + | QuantityRef::EventContextSourceModesChosen | QuantityRef::CrimesCommittedThisTurn | QuantityRef::BendTypesThisTurn | QuantityRef::LifeGainedThisTurn { .. } @@ -1233,6 +1262,10 @@ fn walk_ability_condition( | AbilityCondition::TargetMatchesFilter { filter, .. } | AbilityCondition::TriggeringSpellTargetsFilter { filter } | AbilityCondition::SourceMatchesFilter { filter } + // CR 615.5 + CR 120.1: "if damage from a creature source is prevented this + // way" gates on a `TargetFilter` naming the prevented event's damage + // source — the same word-bearing carrier as `SourceMatchesFilter`. + | AbilityCondition::PostReplacementDamageSourceMatchesFilter { filter } | AbilityCondition::ZoneChangeObjectMatchesFilter { filter, .. } | AbilityCondition::ControllerControlsMatching { filter } | AbilityCondition::ControllerControlledMatchingAsCast { filter } @@ -2026,6 +2059,9 @@ fn walk_static_mode(mode: &mut StaticMode, category: TextWordCategory, cursor: & | StaticMode::CantBeCast { .. } | StaticMode::CantSearchLibrary { .. } | StaticMode::RestrictLibrarySearchToTop { .. } + // CR 723.1a: a `ProhibitionScope` player-scope decision-authority override — + // no color / land / creature WORD (sibling of the two search prohibitions). + | StaticMode::ControlPlayersDuringOwnLibrarySearch { .. } | StaticMode::CantCauseSacrificeOrExile { .. } | StaticMode::CastWithFlash | StaticMode::GrantsExtraVote @@ -2343,6 +2379,10 @@ fn walk_continuous_modification( } ContinuousModification::CopyValues { .. } | ContinuousModification::SetName { .. } + // CR 612.2 + CR 612.8: a literal NAME is not a color/land/creature word + // used as such — names are structurally excluded from the walk (sibling of + // `SetName` / `SetChosenName`). + | ContinuousModification::SetTextName { .. } | ContinuousModification::AddPower { .. } | ContinuousModification::AddToughness { .. } | ContinuousModification::SetPower { .. } @@ -2372,6 +2412,11 @@ fn walk_continuous_modification( | ContinuousModification::ReplaceTextWord { .. } | ContinuousModification::RetainPrintedTriggerFromSource { .. } | ContinuousModification::RetainPrintedAbilityFromSource { .. } + // CR 707.9a: a nullary "retain this object's other abilities" copy marker — + // it names no word; the retained abilities are read from the source object's + // own base sets, which the layer system re-seeds and re-walks (sibling of + // the two `RetainPrinted*FromSource` markers). + | ContinuousModification::RetainAllOtherAbilitiesFromSource | ContinuousModification::AddSupertype { .. } | ContinuousModification::RemoveSupertype { .. } | ContinuousModification::SetStartingLoyalty { .. } @@ -3009,6 +3054,14 @@ fn walk_effect(effect: &mut Effect, category: TextWordCategory, cursor: &mut Wor } walk_target_filter(filter, category, cursor); } + // CR 612.2 + CR 901.4: the planar-deck analog of `Dig`. The planar deck + // holds only plane / phenomenon cards, so there is no object filter here, + // but both counts are `QuantityExpr`s whose typed `ObjectCount` filter can + // name a creature/land/color word ("look at the top X, where X is …"). + Effect::ArrangePlanarDeckTop { count, keep_on_top } => { + walk_quantity_expr(count, category, cursor); + walk_quantity_expr(keep_on_top, category, cursor); + } Effect::RevealHand { card_filter, count, .. } => { diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 246ccab100..57a16deb10 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -14989,6 +14989,7 @@ impl Effect { | Effect::VentureIntoDungeon | Effect::VentureInto { .. } | Effect::TakeTheInitiative + | Effect::ArrangePlanarDeckTop { .. } | Effect::Planeswalk | Effect::ChaosEnsues | Effect::RedistributeLifeTotals diff --git a/crates/engine/src/types/action_stable_order.rs b/crates/engine/src/types/action_stable_order.rs index c97df36ff3..38e4637f00 100644 --- a/crates/engine/src/types/action_stable_order.rs +++ b/crates/engine/src/types/action_stable_order.rs @@ -58,6 +58,15 @@ fn cmp_payload(a: &GameAction, b: &GameAction) -> Ordering { }; cmp_val(a0, b0) } + // CR 612.1: the resolution-choice response is a single index into the + // engine-enumerated `(category, from, to)` option list — ordered by that + // index alone, exactly like the other indexed resolution choices. + GameAction::ChooseTextWordReplacement { index: a0 } => { + let GameAction::ChooseTextWordReplacement { index: b0 } = b else { + unreachable!("cmp_payload: same-variant invariant"); + }; + cmp_val(a0, b0) + } GameAction::PlayLand { object_id: a0, card_id: a1, diff --git a/crates/engine/tests/integration/text_changing_effects.rs b/crates/engine/tests/integration/text_changing_effects.rs index 0ce31b84e0..cec1bcd587 100644 --- a/crates/engine/tests/integration/text_changing_effects.rs +++ b/crates/engine/tests/integration/text_changing_effects.rs @@ -943,7 +943,7 @@ fn creature_type_in_trigger_intervening_if_changes_firing() { let cond = runner.state().objects[&scout] .trigger_definitions .iter_unchecked() - .find_map(|t| t.condition.clone()); + .find_map(|t| t.definition().condition.clone()); assert!( matches!(&cond, Some(TriggerCondition::ControlsType { .. })), "expected a ControlsType intervening-if on the attack trigger, got {cond:?}" @@ -1507,7 +1507,9 @@ fn creature_type_in_attackers_declared_count_is_replaced() { fn subject_subtypes(obj: &GameObject) -> Vec { let mut out = Vec::new(); for t in obj.trigger_definitions.iter_unchecked() { - if let Some(TriggerCondition::AttackersDeclaredCount { subject, .. }) = &t.condition { + if let Some(TriggerCondition::AttackersDeclaredCount { subject, .. }) = + &t.definition().condition + { let filter = match subject { AttackersDeclaredCountSubject::Controller { filter, .. } | AttackersDeclaredCountSubject::AttackTarget { filter, .. } => filter, From 7cf1a3719223a0353bca15764e00e5723e76e559 Mon Sep 17 00:00:00 2001 From: real-venus Date: Tue, 21 Jul 2026 19:31:12 -0700 Subject: [PATCH 4/9] fix(engine): text-change token creation specs (CR 612.2a) Addresses the remaining review blocker. `Effect::Token` previously walked only `static_abilities`, so after Artificial Evolution changed Goblin to Elf, an ability that creates a Goblin token still created a Goblin token. CR 612.2a is an explicit carve-out from the CR 612.2 name rule: spells and abilities that create creature tokens use creature types to define both the token's types AND its name, and a text-changing effect changes those words "because they're being used as creature types, even though they're also being used as names." The walker now honors that: - `Effect::Token` walks `name` + `types`, and also `colors` (a color word used as a color word, CR 612.2), `keywords`, `count` and `enter_with_counters`. The name rewrite is deliberately narrower than the type rewrite: only for the creature-type category, and only for name words the spec also declares in `types` (snapshotted before substitution), so a predefined token name that is not backed by a declared creature type (Treasure, Food, a Role) is untouched. - The same discipline applies to the replacement-side `TokenSpec` (`additional_token_spec` / `ensure_token_specs`), to `FaceDownProfile.subtypes` (CR 708.2a) on Manifest / TurnFaceDown / ChangeZone / ChangeZoneAll, and to `CopyTokenOf`'s printed carriers. `CopyTokenOf`/`CreateTokenCopyFromPool` take their name and types from the copied object (CR 707.2) and print none of their own, so CR 612.2a has nothing to reach there. Seven runtime regressions, each verified by neutering its seam and observing the failure: the created token's subtype and display name both change; a land-type change leaves the token's creature type and name alone; a color-word change rewrites only the color axis; and the face-down profile, replacement token spec and copy-token carriers each have their own test. Also addresses the review's non-blocking notes: the serde round trip now covers the empty `excluded_to` skip path, the "The new X can't be Y" rider is anchored so a truncated rider no longer parses, coverage detail reports target/ excluded_to/duration, the parser re-pairs via TextPair::split_at instead of raw byte offsets, and the six non-English locales carry real translations. --- client/src/i18n/locales/de/game.json | 4 +- client/src/i18n/locales/es/game.json | 4 +- client/src/i18n/locales/fr/game.json | 4 +- client/src/i18n/locales/it/game.json | 4 +- client/src/i18n/locales/pl/game.json | 4 +- client/src/i18n/locales/pt/game.json | 4 +- crates/engine/src/game/coverage.rs | 28 +- crates/engine/src/game/text_substitution.rs | 281 ++++++++- crates/engine/src/parser/oracle_effect/mod.rs | 13 +- .../src/parser/oracle_effect/sequence.rs | 52 +- .../integration/text_changing_effects.rs | 578 ++++++++++++++++++ 11 files changed, 926 insertions(+), 50 deletions(-) diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index 413ee1334a..e4ae85d37c 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -1147,8 +1147,8 @@ }, "cardChoice": { "textWordReplacement": { - "title": "Change Text", - "subtitle": "Choose a word to replace" + "title": "Text ändern", + "subtitle": "Wähle ein zu ersetzendes Wort" }, "eachPlayerCopyChosen": { "title": "Choose creatures to copy", diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index bf11a1734b..33b4777ef3 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -1147,8 +1147,8 @@ }, "cardChoice": { "textWordReplacement": { - "title": "Change Text", - "subtitle": "Choose a word to replace" + "title": "Cambiar texto", + "subtitle": "Elige una palabra que reemplazar" }, "eachPlayerCopyChosen": { "title": "Choose creatures to copy", diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index 8bc8eb4ad7..02b401f6ec 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -1147,8 +1147,8 @@ }, "cardChoice": { "textWordReplacement": { - "title": "Change Text", - "subtitle": "Choose a word to replace" + "title": "Changer le texte", + "subtitle": "Choisissez un mot à remplacer" }, "eachPlayerCopyChosen": { "title": "Choose creatures to copy", diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index f7137df08c..68af71391b 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -1147,8 +1147,8 @@ }, "cardChoice": { "textWordReplacement": { - "title": "Change Text", - "subtitle": "Choose a word to replace" + "title": "Cambia testo", + "subtitle": "Scegli una parola da sostituire" }, "eachPlayerCopyChosen": { "title": "Choose creatures to copy", diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index 1241e10d26..275eab98e4 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -1147,8 +1147,8 @@ }, "cardChoice": { "textWordReplacement": { - "title": "Change Text", - "subtitle": "Choose a word to replace" + "title": "Zmień tekst", + "subtitle": "Wybierz słowo do zamiany" }, "eachPlayerCopyChosen": { "title": "Choose creatures to copy", diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index fbd5f41af5..767f8bed8e 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -1147,8 +1147,8 @@ }, "cardChoice": { "textWordReplacement": { - "title": "Change Text", - "subtitle": "Choose a word to replace" + "title": "Mudar texto", + "subtitle": "Escolha uma palavra para substituir" }, "eachPlayerCopyChosen": { "title": "Choose creatures to copy", diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index f9499f0292..7678758021 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -2207,11 +2207,35 @@ fn fmt_count_scope(scope: &CountScope) -> &'static str { fn effect_details(effect: &Effect) -> Vec<(String, String)> { let mut d = Vec::new(); match effect { - // CR 612.1: text-change — record which word categories it may replace. + // CR 612.1: text-change — record which word categories it may replace, + // the object it rewrites (CR 612.1 applies to one object), the CR 612.2 + // `to`-word exclusions from a "The new can't be " rider, + // and the CR 611.2b duration. Mirrors the sibling effect arms, which all + // report their `target` via `fmt_target` and their duration via + // `fmt_duration`; without them two structurally different text-changers + // (Sleight of Mind vs. Crystal Spray, Artificial Evolution with and + // without its "can't be Wall" rider) share one coverage signature. Effect::ChangeTextWords { - allowed_categories, .. + target, + allowed_categories, + excluded_to, + duration, } => { d.push(("categories".into(), format!("{allowed_categories:?}"))); + d.push(("target".into(), fmt_target(target))); + if !excluded_to.is_empty() { + d.push(( + "excluded to".into(), + excluded_to + .iter() + .map(|w| w.label()) + .collect::>() + .join(", "), + )); + } + if let Some(dur) = duration { + d.push(("duration".into(), fmt_duration(dur))); + } } Effect::StartYourEngines { player_scope } => { d.push(("players".into(), fmt_player_filter(player_scope))); diff --git a/crates/engine/src/game/text_substitution.rs b/crates/engine/src/game/text_substitution.rs index 8ed388d9ac..1691cbf9ef 100644 --- a/crates/engine/src/game/text_substitution.rs +++ b/crates/engine/src/game/text_substitution.rs @@ -20,6 +20,16 @@ //! color-set-size predicate is not a color WORD (CR 612.2 + CR 107.4), so those //! carriers are explicit no-ops below. //! +//! CR 612.2a is the one documented exception to the name exclusion, and it is +//! about a token a spell/ability CREATES, not about the object's own name: +//! "Most spells and abilities that create creature tokens use creature types to +//! define both the creature types and the names of the tokens. A text-changing +//! effect that affects such a spell or an object with such an ability can change +//! these words because they're being used as creature types, even though they're +//! also being used as names." So a token-creation spec's creature-type words are +//! rewritten in BOTH the token's types and its name — see +//! [`WordCursor::token_spec`]. The object's OWN name is still never touched. +//! //! Every enum match here is exhaustive with no `_` wildcard: a future //! word-bearing variant fails to compile until it is classified as a carrier, //! a recursion point, or an explicit no-op. @@ -146,6 +156,64 @@ impl WordCursor<'_> { self.subtype(category, s); } } + + /// CR 612.2a + CR 612.4: visit a token-creation spec's declared subtype list + /// and the token NAME those subtypes define. + /// + /// CR 612.2 normally protects names ("An effect that changes a color word or + /// a subtype can't change a card name"), but CR 612.2a is the explicit + /// carve-out: "Most spells and abilities that create creature tokens use + /// creature types to define both the creature types and the names of the + /// tokens. A text-changing effect that affects such a spell or an object with + /// such an ability can change these words because they're being used as + /// creature types, even though they're also being used as names." So after + /// Artificial Evolution changes Goblin to Elf, a "create a 1/1 red Goblin + /// creature token" ability creates an Elf token *named* Elf. + /// + /// `types` routes through the shared [`Self::subtype`] path, so the walk's + /// `category` still disambiguates land types from creature types and the + /// resolver's live-creature-type intersection still applies. + /// + /// The `name` rewrite is deliberately narrower than the `types` rewrite: + /// - it applies ONLY under [`TextWordCategory::CreatureType`], because + /// CR 612.2a names creature types specifically — a color word or basic land + /// type appearing in a token's name is still a NAME under CR 612.2; + /// - it applies ONLY to name words the same spec also declares in `types`, + /// which is exactly CR 612.2a's "use creature types to define ... the + /// names" condition. A predefined token name that is not backed by a + /// declared creature type (Treasure, Food, a Role name) is a name used as a + /// name and stays untouched. + /// + /// Collection needs no name pass: every word the name can legally contribute + /// is, by that same condition, already declared in `types`. + fn token_spec(&mut self, category: TextWordCategory, name: &mut String, types: &mut [String]) { + // Snapshot the PRINTED type words before substituting, so the CR 612.2a + // "the name is defined by these creature types" test reads the spec as + // printed rather than as already-rewritten. + let declared: Vec = types.to_vec(); + for token_type in types.iter_mut() { + self.subtype(category, token_type); + } + if category != TextWordCategory::CreatureType { + return; + } + let WordCursor::Replace { from, to } = self else { + return; + }; + let (TextWord::CreatureType(f), TextWord::CreatureType(t)) = (&**from, &**to) else { + return; + }; + if !declared.iter().any(|d| d == f) { + return; + } + if name.split_whitespace().any(|word| word == f) { + *name = name + .split_whitespace() + .map(|word| if word == f { t.as_str() } else { word }) + .collect::>() + .join(" "); + } + } } /// CR 612.2 enumerator: collect every text word of `category` currently present @@ -243,6 +311,27 @@ pub fn collect_present_words(obj: &GameObject, category: TextWordCategory) -> BT /// count/amount (`Draw.count`, `DealDamage.amount`, `Discover.mana_value_limit`, /// …) or `PtValue`-wrapped quantity (`Pump.power/toughness`, `Animate.power`), /// and `UntilCondition::NextMatches` (`ExileFromTopUntil.until`); +/// - CR 612.2a CARVE-OUT — token-creation specs ARE walked, name included. CR +/// 612.2 says a subtype/color change "can't change a card name", but CR 612.2a +/// is the explicit exception: "Most spells and abilities that create creature +/// tokens use creature types to define both the creature types and the names of +/// the tokens. A text-changing effect that affects such a spell or an object +/// with such an ability can change these words because they're being used as +/// creature types, even though they're also being used as names." So +/// `Effect::Token`'s `name` + `types` (and `colors` / `keywords` / `count` / +/// `enter_with_counters` / `static_abilities`), the replacement-side +/// `TokenSpec.characteristics.display_name` + `.subtypes` +/// (`additional_token_spec` / `ensure_token_specs`, see [`walk_token_spec`]), +/// and the face-down body `FaceDownProfile.subtypes` on +/// `ChangeZone`/`ChangeZoneAll`/`Manifest`/`TurnFaceDown` (CR 708.2a, see +/// [`walk_face_down_profile`]) are all rewritten under the same category +/// discipline as a printed type line. The name half of the carve-out is +/// deliberately narrower than the type half — see [`WordCursor::token_spec`]. +/// `CopyTokenOf` / `CreateTokenCopyFromPool` take their name and types from the +/// COPIED object (CR 707.2) and print none of their own, so CR 612.2a has +/// nothing to reach there; their printed carriers (`source_filter`, `count`, +/// the "except it has …" `extra_keywords` / `additional_modifications`, +/// `type_filter`, `mv_bound`) are walked instead; /// - the ONLY deliberately-red `Effect` surfaces (coverage stays red rather than /// silently mis-substituting; no covered card changes a word inside one): /// the mass-population object `target`/`filter` of every `*All` / @@ -251,12 +340,13 @@ pub fn collect_present_words(obj: &GameObject, category: TextWordCategory) -> BT /// `GoadAll`/`ExploreAll`/… — but a non-object carrier on the SAME effect, e.g. /// `PumpAll.power`/`DamageAll.amount`/`ChangeZoneAll.enter_with_counters`, IS /// walked), `PreventDamage.damage_source_filter`, `CastFromZone.alt_ability_cost`, -/// the token / `CopyTokenOf` / face-down (`FaceDownProfile`) creation-spec fields, /// the `EpicCopy` resolved-spell snapshot, the specialized non-listed sub-enums /// (`DamageTargetFilter`/`DamageRedirectTarget`, `GuessSubject`, /// `PerpetualModification`, `IntensityScope`, `ForEachCategoryAction`, -/// name/label `String`s), and the replacement token-spec / `runtime_execute` -/// fields (see [`walk_replacement_definition`]); +/// name/label `String`s), the Forge-script token identifier +/// (`TokenSpec.script_name` — a serialized import identifier, not printed rules +/// text), and the replacement `runtime_execute` field +/// (see [`walk_replacement_definition`]); /// - alternative / additional CAST-cost riders on keywords and statics /// (`AbilityCost` on Evoke/Bestow/…, `StaticMode::CastWithAlternativeCost` / /// `ImposeAdditionalCost.cost` / `AlternativeKeywordCost.cost` / permission @@ -2174,6 +2264,52 @@ fn walk_static_definition( } } +/// CR 612.2a + CR 612.4 + CR 111.1: Walk a resolved [`TokenSpec`] creation +/// payload — the replacement-side sibling of the `Effect::Token` spec walked in +/// [`walk_effect`] (Chatterfang's "plus that many 1/1 green Squirrel creature +/// tokens", Academy Manufactor's Clue/Food/Treasure set). +/// +/// The `display_name` + `subtypes` pair goes through [`WordCursor::token_spec`], +/// so CR 612.2a's "these words define both the creature types and the names" +/// carve-out applies here exactly as it does to `Effect::Token`. `colors` are +/// color words used as color words, `keywords` carry the usual landwalk / +/// protection color params, `static_abilities` are full granted statics, and a +/// `sacrifice_at` duration can gate on a word-bearing `StaticCondition`. +/// +/// Not walked, and each is genuinely wordless or structurally excluded: +/// `core_types` / `supertypes` (CR 205.1a marker enums), `power` / `toughness` +/// and the already-resolved `enter_with_counters` counts (numbers), `source_id` +/// / `controller` / `attach_to` (runtime identity refs, CR 612.2), and +/// `script_name` — a serialized Forge script identifier (`r_1_1_goblin`), not +/// printed rules text. A spec that carries ONLY a script name declares no +/// `subtypes`, so CR 612.2a's "defined by these creature types" precondition is +/// unmet and the walk leaves it alone rather than guessing: coverage for that +/// import-only shape stays red instead of silently mis-substituting. +fn walk_token_spec( + spec: &mut crate::types::proposed_event::TokenSpec, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + let characteristics = &mut spec.characteristics; + cursor.token_spec( + category, + &mut characteristics.display_name, + &mut characteristics.subtypes, + ); + for color in characteristics.colors.iter_mut() { + cursor.color(category, color); + } + for keyword in characteristics.keywords.iter_mut() { + walk_keyword(keyword, category, cursor); + } + for static_def in spec.static_abilities.iter_mut() { + walk_static_definition(static_def, category, cursor); + } + if let Some(dur) = &mut spec.sacrifice_at { + walk_duration(dur, category, cursor); + } +} + /// CR 614.1 + CR 612.1: Walk the word-bearing children of a replacement effect. /// A creature type / land type / color word can live in the event-shape filter /// (`valid_card` — "whenever a Goblin would enter"), the applicability @@ -2181,11 +2317,14 @@ fn walk_static_definition( /// resulting `execute` ability (its effects/filters), the damage source / /// redirect filters, or the additional-token replacement's own subtypes. /// +/// CR 612.2a: the token-spec creation fields (`additional_token_spec` / +/// `ensure_token_specs`) ARE walked, via [`walk_token_spec`] — their creature-type +/// words define both the created token's types and its name, matching the +/// `Effect::Token` treatment in [`walk_effect`]. +/// /// Intentionally NOT walked (coverage stays red rather than silently /// mis-substituting): `runtime_execute` (a resolution-time `ResolvedAbility` -/// continuation snapshot, not printed rules text); the token-spec creation -/// fields (`additional_token_spec` / `ensure_token_specs` — token subtypes, -/// consistent with the `Effect::Token` subtype exclusion in [`walk_effect`]); +/// continuation snapshot, not printed rules text); /// `mana_modification` (a mana SYMBOL, CR 612.2 + CR 107.4); `counter_match` /// (a counter kind, CR 122.1, not a color/land/creature word); and the scalar /// scope / expiry / player-axis fields. @@ -2214,6 +2353,15 @@ fn walk_replacement_definition( if let Some(redirect) = &mut replacement.redirect_target { walk_target_filter(redirect, category, cursor); } + // CR 612.2a + CR 614.1a: the appended / ensured token-creation specs. + if let Some(spec) = &mut replacement.additional_token_spec { + walk_token_spec(spec, category, cursor); + } + if let Some(specs) = &mut replacement.ensure_token_specs { + for spec in specs.iter_mut() { + walk_token_spec(spec, category, cursor); + } + } // CR 614.10 + CR 118.12a: an optional / pay-cost replacement carries a // `decline` continuation ability (and a `MayCost` payment) that are word- // bearing children — walked via [`walk_replacement_mode`]. @@ -2478,6 +2626,27 @@ fn walk_exiled_spell_rider( } } +/// CR 612.2 + CR 708.2a: Walk a face-down-entry characteristics spec. The +/// `subtypes` an effect specifies for a face-down permanent ("They're 2/2 +/// **Cyberman** artifact creatures.", "It's a **Forest** land.") are words used +/// as subtypes in exactly the sense of CR 612.2, so they route through the same +/// category-disciplined [`WordCursor::subtype`] path as a printed type line — +/// the CR 612.2a token-spec sibling for face-down bodies. +/// +/// The remaining fields are genuinely wordless: `power` / `toughness` are +/// numbers, `body` / `extra_core_types` are `CoreType` markers (CR 205.1a — not +/// a color/land/creature WORD), and `ward` is a mana-symbol cost (CR 107.4 — a +/// pip, not a color word), consistent with the module-level pip exclusion. +fn walk_face_down_profile( + profile: &mut crate::types::ability::FaceDownProfile, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + for subtype in profile.subtypes.iter_mut() { + cursor.subtype(category, subtype); + } +} + /// CR 612.1: Walk the word-bearing children of an ability's effect. Descends into /// nested-ability composites (so granted statics/keywords/subtype filters are /// reached) and the two nested-effect replacement builders. @@ -2662,12 +2831,41 @@ fn walk_effect(effect: &mut Effect, category: TextWordCategory, cursor: &mut Wor walk_duration(dur, category, cursor); } } + // CR 612.2a + CR 612.4: the token-creation spec is a first-class word + // carrier, not a red surface. Its `types` are subtypes used as subtypes + // and its `name` is defined by those creature types (the CR 612.2a + // carve-out from the CR 612.2 name protection) — both go through + // [`WordCursor::token_spec`]. The spec's `colors` are color words used as + // color words ("a 1/1 **red** Goblin creature token"), its granted + // `keywords` carry the same landwalk / protection color params as any + // printed keyword, and its `count` / `enter_with_counters` quantities can + // embed a typed `ObjectCount` filter ("for each Goblin you control"). + // The `owner` / `attach_to` axes are already reached above via + // `target_filter_mut`. Effect::Token { - static_abilities, .. + name, + types, + colors, + keywords, + static_abilities, + count, + enter_with_counters, + .. } => { + cursor.token_spec(category, name, types); + for color in colors.iter_mut() { + cursor.color(category, color); + } + for keyword in keywords.iter_mut() { + walk_keyword(keyword, category, cursor); + } for static_def in static_abilities.iter_mut() { walk_static_definition(static_def, category, cursor); } + walk_quantity_expr(count, category, cursor); + for (_, qty) in enter_with_counters.iter_mut() { + walk_quantity_expr(qty, category, cursor); + } } // CR 611.2b: effects that install a continuous modification carry a // `duration` (`Option`/`Duration`) whose `ForAsLongAs` shape @@ -2935,6 +3133,33 @@ fn walk_effect(effect: &mut Effect, category: TextWordCategory, cursor: &mut Wor walk_target_filter(source_filter, category, cursor); walk_target_filter(partner_filter, category, cursor); } + // CR 707.2 + CR 707.9: `CopyTokenOf` takes its name / types / colors from + // the COPIED object (CR 707.2), so it has no printed token name or type + // list of its own and CR 612.2a has nothing to reach there — the copied + // object's own words are text-changed on that object, not here. What it + // does print is the non-targeting copy-source `source_filter` ("for each + // Goblin you control, create a token that's a copy of it"), the "except it + // has …" `extra_keywords` / `additional_modifications` riders, and the + // token `count`. The `target` / `owner` pair follows the shared + // `target_filter_mut` convention documented on [`walk_effect`]. + Effect::CopyTokenOf { + source_filter, + count, + extra_keywords, + additional_modifications, + .. + } => { + if let Some(f) = source_filter { + walk_target_filter(f, category, cursor); + } + walk_quantity_expr(count, category, cursor); + for keyword in extra_keywords.iter_mut() { + walk_keyword(keyword, category, cursor); + } + for modification in additional_modifications.iter_mut() { + walk_continuous_modification(modification, category, cursor); + } + } Effect::CopyTokenBlockingAttacker { source_filter, owner, @@ -3017,9 +3242,27 @@ fn walk_effect(effect: &mut Effect, category: TextWordCategory, cursor: &mut Wor // ---- Effects with a `target`/`player` primary NOT surfaced by // `target_filter_mut` (in its `None` group) plus a count / filter ---- - Effect::Manifest { target, count, .. } => { + // CR 708.2a: the manifest body's effect-specified `profile` subtypes are + // words used as subtypes (see [`walk_face_down_profile`]). + Effect::Manifest { + target, + count, + profile, + .. + } => { walk_target_filter(target, category, cursor); walk_quantity_expr(count, category, cursor); + if let Some(p) = profile { + walk_face_down_profile(p, category, cursor); + } + } + // CR 708.2a + CR 708.2b: "Turn target creature face down. It's a 2/2 + // Cyberman artifact creature." — the specified body's subtypes are words + // used as subtypes; `target` is reached via `target_filter_mut`. + Effect::TurnFaceDown { profile, .. } => { + if let Some(p) = profile { + walk_face_down_profile(p, category, cursor); + } } Effect::Cloak { target, @@ -3132,6 +3375,7 @@ fn walk_effect(effect: &mut Effect, category: TextWordCategory, cursor: &mut Wor enter_with_counters, conditional_enter_with_counters, enters_modified_if, + face_down_profile, .. } => { for (_, count) in enter_with_counters.iter_mut() { @@ -3144,14 +3388,22 @@ fn walk_effect(effect: &mut Effect, category: TextWordCategory, cursor: &mut Wor if let Some(f) = enters_modified_if { walk_target_filter(f, category, cursor); } + // CR 708.2a: the face-down entry body's subtypes. + if let Some(p) = face_down_profile { + walk_face_down_profile(p, category, cursor); + } } Effect::ChangeZoneAll { enter_with_counters, + face_down_profile, .. } => { for (_, count) in enter_with_counters.iter_mut() { walk_quantity_expr(count, category, cursor); } + if let Some(p) = face_down_profile { + walk_face_down_profile(p, category, cursor); + } } // ---- Vec secondary carriers (CR 707.9) ---- @@ -3212,10 +3464,13 @@ fn walk_effect(effect: &mut Effect, category: TextWordCategory, cursor: &mut Wor // `target_filter_mut()` walk above, (b) carries no color/land/creature WORD // (fixed counts, markers, ids, name/label strings, mana pips), or (c) holds // a deliberately-red carrier: a mass-population `*All` object filter, a - // token / CopyTokenOf / face-down / perpetual creation-spec, a resolved-spell - // snapshot (`EpicCopy`), a secondary cast cost, or a specialized non-listed - // sub-enum (`FaceDownProfile`, `GuessSubject`, `PerpetualModification`, - // `IntensityScope`, `ForEachCategoryAction`, `DamageTargetFilter`, etc.). + // perpetual creation-spec, a resolved-spell snapshot (`EpicCopy`), a + // secondary cast cost, or a specialized non-listed sub-enum + // (`GuessSubject`, `PerpetualModification`, `IntensityScope`, + // `ForEachCategoryAction`, `DamageTargetFilter`, etc.). Token-creation + // and face-down creation specs are NO LONGER in this bucket — CR 612.2a / + // CR 708.2a route them through `WordCursor::token_spec` / + // `walk_face_down_profile` above. Effect::ApplyPostReplacementDamage { .. } | Effect::PairWith { .. } | Effect::Destroy { .. } @@ -3245,7 +3500,6 @@ fn walk_effect(effect: &mut Effect, category: TextWordCategory, cursor: &mut Wor | Effect::EndCombatPhase | Effect::SwitchPT { .. } | Effect::EpicCopy { .. } - | Effect::CopyTokenOf { .. } | Effect::Myriad | Effect::Encore | Effect::CombineHost { .. } @@ -3314,7 +3568,6 @@ fn walk_effect(effect: &mut Effect, category: TextWordCategory, cursor: &mut Wor | Effect::Detain { .. } | Effect::SetRoomDoorLock { .. } | Effect::ManifestDread - | Effect::TurnFaceDown { .. } | Effect::ExtraTurn { .. } | Effect::Double { .. } | Effect::RuntimeHandled { .. } diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 8b4fc0b887..481b6a9daf 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -9940,11 +9940,14 @@ fn try_parse_still_a_type(tp: TextPair) -> Option { /// enumeration. Composes: prefix → `parse_target` (target axis) → connector → /// one or two categories → optional trailing duration. fn try_parse_change_text(tp: TextPair) -> Option { - // CR 115.1: "change the text of ". `TextPair::strip_prefix` advances - // both cases in lockstep, then `parse_target` claims the target noun phrase - // off the original-case slice and `TextPair::split_at` re-pairs the - // remainder — no hand-rolled byte-offset arithmetic on either side. - let after_prefix = tp.strip_prefix("change the text of ")?; + // CR 115.1: "change the text of ". `tag()` stays the parsing-dispatch + // authority (nom mandate); `TextPair::split_at` re-pairs the original-case + // slice from each combinator's remainder, so neither the prefix hop nor the + // `parse_target` hop hand-rolls a `&text[..]` byte-offset slice. + let (rest_lower, _) = tag::<_, _, OracleError<'_>>("change the text of ") + .parse(tp.lower) + .ok()?; + let (_, after_prefix) = tp.split_at(tp.len() - rest_lower.len()); let (target, after_target_orig) = super::oracle_target::parse_target(after_prefix.original); let (_, after_target) = after_prefix.split_at(after_prefix.len() - after_target_orig.len()); diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 5d86cbb5a8..c98ada52fd 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -7123,13 +7123,34 @@ pub(super) fn parse_followup_continuation_ast( } } +/// CR 608.2c: The excluded-`to` rider is a COMPLETE, self-contained sentence, so +/// nothing but optional whitespace and one sentence-terminating period may follow +/// the excluded word. Anchoring the tail with `all_consuming` is what keeps a +/// truncated or continued sentence ("The new creature type can't be Wall or +/// Wizard.", "… can't be Wall until end of turn.") from silently degrading into +/// the first recognizable word and dropping the rest of the restriction. +fn is_terminal_rider_tail(tail: &str) -> bool { + all_consuming(terminated( + opt(multispace1::<_, OracleError<'_>>), + opt(tag::<_, _, OracleError<'_>>(".")), + )) + .parse(tail) + .is_ok() +} + /// CR 612.2 + CR 608.2c: Parse "the new can't be [.]" — the excluded-`to` rider on a text-changing -/// effect. Combinator-only: `tag`/`alt`/`value` dispatch on the category phrase, -/// then the excluded word is parsed in that same category (colors via -/// `parse_color`, creature/land types via the canonical subtype matcher). One -/// `alt` per axis — no permutation enumeration. Returns the parsed [`TextWord`], -/// or `None` when the sentence is not this rider or the word is unrecognized. +/// type> can't be ." — the excluded-`to` rider on a text-changing effect. +/// Combinator-only: `tag`/`alt`/`value` dispatch on the category phrase, then the +/// excluded word is parsed in that same category (colors via `parse_color`, +/// creature/land types via the canonical subtype matcher). One `alt` per axis — +/// no permutation enumeration. +/// +/// The whole production is anchored: the head via `tag`, the tail via +/// [`is_terminal_rider_tail`]. Returns the parsed [`TextWord`], or `None` when +/// the sentence is not this rider, the word is unrecognized, or the sentence +/// carries text this parser does not model past the excluded word — the last case +/// stays red (an under-restricted `excluded_to` would silently offer an illegal +/// `to` word). fn parse_text_change_excluded_to(lower: &str) -> Option { let (rest, _) = tag::<_, _, OracleError<'_>>("the new ").parse(lower).ok()?; let (rest, category) = alt(( @@ -7142,25 +7163,22 @@ fn parse_text_change_excluded_to(lower: &str) -> Option { )) .parse(rest) .ok()?; - let (rest, _) = tag::<_, _, OracleError<'_>>(" can't be ") + let (word, _) = tag::<_, _, OracleError<'_>>(" can't be ") .parse(rest) .ok()?; - let word = rest.trim().trim_end_matches('.').trim(); match category { TextWordCategory::ColorWord => { - let (_, color) = nom_primitives::parse_color(word).ok()?; - Some(TextWord::Color(color)) + let (tail, color) = nom_primitives::parse_color(word).ok()?; + is_terminal_rider_tail(tail).then_some(TextWord::Color(color)) } TextWordCategory::BasicLandType => { - let (canonical, _) = crate::parser::oracle_util::parse_subtype(word)?; - canonical - .parse::() - .ok() - .map(TextWord::BasicLandType) + let (canonical, consumed) = crate::parser::oracle_util::parse_subtype(word)?; + let land = canonical.parse::().ok()?; + is_terminal_rider_tail(&word[consumed..]).then_some(TextWord::BasicLandType(land)) } TextWordCategory::CreatureType => { - let (canonical, _) = crate::parser::oracle_util::parse_subtype(word)?; - Some(TextWord::CreatureType(canonical)) + let (canonical, consumed) = crate::parser::oracle_util::parse_subtype(word)?; + is_terminal_rider_tail(&word[consumed..]).then_some(TextWord::CreatureType(canonical)) } } } diff --git a/crates/engine/tests/integration/text_changing_effects.rs b/crates/engine/tests/integration/text_changing_effects.rs index cec1bcd587..316e1eb6c5 100644 --- a/crates/engine/tests/integration/text_changing_effects.rs +++ b/crates/engine/tests/integration/text_changing_effects.rs @@ -1481,8 +1481,38 @@ fn serde_round_trip_text_word_types() { duration: None, }; let json = serde_json::to_string(&effect).expect("serialize Effect"); + assert!( + json.contains("excluded_to"), + "a non-empty excluded_to must be emitted: {json}" + ); let back: Effect = serde_json::from_str(&json).expect("deserialize Effect"); assert_eq!(effect, back); + + // The `skip_serializing_if = "Vec::is_empty"` path itself: an EMPTY + // `excluded_to` (Artificial Evolution without its "can't be Wall" rider, and + // every Sleight of Mind / Magical Hack) must be OMITTED from the JSON and + // come back as an empty vec via `#[serde(default)]`. This is the case the + // non-empty fixture above cannot exercise. + let bare = Effect::ChangeTextWords { + target: engine::types::ability::TargetFilter::Any, + allowed_categories: vec![TextWordCategory::ColorWord], + excluded_to: Vec::new(), + duration: None, + }; + let bare_json = serde_json::to_string(&bare).expect("serialize bare Effect"); + assert!( + !bare_json.contains("excluded_to"), + "an empty excluded_to must be skipped by skip_serializing_if: {bare_json}" + ); + let bare_back: Effect = serde_json::from_str(&bare_json).expect("deserialize bare Effect"); + assert_eq!(bare, bare_back); + match bare_back { + Effect::ChangeTextWords { excluded_to, .. } => assert!( + excluded_to.is_empty(), + "the omitted field must default back to an empty vec: {excluded_to:?}" + ), + other => panic!("expected ChangeTextWords, got {other:?}"), + } } /// CR 612.1 + CR 508.1 (review finding 1): a creature type that lives ONLY inside @@ -2211,3 +2241,551 @@ fn creature_type_in_amass_subtype_is_replaced() { "Goblin must be gone from the Amass subtype: {after:?}" ); } + +// --------------------------------------------------------------------------- +// CR 612.2a — token-creation specs (name + creature types) +// --------------------------------------------------------------------------- + +/// Verbatim Oracle ability line from Ib Halfheart, Goblin Tactician. Chosen +/// because the SAME line carries all three word categories at once: a basic land +/// type in the sacrifice cost (Mountain), a color word in the token spec (red), +/// and a creature type used simultaneously as the token's type and its name +/// (Goblin) — so the three category-discipline tests below share one fixture and +/// each one's "unaffected sibling" assertion is about a word that is genuinely +/// present, never about an absent word. +const IB_HALFHEART_TOKEN_ABILITY: &str = + "Sacrifice a Mountain: Create two 1/1 red Goblin creature tokens."; + +/// Every token permanent currently on the battlefield. +fn battlefield_tokens( + state: &engine::types::game_state::GameState, +) -> Vec<&engine::game::game_object::GameObject> { + state + .objects + .values() + .filter(|o| o.is_token && o.zone == engine::types::zones::Zone::Battlefield) + .collect() +} + +/// Index of the `Effect::Token` activated ability on `obj`. +fn token_ability_index(obj: &engine::game::game_object::GameObject) -> usize { + obj.abilities + .iter() + .position(|a| matches!(&*a.effect, Effect::Token { .. })) + .expect("the fixture must expose a token-creating activated ability") +} + +/// The live `(name, types)` pair of the object's token-creation spec. +fn token_spec_identity(obj: &engine::game::game_object::GameObject) -> (String, Vec) { + obj.abilities + .iter() + .find_map(|a| match &*a.effect { + Effect::Token { name, types, .. } => Some((name.clone(), types.clone())), + _ => None, + }) + .expect("the fixture must expose a token-creation spec") +} + +/// CR 612.2a (maintainer HIGH blocker): "Most spells and abilities that create +/// creature tokens use creature types to define both the creature types and the +/// names of the tokens. A text-changing effect that affects such a spell or an +/// object with such an ability can change these words because they're being used +/// as creature types, even though they're also being used as names." +/// +/// Full production path: parse the real Oracle line -> cast Artificial Evolution +/// -> resolve -> `WaitingFor::TextWordReplacement` -> +/// `GameAction::ChooseTextWordReplacement` (Goblin -> Elf) -> activate the token +/// ability -> pay the sacrifice cost -> resolve -> inspect the CREATED TOKEN +/// OBJECTS. +/// +/// Revert-failing assertions: each created token's `card_types.subtypes` contains +/// "Elf" and its `name` / `base_name` are "Elf". Before this fix `walk_effect`'s +/// `Effect::Token` arm descended only into `static_abilities`, so both the spec's +/// `types` and its `name` stayed "Goblin" and the ability still created Goblin +/// tokens named Goblin — exactly the reported defect. +#[test] +fn creature_type_change_rewrites_created_token_name_and_subtypes() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let ib = scenario + .add_creature(P0, "Ib Halfheart, Goblin Tactician", 3, 3) + .from_oracle_text(IB_HALFHEART_TOKEN_ABILITY) + .id(); + let mountain = scenario.add_basic_land(P0, ManaColor::Red); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Artificial Evolution", true, ARTIFICIAL_EVOLUTION) + .id(); + let mut runner = scenario.build(); + runner.state_mut().all_creature_types = vec!["Goblin".to_string(), "Elf".to_string()]; + + // Fixture reach guard: the PRINTED spec really is a Goblin token named Goblin + // (so this test cannot pass on a degenerate typeless/nameless spec), and the + // CR 612.2 enumerator offers Goblin as a legal `from` word. + let (printed_name, printed_types) = token_spec_identity(&runner.state().objects[&ib]); + assert_eq!(printed_name, "Goblin", "printed token name"); + assert!( + printed_types.iter().any(|t| t == "Goblin"), + "printed token types must declare Goblin: {printed_types:?}" + ); + let offered = + collect_present_words(&runner.state().objects[&ib], TextWordCategory::CreatureType); + assert!( + offered.contains(&TextWord::CreatureType("Goblin".to_string())), + "the token spec must contribute Goblin to the CR 612.2 `from` set: {offered:?}" + ); + + runner.cast(spell).target_object(ib).resolve(); + apply_replacement( + &mut runner, + TextWord::CreatureType("Goblin".to_string()), + TextWord::CreatureType("Elf".to_string()), + ); + + // The live spec now names Elf in BOTH halves of the CR 612.2a pair. + let (live_name, live_types) = token_spec_identity(&runner.state().objects[&ib]); + assert_eq!( + live_name, "Elf", + "CR 612.2a: the token's NAME is changed too, because the word is used as a creature type" + ); + assert!( + live_types.iter().any(|t| t == "Elf") && !live_types.iter().any(|t| t == "Goblin"), + "the token spec's types must now name Elf, not Goblin: {live_types:?}" + ); + + // Drive the real token creation. + let index = token_ability_index(&runner.state().objects[&ib]); + runner.activate(ib, index).pay_with(&[mountain]).resolve(); + + let tokens = battlefield_tokens(runner.state()); + assert_eq!( + tokens.len(), + 2, + "the ability creates two tokens: {:?}", + tokens.iter().map(|t| &t.name).collect::>() + ); + for token in &tokens { + assert!( + token.card_types.subtypes.iter().any(|s| s == "Elf"), + "the created token must be an Elf: {:?}", + token.card_types.subtypes + ); + assert!( + !token.card_types.subtypes.iter().any(|s| s == "Goblin"), + "the created token must NOT still be a Goblin: {:?}", + token.card_types.subtypes + ); + assert_eq!( + token.name, "Elf", + "CR 612.2a: the created token is NAMED Elf, not Goblin" + ); + assert_eq!(token.base_name, "Elf", "the token's base name too"); + } +} + +/// CR 612.2 category isolation (the maintainer's "unaffected sibling"): a +/// BASIC-LAND-TYPE text change must not touch the creature-type half of the same +/// token-creation spec. +/// +/// Positive reach guard (not vacuous): the fixture has NO Mountain on the +/// battlefield — only an Island. The ability is therefore activatable at all only +/// because Magical Hack rewrote its "Sacrifice a Mountain" cost to "Sacrifice an +/// Island"; if the change had not applied, `ActivateAbility` / the cost payment +/// would be rejected and the driver would panic before any assertion runs. +/// +/// Revert-failing assertions: the created token is still a Goblin NAMED Goblin. +/// If `WordCursor::token_spec` dropped its `category != CreatureType` guard and +/// rewrote names under every category, or if the `types` walk ignored the +/// category, these flip. +#[test] +fn basic_land_type_change_leaves_created_token_creature_type() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let ib = scenario + .add_creature(P0, "Ib Halfheart, Goblin Tactician", 3, 3) + .from_oracle_text(IB_HALFHEART_TOKEN_ABILITY) + .id(); + // Deliberately NO Mountain: the cost is only payable after Mountain -> Island. + let island = scenario.add_basic_land(P0, ManaColor::Blue); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Magical Hack", true, MAGICAL_HACK) + .id(); + let mut runner = scenario.build(); + runner.state_mut().all_creature_types = vec!["Goblin".to_string(), "Elf".to_string()]; + + runner.cast(spell).target_object(ib).resolve(); + apply_replacement( + &mut runner, + TextWord::BasicLandType(BasicLandType::Mountain), + TextWord::BasicLandType(BasicLandType::Island), + ); + + let index = token_ability_index(&runner.state().objects[&ib]); + runner.activate(ib, index).pay_with(&[island]).resolve(); + + // Reach guard: the Island really was sacrificed, so the cost text DID change. + assert_eq!( + runner.state().objects.get(&island).map(|o| o.zone), + Some(engine::types::zones::Zone::Graveyard), + "the rewritten 'Sacrifice an Island' cost must have consumed the Island" + ); + + let tokens = battlefield_tokens(runner.state()); + assert_eq!(tokens.len(), 2, "the ability creates two tokens"); + for token in &tokens { + assert!( + token.card_types.subtypes.iter().any(|s| s == "Goblin"), + "a basic-land-type change must not retype the token: {:?}", + token.card_types.subtypes + ); + assert_eq!( + token.name, "Goblin", + "CR 612.2: only the CR 612.2a creature-type carve-out reaches a token's name" + ); + } +} + +/// CR 612.2 (the second half of the category-discipline pair): a COLOR-WORD text +/// change rewrites the color word printed in the token spec ("a 1/1 **red** +/// Goblin creature token") and leaves the creature-type / name half alone. +/// +/// This is the rules-correct reading of CR 612.2 — "red" there is a Magic color +/// word being used as a color word — so the sibling assertion is not "the token +/// spec is inert under a color change" but "a color change touches only the color +/// axis". +/// +/// Revert-failing assertions: the created token's `color` is `[Blue]` (drop the +/// `colors` walk in the `Effect::Token` arm and it stays `[Red]`), while its +/// subtype and name stay Goblin (drop the `category != CreatureType` guard in +/// `WordCursor::token_spec` and the name would be rewritten by a color change). +#[test] +fn color_word_change_rewrites_created_token_color_not_its_creature_type() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let ib = scenario + .add_creature(P0, "Ib Halfheart, Goblin Tactician", 3, 3) + .from_oracle_text(IB_HALFHEART_TOKEN_ABILITY) + .id(); + let mountain = scenario.add_basic_land(P0, ManaColor::Red); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Sleight of Mind", true, SLEIGHT_OF_MIND) + .id(); + let mut runner = scenario.build(); + runner.state_mut().all_creature_types = vec!["Goblin".to_string(), "Elf".to_string()]; + + runner.cast(spell).target_object(ib).resolve(); + apply_replacement( + &mut runner, + TextWord::Color(ManaColor::Red), + TextWord::Color(ManaColor::Blue), + ); + + let index = token_ability_index(&runner.state().objects[&ib]); + runner.activate(ib, index).pay_with(&[mountain]).resolve(); + + let tokens = battlefield_tokens(runner.state()); + assert_eq!(tokens.len(), 2, "the ability creates two tokens"); + for token in &tokens { + assert!( + token.color.contains(&ManaColor::Blue), + "the color word 'red' in the token spec must have become blue: {:?}", + token.color + ); + assert!( + !token.color.contains(&ManaColor::Red), + "the token must no longer be red: {:?}", + token.color + ); + // Category discipline: the creature-type half is untouched. + assert!( + token.card_types.subtypes.iter().any(|s| s == "Goblin"), + "a color-word change must not retype the token: {:?}", + token.card_types.subtypes + ); + assert_eq!( + token.name, "Goblin", + "a color-word change must not rename the token (CR 612.2)" + ); + } +} + +/// CR 612.2 + CR 608.2c (CodeRabbit item): the "The new can't be +/// ." rider is a complete sentence. A truncated or continued rider must NOT +/// parse into `excluded_to` — an under-restricted exclusion silently offers an +/// illegal `to` word at resolution. +/// +/// Paired positive (so none of the negatives is vacuous): the well-formed rider +/// on the same base text DOES populate `excluded_to` with Wall. +#[test] +fn partial_excluded_to_rider_does_not_parse() { + const BASE: &str = "Change the text of target spell or permanent by replacing \ + all instances of one creature type with another."; + + // Positive control: the complete rider is absorbed. + assert_eq!( + change_text_snapshots( + "Artificial Evolution", + &format!("{BASE} The new creature type can't be Wall.") + ), + vec![( + vec![TextWordCategory::CreatureType], + vec![TextWord::CreatureType("Wall".to_string())], + None + )], + "the complete rider must still populate excluded_to" + ); + + // Each of these carries text past the excluded word that the recognizer does + // not model. Keeping the leading word and dropping the rest would install a + // WRONGLY-narrow restriction, so the rider must not be absorbed at all. + for tail in [ + "The new creature type can't be Wall or Wizard.", + "The new creature type can't be Wall until end of turn.", + "The new creature type can't be Wall unless you control a Wall.", + ] { + let snapshots = change_text_snapshots("Artificial Evolution", &format!("{BASE} {tail}")); + assert!( + snapshots.iter().all(|(_, excluded, _)| excluded.is_empty()), + "an unmodelled rider tail must not populate excluded_to ({tail}): {snapshots:?}" + ); + } + + // Truncated riders (no recognizable word) must likewise not parse. + for tail in [ + "The new creature type can't be.", + "The new creature type can't be .", + ] { + let snapshots = change_text_snapshots("Artificial Evolution", &format!("{BASE} {tail}")); + assert!( + snapshots.iter().all(|(_, excluded, _)| excluded.is_empty()), + "a truncated rider must not populate excluded_to ({tail}): {snapshots:?}" + ); + } +} + +/// CR 708.2a + CR 612.2 (sibling creation-spec, `FaceDownProfile.subtypes`): the +/// creature subtype an effect specifies for a permanent it puts onto the +/// battlefield face down ("They're 2/2 Cyberman artifact creatures.") is a word +/// used as a subtype, so a creature-type text change rewrites it. Same shape as +/// the token-creation spec above; `FaceDownProfile` was in the same +/// deliberately-red bucket before this change. +/// +/// Full cast pipeline (parse Artificial Evolution -> cast -> resolve -> choose). +/// Revert guard: without the `walk_face_down_profile` call on `Effect::Manifest`, +/// Cyberman is never collected, no option offers Cyberman -> Elf and +/// `apply_replacement` panics; the live `profile.subtypes` also stays Cyberman. +#[test] +fn creature_type_in_face_down_profile_is_replaced() { + use engine::types::ability::{ + AbilityDefinition, AbilityKind, FaceDownProfile, QuantityExpr, TargetFilter, + }; + + let manifest = Effect::Manifest { + target: TargetFilter::Controller, + count: QuantityExpr::Fixed { value: 1 }, + profile: Some(FaceDownProfile { + subtypes: vec!["Cyberman".to_string()], + ..FaceDownProfile::vanilla_2_2() + }), + enters_under: None, + }; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature(P0, "Cyber Controller", 2, 2) + .with_ability_definition(AbilityDefinition::new(AbilityKind::Activated, manifest)) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Artificial Evolution", true, ARTIFICIAL_EVOLUTION) + .id(); + let mut runner = scenario.build(); + runner.state_mut().all_creature_types = vec!["Cyberman".to_string(), "Elf".to_string()]; + + let before = collect_present_words( + &runner.state().objects[&creature], + TextWordCategory::CreatureType, + ); + assert!( + before.contains(&TextWord::CreatureType("Cyberman".to_string())), + "the face-down profile should contribute Cyberman: {before:?}" + ); + + runner.cast(spell).target_object(creature).resolve(); + apply_replacement( + &mut runner, + TextWord::CreatureType("Cyberman".to_string()), + TextWord::CreatureType("Elf".to_string()), + ); + + let after = ability_effect_subtypes(&runner.state().objects[&creature], |e, out| { + if let Effect::Manifest { + profile: Some(p), .. + } = e + { + out.extend(p.subtypes.iter().cloned()); + } + }); + assert_eq!( + after, + vec!["Elf".to_string()], + "the face-down body must now specify Elf, not Cyberman" + ); +} + +/// CR 612.2a + CR 614.1a (sibling creation-spec, replacement `TokenSpec`): the +/// replacement-side token-creation spec (Chatterfang's "plus that many 1/1 green +/// Squirrel creature tokens") carries the same CR 612.2a name/creature-type pair +/// as `Effect::Token`, so a creature-type change rewrites both halves. +/// +/// Full cast pipeline. Revert guard: without the `walk_token_spec` calls in +/// `walk_replacement_definition`, Squirrel is never collected, no option offers +/// Squirrel -> Elf and `apply_replacement` panics; the spec's `display_name` and +/// `subtypes` also stay Squirrel. +#[test] +fn creature_type_in_replacement_token_spec_is_replaced() { + use engine::types::card_type::CoreType; + use engine::types::identifiers::ObjectId as OId; + use engine::types::proposed_event::{TokenCharacteristics, TokenSpec}; + use engine::types::replacements::ReplacementEvent; + + let spec = TokenSpec { + characteristics: TokenCharacteristics { + display_name: "Squirrel".to_string(), + power: Some(1), + toughness: Some(1), + core_types: vec![CoreType::Creature], + subtypes: vec!["Squirrel".to_string()], + supertypes: vec![], + colors: vec![ManaColor::Green], + keywords: vec![], + }, + script_name: String::new(), + static_abilities: vec![], + enter_with_counters: vec![], + tapped: false, + enters_attacking: false, + sacrifice_at: None, + source_id: OId(0), + controller: P0, + attach_to: None, + }; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature(P0, "Chatterfang, Squirrel General", 4, 4) + .with_replacement_definition( + engine::types::ability::ReplacementDefinition::new(ReplacementEvent::CreateToken) + .additional_token_spec(spec), + ) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Artificial Evolution", true, ARTIFICIAL_EVOLUTION) + .id(); + let mut runner = scenario.build(); + runner.state_mut().all_creature_types = vec!["Squirrel".to_string(), "Elf".to_string()]; + + let before = collect_present_words( + &runner.state().objects[&creature], + TextWordCategory::CreatureType, + ); + assert!( + before.contains(&TextWord::CreatureType("Squirrel".to_string())), + "the replacement token spec should contribute Squirrel: {before:?}" + ); + + runner.cast(spell).target_object(creature).resolve(); + apply_replacement( + &mut runner, + TextWord::CreatureType("Squirrel".to_string()), + TextWord::CreatureType("Elf".to_string()), + ); + + let obj = &runner.state().objects[&creature]; + let live = obj + .replacement_definitions + .iter_unchecked() + .find_map(|r| r.additional_token_spec.as_ref()) + .expect("the replacement's additional token spec must survive the walk"); + assert_eq!( + live.characteristics.subtypes, + vec!["Elf".to_string()], + "the appended token spec must now be an Elf" + ); + assert_eq!( + live.characteristics.display_name, "Elf", + "CR 612.2a: the appended token's NAME changes with its creature type" + ); +} + +/// CR 707.2 + CR 707.9 (sibling creation-spec, `CopyTokenOf`): a copy-token +/// effect prints no token name or type list of its own — those come from the +/// COPIED object (CR 707.2), so CR 612.2a has nothing to reach there. What it +/// DOES print is the non-targeting copy-source `source_filter` ("for each Goblin +/// you control, create a token that's a copy of it") and the "except it has …" +/// riders, and those are words used as words. +/// +/// Full cast pipeline. Revert guard: with `CopyTokenOf` back in the no-op bucket, +/// Goblin is never collected, no option offers Goblin -> Elf and +/// `apply_replacement` panics; the live `source_filter` also stays Goblin. +#[test] +fn creature_type_in_copy_token_source_filter_is_replaced() { + use engine::types::ability::{AbilityDefinition, AbilityKind, QuantityExpr, TargetFilter}; + + let copy = Effect::CopyTokenOf { + target: TargetFilter::SelfRef, + owner: TargetFilter::Controller, + source_filter: Some(creature_subtype_filter("Goblin")), + enters_attacking: false, + tapped: false, + count: QuantityExpr::Fixed { value: 1 }, + extra_keywords: vec![], + additional_modifications: vec![], + }; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .add_creature(P0, "Goblin Duplicator", 2, 2) + .with_ability_definition(AbilityDefinition::new(AbilityKind::Activated, copy)) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Artificial Evolution", true, ARTIFICIAL_EVOLUTION) + .id(); + let mut runner = scenario.build(); + runner.state_mut().all_creature_types = vec!["Goblin".to_string(), "Elf".to_string()]; + + let before = collect_present_words( + &runner.state().objects[&creature], + TextWordCategory::CreatureType, + ); + assert!( + before.contains(&TextWord::CreatureType("Goblin".to_string())), + "the copy-token source filter should contribute Goblin: {before:?}" + ); + + runner.cast(spell).target_object(creature).resolve(); + apply_replacement( + &mut runner, + TextWord::CreatureType("Goblin".to_string()), + TextWord::CreatureType("Elf".to_string()), + ); + + let after = ability_effect_subtypes(&runner.state().objects[&creature], |e, out| { + if let Effect::CopyTokenOf { + source_filter: Some(f), + .. + } = e + { + filter_subtypes(f, out); + } + }); + assert!( + after.iter().any(|s| s == "Elf"), + "the copy-token source filter must now name Elf: {after:?}" + ); + assert!( + !after.iter().any(|s| s == "Goblin"), + "Goblin must be gone from the copy-token source filter: {after:?}" + ); +} From 0d5b04c096d72965ca63799bdca8b63666b606c2 Mon Sep 17 00:00:00 2001 From: real-venus Date: Wed, 22 Jul 2026 06:23:59 -0700 Subject: [PATCH 5/9] fix(engine): complete the text-word choice through finish_with_continuation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `ChooseTextWordReplacement` arm re-implemented the completion boundary inline (`set_priority` + `drain_pending_continuation` + returning the cloned waiting state) instead of calling the shared helper every other resolution choice uses. `finish_with_continuation` additionally runs `resume_pending_continuation_if_priority`, which resumes parked typed resolution frames (BatchDelivery, PerPlayerZoneChoice, CopyToken, MultiDraw, ...) and drains eligible cost-move roots — none of which the inline copy reached. Routing through the shared authority removes the divergence. Everything before the completion boundary is unchanged: index validation, the Layer-3 ReplaceTextWord transient continuous effect keyed to the target, and its duration default. Also classifies two new upstream shapes the walker's exhaustive matches require, both explicit no-ops: `Effect::Cloak.enters_under` (a controller ref, CR 110.2a - no printed word, mirroring Manifest) and `StaticMode::MustAttackAwayFromSource` (nullary combat requirement, CR 508.1d + CR 701.15b). No regression test accompanies this: the behaviour is currently unobservable from the shipped card class. `drain_pending_continuation` already drains every frame kind these ten cards can park (including RepeatFor at effects/mod.rs:773 and merged AbilityContinuation chains), and the frame kinds it cannot reach require ChangeTextWords to execute inside a batch / per-player / replacement body, which no printed card in the class does. Details in the PR discussion. --- crates/engine/src/game/engine_resolution_choices.rs | 13 ++++++++----- crates/engine/src/game/text_substitution.rs | 8 ++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 8982a991b8..df9cfa09e6 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -5754,8 +5754,13 @@ pub(super) fn handle_resolution_choice( } // CR 612.1 + CR 613.1c: the controller picked one (category, from, to) // substitution; install it as a Layer-3 text-changing continuous effect - // keyed to the target, then drain any parked follow-up (Crystal Spray's - // "Draw a card"). + // keyed to the target, then hand off to the shared resolution-choice + // completion boundary. `finish_with_continuation` drains the immediate + // parked follow-up (Crystal Spray's "Draw a card") AND resumes any + // higher-level typed resolution frame still parked beneath this choice + // (`resume_resolution_frames`) plus eligible cost-move roots — a local + // `drain_pending_continuation` would return priority with those frames + // stranded. ( WaitingFor::TextWordReplacement { player, @@ -5783,9 +5788,7 @@ pub(super) fn handle_resolution_choice( ], None, ); - set_priority(state, player); - effects::drain_pending_continuation(state, events); - ResolutionChoiceOutcome::WaitingFor(state.waiting_for.clone()) + ResolutionChoiceOutcome::WaitingFor(finish_with_continuation(state, player, events)) } ( WaitingFor::ChooseRingBearer { player, candidates }, diff --git a/crates/engine/src/game/text_substitution.rs b/crates/engine/src/game/text_substitution.rs index 1691cbf9ef..4c6e244e63 100644 --- a/crates/engine/src/game/text_substitution.rs +++ b/crates/engine/src/game/text_substitution.rs @@ -2209,6 +2209,10 @@ fn walk_static_mode(mode: &mut StaticMode, category: TextWordCategory, cursor: & | StaticMode::CantTap | StaticMode::CantUntap | StaticMode::Goaded + // CR 508.1d + CR 701.15b: nullary combat requirement — the avoided + // player rides `StaticDefinition::source_controller`, so this mode + // prints no color/land/creature-type word for CR 612.2 to reach. + | StaticMode::MustAttackAwayFromSource | StaticMode::CombatAlone { .. } | StaticMode::CantCrew | StaticMode::CantPhaseIn @@ -3268,6 +3272,10 @@ fn walk_effect(effect: &mut Effect, category: TextWordCategory, cursor: &mut Wor target, count, object_source, + // CR 110.2a: `enters_under` is an `Option` — a + // player-role reference, not a printed word, so CR 612.2 has + // nothing to replace in it (mirrors `Effect::Manifest`). + enters_under: _, } => { walk_target_filter(target, category, cursor); walk_quantity_expr(count, category, cursor); From 7da1a6d0ce24e82532df1c6eac176f3a3d10463d Mon Sep 17 00:00:00 2001 From: real-venus Date: Thu, 23 Jul 2026 17:18:00 -0700 Subject: [PATCH 6/9] fix(engine): bind text-word replacement into the interaction subsystem Main added the game/interaction.rs multiplayer subsystem, whose exhaustive WaitingFor / GameAction / ContinuousModification matches did not cover this PR's variants, so the merged tree did not compile. Classifies them: - WaitingFor::TextWordReplacement is a single-selection index choice with engine-computed public options, bound exactly like DamageSourceChoice across human_response_model / classify_waiting_for / selection_projection. - GameAction::ChooseTextWordReplacement carries index: usize, so in project_action_payload it joins the OptionIndex group (ChooseBranch / ChooseReplacement), not the ObjectId-carrying ChooseDamageSource; adds the InteractionActionCode::ChooseTextWordReplacement literal and regenerates the client TS binding. - ContinuousModification::ReplaceTextWord carries no QuantityExpr, so it joins the None group in continuous_modification_dynamic_quantity_mut. check-interaction-bindings.sh and check-parser-combinators.sh pass; workspace clippy -D warnings clean; cargo test -p engine 3914 passed / 0 failed. --- client/src/adapter/generated/interaction/index.ts | 2 +- crates/engine/src/game/interaction.rs | 11 ++++++++++- crates/engine/src/parser/oracle_static/shared.rs | 3 +++ crates/engine/src/types/interaction.rs | 1 + 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/client/src/adapter/generated/interaction/index.ts b/client/src/adapter/generated/interaction/index.ts index d17fd41958..3c552da7e4 100644 --- a/client/src/adapter/generated/interaction/index.ts +++ b/client/src/adapter/generated/interaction/index.ts @@ -41,7 +41,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" | "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" | "debug"; +export type InteractionActionCode = "passPriority" | "chooseMeldPair" | "chooseEntryAttackTarget" | "playLand" | "castSpell" | "foretell" | "activateAbility" | "declareAttackers" | "declareBlockers" | "chooseUntap" | "chooseExert" | "chooseEnlist" | "chooseClashOpponent" | "chooseZoneOpponentChooser" | "choosePileOpponent" | "chooseAnnouncingOpponent" | "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" | "chooseTextWordReplacement" | "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" | "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" | "color" | "player" | "castingVariant" | "mode" | "modeCost" | "castingCost" | "voteOption" | "voteCandidate"; diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index fbc40b4be6..2102b7e783 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -263,6 +263,7 @@ fn human_response_model(waiting_for: &WaitingFor, semantic_owner: PlayerId) -> H | WaitingFor::OpponentGuess { .. } | WaitingFor::SpellbookDraft { .. } | WaitingFor::DamageSourceChoice { .. } + | WaitingFor::TextWordReplacement { .. } | WaitingFor::OptionalCostChoice { .. } | WaitingFor::SpliceOffer { .. } | WaitingFor::DefilerPayment { .. } @@ -490,6 +491,7 @@ fn classify_waiting_for(waiting_for: &WaitingFor) -> WaitingClassification { | WaitingFor::OpponentGuess { .. } | WaitingFor::SpellbookDraft { .. } | WaitingFor::DamageSourceChoice { .. } + | WaitingFor::TextWordReplacement { .. } | WaitingFor::OptionalCostChoice { .. } | WaitingFor::SpliceOffer { .. } | WaitingFor::CastOffer { .. } @@ -3203,6 +3205,7 @@ fn selection_projection( | WaitingFor::OpponentGuess { .. } | WaitingFor::SpellbookDraft { .. } | WaitingFor::DamageSourceChoice { .. } + | WaitingFor::TextWordReplacement { .. } | WaitingFor::ModeChoice { .. } | WaitingFor::OptionalCostChoice { .. } | WaitingFor::SpliceOffer { .. } @@ -3961,7 +3964,10 @@ fn project_action_payload( GameAction::ChooseReplacement { index } | GameAction::ChooseBranch { index } | GameAction::ChooseCastingVariant { index } - | GameAction::ChooseActivationCostBranch { index } => { + | GameAction::ChooseActivationCostBranch { index } + // CR 612.1: the text-word replacement answer is a single index into the + // engine-computed option list — the index is the distinguishing field. + | GameAction::ChooseTextWordReplacement { index } => { push_value_surface(surfaces, InteractionRoleCode::OptionIndex, index) } GameAction::OrderTriggers { order } => { @@ -4583,6 +4589,9 @@ fn action_code(action: &GameAction) -> InteractionActionCode { InteractionActionCode::SubmitLifeRedistribution } GameAction::ChooseDamageSource { .. } => InteractionActionCode::ChooseDamageSource, + GameAction::ChooseTextWordReplacement { .. } => { + InteractionActionCode::ChooseTextWordReplacement + } GameAction::SelectModes { .. } => InteractionActionCode::SelectModes, GameAction::DecideOptionalCost { .. } => InteractionActionCode::DecideOptionalCost, GameAction::ChooseAdventureFace { .. } => InteractionActionCode::ChooseAdventureFace, diff --git a/crates/engine/src/parser/oracle_static/shared.rs b/crates/engine/src/parser/oracle_static/shared.rs index 3796bf5057..28c2c2ba0c 100644 --- a/crates/engine/src/parser/oracle_static/shared.rs +++ b/crates/engine/src/parser/oracle_static/shared.rs @@ -927,6 +927,9 @@ fn continuous_modification_dynamic_quantity_mut( | ContinuousModification::RetainAllOtherAbilitiesFromSource | ContinuousModification::AddSupertype { .. } | ContinuousModification::RemoveSupertype { .. } + // CR 612.1: A text-word replacement latches fixed `from`/`to` words with + // no game-state-derived magnitude, so it carries no `QuantityExpr`. + | ContinuousModification::ReplaceTextWord { .. } | ContinuousModification::RemoveManaCost => None, } } diff --git a/crates/engine/src/types/interaction.rs b/crates/engine/src/types/interaction.rs index 7a49ad382a..6ac0042e49 100644 --- a/crates/engine/src/types/interaction.rs +++ b/crates/engine/src/types/interaction.rs @@ -335,6 +335,7 @@ pub enum InteractionActionCode { ChooseBranch, SubmitLifeRedistribution, ChooseDamageSource, + ChooseTextWordReplacement, SelectModes, DecideOptionalCost, ChooseAdventureFace, From 495e89dc49346a9cc7cde39ef1f369c4d28c65cc Mon Sep 17 00:00:00 2001 From: real-venus Date: Thu, 23 Jul 2026 19:49:09 -0700 Subject: [PATCH 7/9] docs(engine): drop misplaced CR 612.1 from the interaction projection comment Per CodeRabbit review: the comment above the ChooseTextWordReplacement action projection described protocol shaping (the answer is an index into the engine-computed option list), not a rules implementation, so the CR 612.1 citation didn't belong there. --- crates/engine/src/game/interaction.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index 2102b7e783..bffac827e6 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -3965,8 +3965,10 @@ fn project_action_payload( | GameAction::ChooseBranch { index } | GameAction::ChooseCastingVariant { index } | GameAction::ChooseActivationCostBranch { index } - // CR 612.1: the text-word replacement answer is a single index into the - // engine-computed option list — the index is the distinguishing field. + // Protocol projection: the text-word replacement answer is a single index + // into the engine-computed option list — the index is the distinguishing + // field. (Not a rules citation; the CR 612 text-change logic lives in the + // effect resolver, not this surface projection.) | GameAction::ChooseTextWordReplacement { index } => { push_value_surface(surfaces, InteractionRoleCode::OptionIndex, index) } From 97d0adba6a0faed09281798aae4c6c785556ce42 Mon Sep 17 00:00:00 2001 From: real-venus Date: Fri, 24 Jul 2026 01:48:28 -0700 Subject: [PATCH 8/9] fix(engine): classify new upstream ChoosePermanent / CopyChosen in the CR 612 walker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main advanced under the branch again. The walker's exhaustive matches require: - Effect::ChoosePermanent { filter } — the Metamorphic Alteration "choose a creature" filter can name a creature type used as a creature type (CR 612.2), so walk_effect rewrites it; target_filter_mut returns None to stay paired with target_filter (the choice is an as-enters replacement, not a stack target). - ContinuousModification::CopyChosen — a runtime copy of the chosen object's values (CR 707.2), no printed word, no-op. Plus the additive doc.rs merge (both sides added a no-op printed-slot arm). --- crates/engine/src/game/text_substitution.rs | 10 ++++++++++ crates/engine/src/types/ability.rs | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/crates/engine/src/game/text_substitution.rs b/crates/engine/src/game/text_substitution.rs index 4c6e244e63..669d4f7a56 100644 --- a/crates/engine/src/game/text_substitution.rs +++ b/crates/engine/src/game/text_substitution.rs @@ -2530,6 +2530,9 @@ fn walk_continuous_modification( walk_quantity_expr(count, category, cursor) } ContinuousModification::CopyValues { .. } + // CR 707.2: copies the chosen object's values at resolution — no printed + // color/land/creature-type word of its own to text-change. + | ContinuousModification::CopyChosen | ContinuousModification::SetName { .. } // CR 612.2 + CR 612.8: a literal NAME is not a color/land/creature word // used as such — names are structurally excluded from the walk (sibling of @@ -3202,6 +3205,13 @@ fn walk_effect(effect: &mut Effect, category: TextWordCategory, cursor: &mut Wor walk_target_filter(f, category, cursor); } } + // CR 612.2 + CR 614.12a: Metamorphic Alteration's "choose a creature" + // filter can name a creature type used as a creature type, so a + // text-changing effect must reach it. `target_filter_mut` returns None + // for this resolution-time choice, so it is walked explicitly here. + Effect::ChoosePermanent { filter } => { + walk_target_filter(filter, category, cursor); + } Effect::ChooseObjectsIntoTrackedSet { chooser, filter, .. } => { diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index c4d06a65e6..58e52bdb6f 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -15021,6 +15021,10 @@ impl Effect { // target) picked at resolution time via // `WaitingFor::ReturnAsAuraTarget`. No stack-push target slot. | Effect::ReturnAsAura { .. } + // CR 614.12a + CR 707.2c: Metamorphic Alteration's "choose a creature" + // is an as-enters replacement choice (WaitingFor::CopyTargetChoice), + // not a stack-declared target — paired with target_filter(). + | Effect::ChoosePermanent { .. } | Effect::ChooseFromZone { .. } | Effect::ForEachCategory { .. } | Effect::ChooseAndSacrificeRest { .. } From 36cb9fc7c1bd5dc32b45e3d3cff09ecba8dd52c9 Mon Sep 17 00:00:00 2001 From: real-venus Date: Fri, 24 Jul 2026 02:29:07 -0700 Subject: [PATCH 9/9] fix(engine): classify upstream ContinuousModification::GrantReplacement (carrier) New upstream variant. A granted replacement effect (CR 613.1f layer-6 ability-adding) carries the same word-bearing condition/event/replaced-effect filters as any other ReplacementDefinition (e.g. a subtype-scoped check-land condition), so the CR 612 walker routes it through walk_replacement_definition, mirroring GrantStaticAbility. Also merges latest main. --- crates/engine/src/game/text_substitution.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/engine/src/game/text_substitution.rs b/crates/engine/src/game/text_substitution.rs index 669d4f7a56..b0047bc3b1 100644 --- a/crates/engine/src/game/text_substitution.rs +++ b/crates/engine/src/game/text_substitution.rs @@ -2501,6 +2501,12 @@ fn walk_continuous_modification( ContinuousModification::GrantStaticAbility { definition } => { walk_static_definition(definition, category, cursor) } + // CR 613.1f + CR 612.1: a granted replacement effect carries the same + // word-bearing condition / event / replaced-effect filters as any other + // replacement definition (e.g. a subtype-scoped check-land condition). + ContinuousModification::GrantReplacement { replacement } => { + walk_replacement_definition(replacement, category, cursor) + } // CR 613.1f + CR 612.1: a granted rule-modification static `mode` carries // the same word-bearing evasion / protection / cost-filter / color params as // any static's mode (e.g. a granted "can't be blocked by Goblins").