Skip to content

feat(engine): implement CR 612 text-changing effects (word replacement)#5866

Open
real-venus wants to merge 1 commit into
phase-rs:mainfrom
real-venus:feat/text-changing-effects
Open

feat(engine): implement CR 612 text-changing effects (word replacement)#5866
real-venus wants to merge 1 commit into
phase-rs:mainfrom
real-venus:feat/text-changing-effects

Conversation

@real-venus

Copy link
Copy Markdown
Contributor

Implement CR 612 text-changing effects (word replacement)

Summary

Adds a reusable, rules-correct primitive for text-changing effects (CR 612) — the
"replace all instances of one color word / basic land type / creature type with
another" family. The effect is applied as a Layer-3 continuous modification that
mirrors the engine's existing SetChosenName name-change, extending the same layer path
rather than introducing a parallel mechanism.

This is a class implementation, not a per-card one: a single interactive effect plus a
single substitution walker cover every card in the family, and the walker classifies
every word-bearing enum leaf in the engine, so future printings in this class are handled
with no additional parser work.

Motivation

Word-replacement text-changers (Mind Bend, Sleight of Mind, Glamerdye, …) were unsupported —
the parser dropped the clause and the cards fell through to unimplemented. Name-changing
(SetChosenName) already existed at Layer 3, but word replacement (CR 612.1/612.2) had no
representation.

What's implemented

  • Effect::ChangeTextWords — interactive effect. At resolution the controller chooses a
    category (from the card's allowed set), a from word present on the target (CR 612.2), and
    a to word of that category. Drives a WaitingFor / GameAction round-trip.
  • ContinuousModification::ReplaceTextWord { category, from, to } — the Layer-3
    modification. Operands are latched at resolution (never re-read from a source), keyed to
    the target via TargetFilter::SpecificObject.
  • game/text_substitution.rs — the substitution walker. Exhaustive, _-free matches over
    every word-bearing enum (keywords, protection/hexproof, target filters, devotion, type lines,
    landwalk, and ability effect target filters), so a future word-bearing variant fails to
    compile until it's explicitly classified as carrier or no-op.
  • Effect::target_filter_mut — a mutable mirror of the existing target_filter(), so the
    walker reaches words inside an ability's effect target filter (e.g. {T}: Destroy target red creature).
  • Interactive plumbing: AI legal-action enumeration, multiplayer routing, and a render-only
    choice UI (all option labels are engine-computed).

CR compliance

Every rule-bearing arm is annotated and verified against the Comprehensive Rules:

  • CR 612.1 / 612.2 / 613.1c — text-changing effect, "used in the correct way," Layer-3
    application.
  • CR 612.2 exclusions are structural — the walker never descends into name / base_name
    (a card named "Whitemane Lion" is untouched by a white→blue change), the Layer-5 color
    characteristic, or mana cost / pips. Mana symbols ({R}), color-set-size predicates, and
    chosen-attribute refs are explicit no-ops; the color word "red" is a carrier, the {R} pip
    is not.
  • Duration (until end of turn vs. indefinite) reuses the existing continuous-effect machinery;
    multi-authority composition follows CR 613.7 timestamp order.

Scope

In scope (10 cards): Mind Bend, Sleight of Mind, Glamerdye, Alter Reality, Magical Hack,
Artificial Evolution (with the "The new creature type can't be Wall" excluded_to rider),
Crystal Spray, Trait Doctoring, Whim of Volrath, Spectral Shift (modal).

Deliberately out of scope (left unimplemented, coverage stays honestly red): New Blood
(gain-control + fixed target word), Balduvian Shaman (constrained target + granted upkeep),
Deceptive Divination (mass fixed replacement + chaos), Magical Hacker (+/- symbol swap —
different category), Overload / March of Progress (Overload keyword), and text-changes on an
instant/sorcery during its own resolution (the layer system doesn't process stack objects).

One documented walker boundary: words inside mass-population effect filters (DestroyAll
"all red creatures") are not reached — a benign no-op, never a wrong substitution. The correct
future fix is a separate effect-filter enumerator, not a divergence of the paired
target_filter accessors.

Testing

12 discriminating runtime tests, each driving the real parse → cast → resolve → choose → flush
pipeline with revert-failing assertions:

  • per-category substitution (color in a keyword, basic land type on a type line + landwalk,
    creature type on a type line);
  • duration — until-end-of-turn expiry vs. indefinite persistence across cleanup;
  • filter-position recursion — a color word inside a granted static's affected filter, and
    inside an ability's effect target filter;
  • CR 612.2 name exclusion (with a positive reach-guard proving the object was actually
    processed);
  • multi-authority timestamp ordering; the no-op case (no qualifying word); serde round-trip;
    and a 10-card parser snapshot.

Verification

cargo fmt clean · cargo clippy -p engine --all-targets -- -D warnings clean · full
cargo test -p engine green (0 failed) · phase-ai and engine-wasm compile clean ·
parser-combinator and CR-annotation gates pass.

@real-venus real-venus requested a review from matthewevans as a code owner July 15, 2026 15:10

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements interactive text-changing effects (CR 612.1 and CR 612.2) across the engine, parser, AI, and frontend. It introduces the ChangeTextWords effect, the ReplaceTextWord continuous modification in Layer 3, and the TextWordReplacement waiting state, allowing players to choose word substitutions (colors, basic land types, or creature types) via a new frontend modal. The reviewer feedback suggests increasing the touch target height of the replacement buttons in the React modal to at least 44px to meet mobile accessibility guidelines, and optimizing the backend resolution logic in text_change.rs by passing references instead of cloning vectors to avoid unnecessary allocations.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

{data.options.map((option, index) => (
<motion.button
key={index}
className="rounded-lg border border-white/20 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/20"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The touch target height of the text-word replacement buttons is less than 44px (using py-2 with text-sm yields approximately 36px). To ensure mobile/touch friendliness, the touch target should be at least 44pt/px as specified in the repository style guide.

Suggested change
className="rounded-lg border border-white/20 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/20"
className="min-h-[44px] rounded-lg border border-white/20 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/20"
References
  1. Mobile / touch: touch targets ≥ 44pt; :hover-only state breaks on mobile and needs a touch-equivalent. (link)

Comment on lines +29 to +57
let (allowed_categories, excluded_to, duration) = match &ability.effect {
Effect::ChangeTextWords {
allowed_categories,
excluded_to,
duration,
..
} => (
allowed_categories.clone(),
excluded_to.clone(),
duration.clone(),
),
_ => {
return Err(EffectError::InvalidParam(
"expected ChangeTextWords effect".to_string(),
))
}
};

// CR 608.2b: the effect needs a legal object target still in its zone.
let target = ability.targets.iter().find_map(|t| match t {
TargetRef::Object(id) => Some(*id),
TargetRef::Player(_) => None,
});
let Some(target) = target.filter(|id| state.objects.contains_key(id)) else {
events.push(resolved_event(ability));
return Ok(());
};

let options = build_options(state, target, &allowed_categories, &excluded_to);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Avoid cloning allowed_categories and excluded_to vectors during resolution. Since build_options only requires references to these slices, and WaitingFor::TextWordReplacement does not store them, we can match them as references and only clone the duration option. This avoids two unnecessary vector allocations on every resolution pass.

    let (allowed_categories, excluded_to, duration) = match &ability.effect {
        Effect::ChangeTextWords {
            allowed_categories,
            excluded_to,
            duration,
            ..
        } => (
            allowed_categories,
            excluded_to,
            duration.clone(),
        ),
        _ => {
            return Err(EffectError::InvalidParam(
                "expected ChangeTextWords effect".to_string(),
            ))
        }
    };

    // CR 608.2b: the effect needs a legal object target still in its zone.
    let target = ability.targets.iter().find_map(|t| match t {
        TargetRef::Object(id) => Some(*id),
        TargetRef::Player(_) => None,
    });
    let Some(target) = target.filter(|id| state.objects.contains_key(id)) else {
        events.push(resolved_event(ability));
        return Ok(());
    };

    let options = build_options(state, target, allowed_categories, excluded_to);

@real-venus real-venus force-pushed the feat/text-changing-effects branch from 72d7f28 to d122a11 Compare July 15, 2026 16:19
@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 10 card(s), 6 signature(s) (baseline: main 56703eca2f9f)

7 card(s) · ability/change · removed: change

Examples: Alter Reality, Artificial Evolution, Glamerdye (+4 more)

4 card(s) · ability/ChangeTextWords · added: ChangeTextWords (categories=[ColorWord, BasicLandType])

Examples: Crystal Spray, Mind Bend, Trait Doctoring (+1 more)

4 card(s) · ability/ChangeTextWords · added: ChangeTextWords (categories=[ColorWord])

Examples: Alter Reality, Glamerdye, Sleight of Mind (+1 more)

3 card(s) · ability/change · removed: change (duration=until end of turn)

Examples: Crystal Spray, Trait Doctoring, Whim of Volrath

2 card(s) · ability/ChangeTextWords · added: ChangeTextWords (categories=[BasicLandType])

Examples: Magical Hack, Spectral Shift

1 card(s) · ability/ChangeTextWords · added: ChangeTextWords (categories=[CreatureType])

Examples: Artificial Evolution

1 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.

@real-venus real-venus force-pushed the feat/text-changing-effects branch from d122a11 to b36db57 Compare July 15, 2026 17:24
@real-venus

Copy link
Copy Markdown
Contributor Author

@matthewevans
Please kindly review this.

@matthewevans

Copy link
Copy Markdown
Member

Closed without implementation-diff review. This PR enters protected architecture scope because it touches crates/engine/src/game/mod.rs, crates/server-core/src/game_action_payload_guard.rs and spans engine, frontend, and server. AI-contributor PRs may enter this scope only after an explicit prior maintainer appointment or when the PR closes an issue labeled accepted. Tier, contributor standing, the quality label, prior praise, and frontend permission do not waive this gate. Open a fresh PR from current main only after one of those authorizations exists, and rerun /engine-implementer, the final review-impl, and Gate A against its committed head.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Request changes

🔴 Blocker

  • [HIGH] The word walker explicitly omits AbilityDefinition.cost (and AbilityCondition), so this is not rules-complete for the class it parses. Evidence: crates/engine/src/game/text_substitution.rs:157-161 says those roots are an intentional coverage gap, while CR 612.1 says a text-changing effect generally affects an object's rules text and/or type line, and CR 612.2 limits which words may change—not which rules-text fields may be skipped. A concrete supported pattern is Goblin Chirurgeon: its activation cost is “Sacrifice a Goblin”; after Artificial Evolution changes Goblin to Elf, that cost must require sacrificing an Elf. The current walker never descends from AbilityDefinition into cost, so it leaves that rules text unchanged while presenting this as a complete class implementation. Please add a recursive AbilityCost traversal (including nested target/filter-bearing costs and the relevant condition roots), and add an integration test that proves Artificial Evolution changes the legal sacrifice candidate for this activation cost. Do not silently parse cards whose affected text carriers remain unimplemented.

🟡 Non-blocking

  • Gemini's two open notes are worthwhile cleanup: avoid cloning the category/exclusion vectors in text_change::resolve, and make the new modal buttons meet the project's touch-target minimum.

✅ Clean

  • The selected replacement is represented as a Layer-3 transient continuous effect with a concrete object identity and duration; that is the right seam for CR 612/613. The type-line traversal is also supported by CR 612.1.

Recommendation: Address the missing rules-text roots and add the activation-cost regression, then request re-review on the updated head.

@matthewevans matthewevans added the enhancement New feature or request label Jul 15, 2026
Add a reusable Layer-3 token-substitution primitive that replaces one
color word / basic land type / creature type with another on a target
spell or permanent, mirroring the existing SetChosenName name-change.

- New Effect::ChangeTextWords (interactive from/to choice at resolution)
  and ContinuousModification::ReplaceTextWord (latched operands), applied
  at Layer 3 per CR 612.1 / 612.2 / 613.1c.
- New game/text_substitution.rs walker with an exhaustive, CR-612.2-correct
  carrier/no-op classification across every color/land/creature-type-bearing
  enum leaf (keywords, protection/hexproof, target filters, devotion, type
  lines, landwalk, and ability effect target filters). Mana symbols,
  color-set-size predicates, chosen-refs, card names, and the Layer-5 color
  characteristic are correctly never changed.
- Interactive WaitingFor::TextWordReplacement + GameAction round-trip with AI
  legal-action enumeration, multiplayer routing, and render-only choice UI.
- Effect::target_filter_mut accessor (paired with target_filter) so the
  walker reaches words inside ability effect target filters.

Covers Mind Bend, Sleight of Mind, Glamerdye, Alter Reality, Magical Hack,
Artificial Evolution (with the "can't be Wall" excluded_to rider), Crystal
Spray, Trait Doctoring, Whim of Volrath, and Spectral Shift (modal). New
Blood, Balduvian Shaman, Deceptive Divination, Magical Hacker, and
mass-population / on-stack-instant text-changes remain honest coverage gaps.

12 discriminating runtime tests (duration expiry, filter-position color
recursion, landwalk, multi-authority timestamp ordering, Crystal Spray
continuation, CR-612.2 name exclusion, serde round-trip, effect-target-filter
recursion) plus a 10-card parser snapshot.
@real-venus real-venus force-pushed the feat/text-changing-effects branch from 6a437f1 to b3c5198 Compare July 15, 2026 21:22
@real-venus

Copy link
Copy Markdown
Contributor Author

@matthewevans
Please review this

@matthewevans matthewevans self-assigned this Jul 15, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Request changes

🔴 Blocker

  • [HIGH] The replacement traversal still deliberately skips TriggerDefinition.condition / TriggerConstraint and PlayerFilter, while this PR makes text-changing spells supported and lets them target any matching permanent or spell. Evidence: crates/engine/src/game/text_substitution.rs:166-177 documents those roots as excluded, and walk_trigger_definition at lines 1158-1194 only visits event-shape filters, execution, and unless_pay; meanwhile TriggerCondition::ControlsType { filter }, ControlCount { filter }, EventObjectMatchesFilter { filter }, and similar variants carry creature-type/color/land-type words. Why it matters: Artificial Evolution and siblings must replace eligible words in an object's full rules text; a permanent whose relevant word is only in an intervening-if trigger condition will either offer no replacement or retain the old trigger predicate, producing silently wrong behavior. Suggested fix: add exhaustive traversal for the word-bearing TriggerCondition / TriggerConstraint / PlayerFilter branches (or keep the affected-card class honestly unsupported), then add a production-pipeline regression that changes a trigger-condition filter and proves the trigger behavior changes.

  • [MED] The new Goblin Chirurgeon regression inspects the rewritten AbilityCost structure but never attempts the activated ability with an Elf versus a Goblin sacrifice candidate. Evidence: crates/engine/tests/integration/text_changing_effects.rs:469-537 uses the local sacrifice_cost_subtypes helper after the spell resolves. Why it matters: it proves traversal mutation but not that activation's real legality/payment pipeline consumes the rewritten filter—the specific regression requested was legal-candidate behavior. Suggested fix: after Artificial Evolution resolves, drive the actual activation path with both candidate types and assert Elf is accepted while Goblin is rejected (with a reach guard showing the ability/action is available).

🟡 Non-blocking

  • Gemini's outdated notes on the earlier head do not block this review; the current resolver already borrows the category/exclusion slices and the modal uses a 44px minimum height.

✅ Clean

  • The updated head does descend through AbilityDefinition.cost, AbilityCondition, unless_pay, and nested costs, so it addresses the previous cost-root finding at the intended traversal seam.

Recommendation: Complete the remaining rules-text carriers and replace the structural-only cost assertion with an activation-pipeline regression before re-review.

@matthewevans matthewevans removed their assignment Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants