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/client/src/adapter/types.ts b/client/src/adapter/types.ts index 84947991fd..831e7ea2f1 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1602,6 +1602,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[] } } @@ -1664,6 +1673,8 @@ export type WaitingFor = | { type: "OpponentGuess"; data: { player: PlayerId; options: string[]; choice_type: string | Record; source: { prompt: { identity: unknown; controller: PlayerId; display_name: string } }; 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[] } } @@ -2181,6 +2192,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 ec24f65838..3328342e7d 100644 --- a/client/src/components/modal/CardChoiceModal.tsx +++ b/client/src/components/modal/CardChoiceModal.tsx @@ -109,6 +109,7 @@ type RevealUntilKeptChoice = Extract< type RepeatDecision = Extract; type ManifestDreadChoice = Extract; type DamageSourceChoice = Extract; +type TextWordReplacement = Extract; type LearnChoice = Extract; type BeholdChoice = Extract; @@ -196,6 +197,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 ; @@ -2892,6 +2896,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 d6831ace7e..508995cd4d 100644 --- a/client/src/game/waitingForRegistry.ts +++ b/client/src/game/waitingForRegistry.ts @@ -151,6 +151,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 11ef561748..1486b8285a 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -1155,6 +1155,10 @@ } }, "cardChoice": { + "textWordReplacement": { + "title": "Text ändern", + "subtitle": "Wähle ein zu ersetzendes Wort" + }, "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 d60fdd2c7c..1ac662edc2 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -1585,6 +1585,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 1dd4be2ffe..d8ed47529a 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -1155,6 +1155,10 @@ } }, "cardChoice": { + "textWordReplacement": { + "title": "Cambiar texto", + "subtitle": "Elige una palabra que reemplazar" + }, "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 bf67c443fa..22e2e6f541 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -1155,6 +1155,10 @@ } }, "cardChoice": { + "textWordReplacement": { + "title": "Changer le texte", + "subtitle": "Choisissez un mot à remplacer" + }, "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 d20cb4f87d..9b6bcca932 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -1155,6 +1155,10 @@ } }, "cardChoice": { + "textWordReplacement": { + "title": "Cambia testo", + "subtitle": "Scegli una parola da sostituire" + }, "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 fb62f4f14b..c77a04947c 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -1155,6 +1155,10 @@ } }, "cardChoice": { + "textWordReplacement": { + "title": "Zmień tekst", + "subtitle": "Wybierz słowo do zamiany" + }, "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 92f350e662..fad9ece652 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -1155,6 +1155,10 @@ } }, "cardChoice": { + "textWordReplacement": { + "title": "Mudar texto", + "subtitle": "Escolha uma palavra para substituir" + }, "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 4b7480dfa7..6fe1396f01 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -1570,6 +1570,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 f10ee30982..7ec6dbb734 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 7d77223685..b09f11a869 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -2757,6 +2757,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::RetainAllOtherAbilitiesFromSource @@ -3386,6 +3389,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, } } @@ -5164,7 +5169,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 d30fdc0699..a7c90cea0e 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -404,6 +404,9 @@ fn scan_effect(x: &Effect, mode: ScanMode) -> Axes { // player/object references, not board censuses) so a vanilla token stays read-free. let target_ctx = effect_target_ctx(x, mode); 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, mode)); @@ -5063,6 +5066,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 { .. } @@ -5434,6 +5440,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, } } @@ -5786,6 +5796,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), } } @@ -6096,6 +6109,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, } } @@ -6354,6 +6369,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, } } @@ -8077,6 +8094,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 5b18a8693b..e1e89c4e5e 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -2207,6 +2207,36 @@ 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, + // 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 { + 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))); } @@ -4269,6 +4299,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 c4365c7d73..750c4d4508 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -218,6 +218,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; @@ -2441,6 +2442,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 { .. } @@ -3826,6 +3831,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..06e3fd6249 --- /dev/null +++ b/crates/engine/src/game/effects/text_change.rs @@ -0,0 +1,136 @@ +//! 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 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. + 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: duration.clone(), + }; + 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 6dde5e8c74..80749cf3de 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -697,6 +697,7 @@ pub(super) fn handles(waiting_for: &WaitingFor) -> bool { | WaitingFor::OpponentGuess { .. } | WaitingFor::SpellbookDraft { .. } | WaitingFor::DamageSourceChoice { .. } + | WaitingFor::TextWordReplacement { .. } | WaitingFor::ChooseRingBearer { .. } | WaitingFor::ChooseRoomDoor { .. } | WaitingFor::ChooseDungeon { .. } @@ -5804,6 +5805,44 @@ 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 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, + 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, + ); + ResolutionChoiceOutcome::WaitingFor(finish_with_continuation(state, player, events)) + } ( WaitingFor::ChooseRingBearer { player, candidates }, GameAction::ChooseRingBearer { target }, diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index fbc40b4be6..bffac827e6 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,12 @@ fn project_action_payload( GameAction::ChooseReplacement { index } | GameAction::ChooseBranch { index } | GameAction::ChooseCastingVariant { index } - | GameAction::ChooseActivationCostBranch { index } => { + | GameAction::ChooseActivationCostBranch { index } + // 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) } GameAction::OrderTriggers { order } => { @@ -4583,6 +4591,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/game/layers.rs b/crates/engine/src/game/layers.rs index b23496db00..7556b07dbd 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -6158,6 +6158,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 21a3f2d4d0..d014496e21 100644 --- a/crates/engine/src/game/mod.rs +++ b/crates/engine/src/game/mod.rs @@ -160,6 +160,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 fec4396418..b7bb091507 100644 --- a/crates/engine/src/game/printed_cards.rs +++ b/crates/engine/src/game/printed_cards.rs @@ -910,6 +910,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 b894174969..30e68ee63f 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -780,6 +780,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::RetainAllOtherAbilitiesFromSource diff --git a/crates/engine/src/game/scenario.rs b/crates/engine/src/game/scenario.rs index be8414ab0b..26cecc7b52 100644 --- a/crates/engine/src/game/scenario.rs +++ b/crates/engine/src/game/scenario.rs @@ -1632,6 +1632,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..b0047bc3b1 --- /dev/null +++ b/crates/engine/src/game/text_substitution.rs @@ -0,0 +1,3612 @@ +//! 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. +//! +//! 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. + +use std::collections::BTreeSet; +use std::str::FromStr; +use std::sync::Arc; + +use crate::game::game_object::GameObject; +use crate::types::ability::{ + 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> { + /// 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.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 +/// 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). +/// +/// 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`); +/// - 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` / +/// 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 `EpicCopy` resolved-spell snapshot, the specialized non-listed sub-enums +/// (`DamageTargetFilter`/`DamageRedirectTarget`, `GuessSubject`, +/// `PerpetualModification`, `IntensityScope`, `ForEachCategoryAction`, +/// 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 +/// `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, + 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. 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(&mut trigger.definition, 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); + } + } + // 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) { + 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), + // 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 + | 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::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::Offspring(..) + | Keyword::Impending { .. } + | Keyword::LevelUp(..) + | Keyword::CumulativeUpkeep(..) + | Keyword::Banding + | 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::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::Bargain + | Keyword::Sunburst + | 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::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); + } + } + // 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) + } + // 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 + | TargetFilter::StackAbility { .. } + | TargetFilter::StackSpell + | TargetFilter::SpecificObject { .. } + // NB: `ChosenDamageSource` / `TrackedSetFiltered` are carriers above. + | TargetFilter::SpecificPlayer { .. } + | TargetFilter::PlayerWhoChoseLabel { .. } + | TargetFilter::Neighbor { .. } + | TargetFilter::ScopedPlayer + | TargetFilter::AttachedTo + | TargetFilter::LastCreated + | TargetFilter::LastRevealed + | TargetFilter::CostPaidObject + | TargetFilter::ChosenCard + | TargetFilter::TrackedSet { .. } + | 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::PostReplacementDamageSource + | TargetFilter::PostReplacementDamageTarget + | TargetFilter::PostReplacementDamageTargetOwner + | TargetFilter::DefendingPlayer + | TargetFilter::HasChosenName + | 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 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::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 + // 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 { .. } + | FilterProp::Modified + | FilterProp::Historic + | 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 { .. } + | FilterProp::AttackedThisTurn { .. } + | FilterProp::BlockedThisTurn + | FilterProp::AttackedOrBlockedThisTurn + | FilterProp::CountersPutOnThisTurn { .. } + | FilterProp::FaceDown + | FilterProp::Transformed + | 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) + } + // 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 { .. } + | 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); + } + } + } +} + +/// 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. + 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); + } + } + // 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). + 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::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::CardsExiledBySource + | QuantityRef::ExiledCardPower { .. } + | QuantityRef::BasicLandTypeCount { .. } + | QuantityRef::TrackedSetSize + | QuantityRef::ExiledFromHandThisResolution + | QuantityRef::PreviousEffectAmount { .. } + | QuantityRef::LifeLostThisTurn { .. } + | QuantityRef::PartySize { .. } + | QuantityRef::Speed { .. } + | 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 { .. } + | 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 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. +fn walk_object_property( + property: &mut ObjectProperty, + _category: TextWordCategory, + _cursor: &mut WordCursor, +) { + match property { + ObjectProperty::Power + | ObjectProperty::Toughness + | ObjectProperty::ManaValue + | ObjectProperty::ManaSymbolCount(..) => {} + } +} + +/// 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 } + // 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 } + | 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); + } + 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); + } + // 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( + trigger: &mut TriggerDefinition, + category: TextWordCategory, + cursor: &mut WordCursor, +) { + 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); + } + if let Some(valid_target) = &mut trigger.valid_target { + walk_target_filter(valid_target, category, cursor); + } + if let Some(valid_source) = &mut trigger.valid_source { + walk_target_filter(valid_source, 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); + } +} + +/// 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, + } + | StaticMode::CantBeBlockedBy { + filter: source_filter, + } + | StaticMode::BlockRestriction { + filter: source_filter, + } + | 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 { .. } + // 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 + | 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 + // 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 + | 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 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 +/// `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. +/// +/// 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); +/// `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 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`]. + 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 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"). + ContinuousModification::AddStaticMode { mode } => { + walk_static_mode(mode, 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) + } + // 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 { .. } + // 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 + // `SetName` / `SetChosenName`). + | ContinuousModification::SetTextName { .. } + | 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::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 { .. } + // 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 { .. } + | 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.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. +/// +/// 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) + } + // 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 { + 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) + } + // 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); + } + } + // 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, + 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); + } + } + // 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, + 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); + } + } + // 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 { + 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 + // 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); + } + for trigger in triggers.iter_mut() { + walk_trigger_definition(trigger, category, cursor); + } + } + // ================= 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); + } + // 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, + } => { + 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); + } + } + // 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, .. + } => { + 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 ---- + // 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, + 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); + 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); + } + // 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, .. + } => { + 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, + face_down_profile, + .. + } => { + 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); + } + // 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) ---- + 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 + // 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 { .. } + | Effect::Regenerate { .. } + | Effect::RemoveAllDamage { .. } + | Effect::Counter { .. } + | Effect::CounterAll { .. } + | Effect::SetTapState { .. } + | Effect::DiscardCard { .. } + | Effect::DestroyAll { .. } + | Effect::GainControl { .. } + | Effect::GainControlAll { .. } + | Effect::ControlNextTurn { .. } + | Effect::Bounce { .. } + | Effect::Explore + | Effect::ExploreAll { .. } + | Effect::Investigate + | Effect::Tribute { .. } + | Effect::TimeTravel + | Effect::BecomeMonarch + | Effect::NoOp + | Effect::Proliferate + | Effect::ProliferateTarget { .. } + | Effect::Populate + | Effect::Clash + | Effect::EndTheTurn + | Effect::EndCombatPhase + | Effect::SwitchPT { .. } + | Effect::EpicCopy { .. } + | Effect::Myriad + | Effect::Encore + | Effect::CombineHost { .. } + | Effect::ExileHaunting { .. } + | Effect::HideawayConceal { .. } + | Effect::ChooseCard { .. } + | Effect::ChooseCounterKind { .. } + | Effect::MultiplyCounter { .. } + | Effect::DoublePT { .. } + | Effect::DoublePTAll { .. } + | Effect::RegisterBending { .. } + | Effect::Cleanup { .. } + | Effect::Mana { .. } + | Effect::Shuffle { .. } + | Effect::Transform { .. } + | Effect::Reveal { .. } + | Effect::RevealTop { .. } + | Effect::TargetOnly { .. } + | Effect::Choose { .. } + | Effect::OpponentGuess { .. } + | Effect::SwapChosenLabels { .. } + | Effect::Suspect { .. } + | Effect::Unsuspect { .. } + | Effect::PhaseOut { .. } + | Effect::PhaseIn { .. } + | Effect::ForceBlock { .. } + | Effect::SolveCase + | Effect::BecomePrepared { .. } + | Effect::BecomeUnprepared { .. } + | Effect::BecomeSaddled { .. } + | Effect::SetClassLevel { .. } + | Effect::AddRestriction { .. } + | 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::AssembleContraptionsFromRollDifference + | Effect::CrankContraptions { .. } + | Effect::ReassembleContraption { .. } + | Effect::ReassembleContraptionOnSprocket { .. } + | Effect::ApplySticker { .. } + | Effect::ProcessRadCounters + | Effect::GrantCastingPermission { .. } + | Effect::RememberCard { .. } + | Effect::ForEachCategory { .. } + | Effect::LoseAllPlayerCounters { .. } + | Effect::Exploit { .. } + | Effect::Heist { .. } + | Effect::HeistExile + | Effect::Cascade + | Effect::Ripple { .. } + | Effect::MiracleCast { .. } + | Effect::MadnessCast { .. } + | Effect::PutOnTopOrBottom { .. } + | Effect::GiftDelivery { .. } + | Effect::Goad { .. } + | Effect::GoadAll { .. } + | Effect::Detain { .. } + | Effect::SetRoomDoorLock { .. } + | Effect::ManifestDread + | Effect::ExtraTurn { .. } + | Effect::Double { .. } + | Effect::RuntimeHandled { .. } + | Effect::Specialize + | Effect::Learn + | Effect::Forage + | Effect::Harness + | Effect::CollectEvidence { .. } + | Effect::BlightEffect { .. } + | Effect::ExchangeLifeWithStat { .. } + | Effect::SetDayNight { .. } + | Effect::RemoveFromCombat { .. } + | Effect::BecomeBlocked { .. } + | Effect::ApplyPerpetual { .. } + | Effect::DraftFromSpellbook { .. } + | Effect::Unimplemented { .. } => {} + } +} diff --git a/crates/engine/src/game/trigger_index.rs b/crates/engine/src/game/trigger_index.rs index 3f814a117e..fea359bd8d 100644 --- a/crates/engine/src/game/trigger_index.rs +++ b/crates/engine/src/game/trigger_index.rs @@ -698,6 +698,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 8d90c03ce1..7420161b53 100644 --- a/crates/engine/src/parser/clause_shell.rs +++ b/crates/engine/src/parser/clause_shell.rs @@ -438,6 +438,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 6495933c8d..c3948cfbcd 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -9067,6 +9067,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::RetainAllOtherAbilitiesFromSource @@ -9170,6 +9172,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::RetainAllOtherAbilitiesFromSource diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 5274dc5fc0..5fbf268320 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -109,9 +109,9 @@ use crate::types::ability::{ ReplacementDefinition, RestrictionExpiry, RestrictionPlayerScope, RevealUntilDisposition, RoundingMode, SharedQuality, SharedQualityRelation, SkipScope, SpellStackToGraveyardReplacement, StaticCondition, StaticDefinition, StepSkipTarget, - SubAbilityLink, TapStateChange, TargetFilter, TargetSelectionMode, ThisWayCause, - TrackedAnaphorSource, TriggerCondition, TriggerDefinition, TypeFilter, TypedFilter, - UnlessPayModifier, UntilCondition, ZoneOwner, + SubAbilityLink, TapStateChange, TargetFilter, TargetSelectionMode, TextWordCategory, + ThisWayCause, TrackedAnaphorSource, TriggerCondition, TriggerDefinition, TypeFilter, + TypedFilter, UnlessPayModifier, UntilCondition, ZoneOwner, }; #[cfg(test)] use crate::types::ability::{AttackScope, AttackSubject}; @@ -8337,6 +8337,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; + } + // CR 614.1a + CR 514.2: floating turn-bound life-floor replacement // ("Until end of turn, damage that would reduce your life total to less // than 1 reduces it to 1 instead." — Angel's Grace; Angel of Grace's ETB @@ -10178,6 +10184,80 @@ 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 ". `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()); + + // 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 06bd63584c..3eab71f158 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; @@ -4896,6 +4897,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); + } + } + } } } @@ -5106,6 +5120,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, } } @@ -5981,7 +5999,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 { .. } @@ -7213,10 +7232,83 @@ 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 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. +/// +/// 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(( + 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 (word, _) = tag::<_, _, OracleError<'_>>(" can't be ") + .parse(rest) + .ok()?; + match category { + TextWordCategory::ColorWord => { + let (tail, color) = nom_primitives::parse_color(word).ok()?; + is_terminal_rider_tail(tail).then_some(TextWord::Color(color)) + } + TextWordCategory::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, consumed) = crate::parser::oracle_util::parse_subtype(word)?; + is_terminal_rider_tail(&word[consumed..]).then_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 6c6c17a226..065cfc9936 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 1b2a94638e..b979c18338 100644 --- a/crates/engine/src/parser/oracle_ir/doc.rs +++ b/crates/engine/src/parser/oracle_ir/doc.rs @@ -1414,6 +1414,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 { .. } => {} // CR 707.2c (Metamorphic Alteration): no nested printed-slot carrier — // the copy is materialized from the chosen donor at resolution. Effect::ChoosePermanent { .. } => {} diff --git a/crates/engine/src/parser/oracle_static/shared.rs b/crates/engine/src/parser/oracle_static/shared.rs index c1344175a0..4d306c419a 100644 --- a/crates/engine/src/parser/oracle_static/shared.rs +++ b/crates/engine/src/parser/oracle_static/shared.rs @@ -931,6 +931,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/ability.rs b/crates/engine/src/types/ability.rs index 4b3baaeba0..3ba567cd94 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -1005,7 +1005,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, @@ -1063,6 +1063,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 { @@ -9812,6 +9861,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, @@ -14094,7 +14159,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, .. } @@ -14605,129 +14671,655 @@ 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 { .. } + // 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 { .. } + // 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 { .. } + | 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::ArrangePlanarDeckTop { .. } + | 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 { .. } @@ -14876,6 +15468,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, .. } @@ -15129,6 +15722,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", @@ -15379,6 +15973,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, @@ -15628,6 +16223,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, @@ -20995,6 +21591,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/action_stable_order.rs b/crates/engine/src/types/action_stable_order.rs index df56558558..c311643325 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/src/types/actions.rs b/crates/engine/src/types/actions.rs index cac3709594..4582abb60b 100644 --- a/crates/engine/src/types/actions.rs +++ b/crates/engine/src/types/actions.rs @@ -142,6 +142,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, @@ -1540,6 +1546,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 0370508cc8..e3772c1036 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -15,7 +15,8 @@ use super::ability::{ FaceDownProfile, GameRestriction, KeywordAction, KickerVariant, LibraryPosition, ModalChoice, PermanentEntryMode, PileSource, QuantityExpr, ResolvedAbility, SearchDestinationSplit, SearchSelectionConstraint, StaticCondition, TapCreaturesAggregate, TargetFilter, TargetRef, - ThisWayCause, TriggerCondition, TriggerDefinition, TriggerDefinitionRef, TriggerEntry, + TextWord, TextWordCategory, ThisWayCause, TriggerCondition, TriggerDefinition, + TriggerDefinitionRef, TriggerEntry, }; use super::attribution::ObjectAttribution; use super::card::{CardFace, PrintedCardRef, TokenImageRef}; @@ -9253,6 +9254,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 @@ -9446,6 +9478,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", @@ -9715,7 +9748,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/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, diff --git a/crates/engine/src/types/layers.rs b/crates/engine/src/types/layers.rs index cad002a5d7..9f5e2e0666 100644 --- a/crates/engine/src/types/layers.rs +++ b/crates/engine/src/types/layers.rs @@ -93,6 +93,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 { .. } @@ -288,6 +290,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::SetTextName { name: "Legitimate Businessperson".to_string(), diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 76d1df3b7a..3f2feebbf7 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -826,6 +826,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 teysa_wojek_investigate_per_opponent; mod the_chain_veil_loyalty_grants; mod the_fourteenth_doctor_graveyard_copy; 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..316e1eb6c5 --- /dev/null +++ b/crates/engine/tests/integration/text_changing_effects.rs @@ -0,0 +1,2791 @@ +//! 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::combat::AttackTarget; +use engine::game::layers::{flush_layers, prune_end_of_turn_effects}; +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, AbilityKind, BasicLandType, ContinuousModification, Duration, Effect, + StaticCondition, StaticDefinition, TargetFilter, TextWord, TextWordCategory, TriggerCondition, + TypeFilter, TypedFilter, +}; +use engine::types::actions::GameAction; +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."; + +/// 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:?}" + ); +} + +/// 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 creature_type_in_offering_keyword_is_replaced() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario + .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, "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::CreatureType("Fox".to_string()), + TextWord::CreatureType("Elf".to_string()), + ); + + let obj = &runner.state().objects[&creature]; + assert!( + obj.keywords.contains(&Keyword::Offering("Elf".to_string())), + "Offering should now name Elf: {:?}", + obj.keywords + ); + assert!( + !obj.keywords.contains(&Keyword::Offering("Fox".to_string())), + "Offering Fox must be gone: {:?}", + obj.keywords + ); +} + +/// 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_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, "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!["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("Goblin".to_string()), + TextWord::CreatureType("Elf".to_string()), + ); + + // 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!( + subs.iter().any(|s| s == "Elf"), + "granted mode filter should now name Elf: {subs:?}" + ); + assert!( + !subs.iter().any(|s| s == "Goblin"), + "granted mode filter must no longer name Goblin: {subs:?}" + ); +} + +/// 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 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, "Replacement Installer", 3, 3) + .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(), "Wall".to_string()]; + + let before = collect_present_words( + &runner.state().objects[&creature], + TextWordCategory::CreatureType, + ); + 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::CreatureType("Goblin".to_string()), + TextWord::CreatureType("Elf".to_string()), + ); + + 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!( + subs.iter().any(|s| s == "Elf"), + "installed replacement valid_card should now name Elf: {subs:?}" + ); + assert!( + !subs.iter().any(|s| s == "Goblin"), + "installed replacement valid_card must no longer name Goblin: {subs:?}" + ); +} + +/// 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 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, "Chosen Color Gate", 3, 3) + .with_static_definition(static_def) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Sleight of Mind", true, SLEIGHT_OF_MIND) + .id(); + let mut runner = scenario.build(); + + 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 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"); +} + +/// 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 + } + + 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()]; + + // 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:?}" + ); + + // 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!( + before.contains(&TextWord::CreatureType("Goblin".to_string())), + "the activation cost's sacrifice filter should carry the Goblin word: {before:?}" + ); + + runner.cast(spell).target_object(chirurgeon).resolve(); + apply_replacement( + &mut runner, + TextWord::CreatureType("Goblin".to_string()), + TextWord::CreatureType("Elf".to_string()), + ); + + // 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:?}" + ); +} + +/// 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:?}" + ); + + // 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!( + before.contains(&TextWord::CreatureType("Goblin".to_string())), + "the condition's filter should carry the Goblin word: {before:?}" + ); + + runner.cast(spell).target_object(figure).resolve(); + apply_replacement( + &mut runner, + TextWord::CreatureType("Goblin".to_string()), + TextWord::CreatureType("Elf".to_string()), + ); + + // 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:?}" + ); +} + +/// 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.definition().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!( + runner.life(P0), + life_before + 1, + "the intervening-if trigger must fire and gain 1 life once it reads 'control an Elf'" + ); +} + +/// 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 ability_index = runner.state().objects[&chirurgeon] + .abilities + .iter() + .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!( + 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.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"); + 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 +/// 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.definition().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:?}" + ); +} + +// --------------------------------------------------------------------------- +// 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:?}" + ); +} diff --git a/crates/manabrew-compat/src/lib.rs b/crates/manabrew-compat/src/lib.rs index efa4b03f7d..937a6da010 100644 --- a/crates/manabrew-compat/src/lib.rs +++ b/crates/manabrew-compat/src/lib.rs @@ -1778,7 +1778,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 cb05977d10..eb960b4cfd 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 58f749c627..df3ee5dae7 100644 --- a/crates/phase-ai/src/policies/effect_classify.rs +++ b/crates/phase-ai/src/policies/effect_classify.rs @@ -191,7 +191,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 edc9fd2d57..d38cb9aa5a 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 f0326de2d9..2e74966416 100644 --- a/crates/phase-ai/src/policies/x_reference.rs +++ b/crates/phase-ai/src/policies/x_reference.rs @@ -229,6 +229,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 0c78ee45ed..3374df1d5b 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -1106,6 +1106,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 52dd6b571f..fbcff5062b 100644 --- a/crates/server-core/src/game_action_payload_guard.rs +++ b/crates/server-core/src/game_action_payload_guard.rs @@ -663,6 +663,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 { .. }