Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b36db57
feat(engine): implement CR 612 text-changing effects (word replacement)
real-venus Jul 15, 2026
6a437f1
Merge branch 'main' into feat/text-changing-effects
matthewevans Jul 15, 2026
663739d
fix(engine): complete CR 612 text-change word substitution across all…
real-venus Jul 16, 2026
7508773
Merge branch 'main' into feat/text-changing-effects
real-venus Jul 17, 2026
fae3f2c
Merge branch 'main' into feat/text-changing-effects
real-venus Jul 21, 2026
97b4929
fix(engine): classify new upstream variants in the CR 612 text-change…
real-venus Jul 21, 2026
b83bb2f
Merge remote feat/text-changing-effects (main update) into local
real-venus Jul 21, 2026
53b5662
Merge remote-tracking branch 'upstream/main' into feat/text-changing-…
real-venus Jul 22, 2026
7cf1a37
fix(engine): text-change token creation specs (CR 612.2a)
real-venus Jul 22, 2026
a0e3c4c
Merge remote-tracking branch 'upstream/main' into feat/text-changing-…
real-venus Jul 22, 2026
0d5b04c
fix(engine): complete the text-word choice through finish_with_contin…
real-venus Jul 22, 2026
7682b5f
Merge branch 'main' into feat/text-changing-effects
real-venus Jul 23, 2026
7da1a6d
fix(engine): bind text-word replacement into the interaction subsystem
real-venus Jul 24, 2026
495e89d
docs(engine): drop misplaced CR 612.1 from the interaction projection…
real-venus Jul 24, 2026
6e0d9df
Merge branch 'main' into feat/text-changing-effects
real-venus Jul 24, 2026
97d0adb
fix(engine): classify new upstream ChoosePermanent / CopyChosen in th…
real-venus Jul 24, 2026
bd73745
Merge branch 'main' into feat/text-changing-effects
real-venus Jul 24, 2026
36cb9fc
fix(engine): classify upstream ContinuousModification::GrantReplaceme…
real-venus Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/src/adapter/generated/interaction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
13 changes: 13 additions & 0 deletions client/src/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] } }
Expand Down Expand Up @@ -1664,6 +1673,8 @@ export type WaitingFor =
| { type: "OpponentGuess"; data: { player: PlayerId; options: string[]; choice_type: string | Record<string, unknown>; 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[] } }
Expand Down Expand Up @@ -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 } }
Expand Down
46 changes: 46 additions & 0 deletions client/src/components/modal/CardChoiceModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ type RevealUntilKeptChoice = Extract<
type RepeatDecision = Extract<WaitingFor, { type: "RepeatDecision" }>;
type ManifestDreadChoice = Extract<WaitingFor, { type: "ManifestDreadChoice" }>;
type DamageSourceChoice = Extract<WaitingFor, { type: "DamageSourceChoice" }>;
type TextWordReplacement = Extract<WaitingFor, { type: "TextWordReplacement" }>;
type LearnChoice = Extract<WaitingFor, { type: "LearnChoice" }>;
type BeholdChoice = Extract<WaitingFor, { type: "BeholdChoice" }>;

Expand Down Expand Up @@ -196,6 +197,9 @@ export function CardChoiceModal() {
case "DamageSourceChoice":
if (!canActForWaitingState) return null;
return <DamageSourceModal data={waitingFor.data} />;
case "TextWordReplacement":
if (!canActForWaitingState) return null;
return <TextWordReplacementModal data={waitingFor.data} />;
case "VoteChoice":
if (!canActForWaitingState) return null;
return <VoteChoiceModal data={waitingFor.data} />;
Expand Down Expand Up @@ -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 (
<ChoiceOverlay
title={t("cardChoice.textWordReplacement.title")}
subtitle={t("cardChoice.textWordReplacement.subtitle")}
>
<div className="flex flex-wrap justify-center gap-2">
{data.options.map((option, index) => (
<motion.button
key={index}
className="min-h-11 rounded-lg border border-white/20 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/20"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 + index * 0.03, duration: 0.25 }}
whileHover={{ scale: 1.05 }}
onClick={() =>
dispatch({
type: "ChooseTextWordReplacement",
data: { index },
})
}
>
{option.label}
</motion.button>
))}
</div>
</ChoiceOverlay>
);
}

// ── Manifest Dread Modal ─────────────────────────────────────────────────

function ManifestDreadModal({ data }: { data: ManifestDreadChoice["data"] }) {
Expand Down
1 change: 1 addition & 0 deletions client/src/game/waitingForRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export const HANDLED_WAITING_FOR_TYPES: ReadonlySet<WaitingFor["type"]> =
"RetargetChoice",
"CopyRetarget",
"DamageSourceChoice",
"TextWordReplacement",
"DiscardToHandSize",
"MiracleReveal",
"TributeChoice",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/de/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -1155,6 +1155,10 @@
}
},
"cardChoice": {
"textWordReplacement": {
"title": "Text ändern",
"subtitle": "Wähle ein zu ersetzendes Wort"
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"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.",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/en/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/es/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/fr/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/it/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/pl/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/pt/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
13 changes: 13 additions & 0 deletions crates/engine/src/ai_support/candidates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion crates/engine/src/analysis/ability_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 { .. }
Expand Down
10 changes: 9 additions & 1 deletion crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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 { .. }
Expand Down
Loading
Loading