phase-rs

add-interactive-effect

Use when adding any effect or ability that requires player input mid-resolution — choices, selections, modal decisions, or any WaitingFor/GameAction round-trip. Covers the continuation pattern, engine handler wiring, AI legal actions, multiplayer routing, and frontend UI.

phase-rs 308 216 Actualitzat fa 2 mesos
GitHub

Instal·la

npx skillscat add phase-rs/phase/add-interactive-effect

Instal·la des del registre de SkillsCat.

SKILL.md

Adding an Interactive Effect

Interactive effects pause game resolution to wait for player input, then resume. Examples: Scry (choose top/bottom), Dig (choose cards to keep), Surveil (choose graveyard/library), Search (choose from library), Reveal+Choose (pick opponent's card).

The core mechanism is the continuation pattern: resolve_ability_chain() detects a waiting state, stashes remaining sub-abilities in pending_continuation, and returns. When the player responds, engine.rs resumes the chain.

Before you start: Trace Scry end-to-end as the simplest example:

  • Resolver: effects/scry.rs — sets WaitingFor::ScryChoice
  • Engine handler: engine.rs — (WaitingFor::ScryChoice, GameAction::SelectCards) arm
  • Continuation: pending_continuation resumed after player selects
  • AI: legal_actions.rs — generates legal card selections
  • Frontend: CardChoiceModal → ScryModal

CR Verification Rule: Every CR number in annotations MUST be verified by grepping docs/MagicCompRules.txt before writing. Do NOT rely on memory — 701.x and 702.x numbers are arbitrary sequential assignments that LLMs consistently hallucinate. Run grep -n "^701.22" docs/MagicCompRules.txt (etc.) for every number. If you cannot find it, do not write the annotation.


The Continuation Pattern

This is the most important architectural concept for interactive effects.

Problem

A card says "Scry 2, then draw a card." This is parsed as Scry { count: 2 } with sub_ability: Draw { count: 1 }. But Scry requires player input — the engine can't just barrel through to Draw.

Solution: pending_continuation

resolve_ability_chain(ability) called:
    ↓
resolve_effect(Scry) → sets state.waiting_for = ScryChoice
    ↓
resolve_ability_chain detects waiting state:
    Has sub_ability (Draw)?
        YES → store as state.pending_continuation
        Return Ok(()) — chain paused
    ↓
[Player makes scry selection via GameAction::SelectCards]
    ↓
engine.rs handler processes the selection
    ↓
Check state.pending_continuation.take():
    Some(continuation) → resolve_ability_chain(continuation)
    None → return to Priority

Key code: resolve_ability_chain() — crates/engine/src/game/effects/mod.rs

// After resolve_effect() returns, check if we entered a waiting state
if matches!(state.waiting_for,
    WaitingFor::ScryChoice { .. }
    | WaitingFor::DigChoice { .. }
    | WaitingFor::SurveilChoice { .. }
    | WaitingFor::RevealChoice { .. }
    | WaitingFor::SearchChoice { .. }
    | WaitingFor::DiscoverChoice { .. }
    | WaitingFor::TriggerTargetSelection { .. }
    | WaitingFor::NamedChoice { .. }
    // ← ADD YOUR NEW VARIANT HERE
) {
    // Stash remaining chain as continuation
    let mut sub_clone = sub.as_ref().clone();
    if sub_clone.targets.is_empty() && !ability.targets.is_empty() {
        sub_clone.targets = ability.targets.clone();  // propagate parent targets
    }
    state.pending_continuation = Some(Box::new(sub_clone));
    return Ok(());
}

If you skip adding your variant to this match, sub-abilities after your interactive effect will execute immediately, bypassing the player choice entirely. This is the #1 source of bugs for interactive effects.

pending_continuation storage — crates/engine/src/types/game_state.rs

pub pending_continuation: Option<PendingContinuation>,

pub struct PendingContinuation {
    pub chain: Box<ResolvedAbility>,
    pub parent_kind: Option<EffectKind>, // used to re-emit parent EffectResolved on drain
}

The parent_kind field is set when the continuation is stashed so that draining the continuation re-emits the parent EffectResolved event (see commit e69173e2f — required for fight / DamageAll / DamageEachPlayer paths that pause mid-delivery).

Target propagation

When the continuation is created, parent targets propagate down if the sub-ability has no targets of its own. This allows chains like "Exile target creature. Its controller gains life equal to its power" to work — the sub-ability receives the creature target from the parent.


Checklist — Adding a New Interactive Effect

Phase 1 — WaitingFor + GameAction

  • crates/engine/src/types/game_state.rs — WaitingFor enum
    Add a variant carrying enough data for the frontend to render the choice UI:

    YourChoice {
        player: PlayerId,
        // Data the frontend needs to display options:
        cards: Vec<ObjectId>,     // if choosing cards
        options: Vec<String>,     // if choosing from named options
        // etc.
    },

    The player field is required — it determines who must act.

  • crates/engine/src/types/actions.rs — GameAction enum
    Add a variant for the player's response. Reuse SelectCards or SelectTargets if they fit. Only create a new variant if the response shape is genuinely different:

    YourResponse {
        selection: YourSelectionType,
    },

Phase 2 — Effect Resolver

  • crates/engine/src/game/effects/<name>.rs — resolver
    The resolver does three things:

    1. Compute the choices available to the player
    2. Set state.waiting_for = WaitingFor::YourChoice { ... }
    3. Emit GameEvent::EffectResolved

    Pattern (from effects/scry.rs):

    pub fn resolve(state: &mut GameState, ability: &ResolvedAbility, events: &mut Vec<GameEvent>) -> Result<(), EffectError> {
        let player = ability.controller;
        // 1. Compute choices
        let cards = /* ... */;
        // 2. Set waiting state
        state.waiting_for = WaitingFor::YourChoice { player, cards };
        // 3. Emit event
        events.push(GameEvent::EffectResolved { kind: EffectKind::from(&*ability.effect), source_id: ability.source_id });
        Ok(())
    }
  • crates/engine/src/game/effects/mod.rs — wire resolver + continuation match
    Two changes:

    1. Add Effect::YourEffect { .. } => your_module::resolve(state, ability, events) to resolve_effect()
    2. Add WaitingFor::YourChoice { .. } to the continuation match in resolve_ability_chain() — this is critical

Phase 3 — Engine Handler

  • crates/engine/src/game/engine.rs — apply() match arm
    Add a (WaitingFor::YourChoice { .. }, GameAction::YourResponse { .. }) arm:

    (WaitingFor::YourChoice { player, cards, .. }, GameAction::YourResponse { selection }) => {
        // 1. Validate the response
        // 2. Apply the choice to game state
        // 3. Resume continuation if present:
        if let Some(continuation) = state.pending_continuation.take() {
            // Optionally inject the chosen card/target into continuation's targets:
            // continuation.targets = vec![TargetRef::Object(chosen_id)];
            effects::resolve_ability_chain(state, &continuation, &mut events, 0)
                .map_err(|e| EngineError::InvalidAction(format!("{:?}", e)))?;
        }
        // 4. Return next waiting state (usually Priority, unless continuation set a new one)
        if !matches!(state.waiting_for, WaitingFor::Priority { .. }) {
            state.waiting_for.clone()
        } else {
            WaitingFor::Priority { player: state.active_player }
        }
    }

    Key detail: After resuming the continuation, check state.waiting_for — the continuation might have entered another interactive state (chained choices).

Phase 4 — AI Legal Actions

  • crates/engine/src/ai_support/candidates.rs — candidate action generation
    Legal action generation now lives in the engine crate (engine::ai_support), not in phase-ai. The entry point is engine::ai_support::legal_actions(state) which calls candidate_actions(). Add a match arm generating all legal responses for your WaitingFor variant:

    WaitingFor::YourChoice { cards, .. } => {
        // Generate all valid selections the AI could make
        cards.iter().map(|&id| GameAction::YourResponse { selection: id }).collect()
    }

    Consider:

    • Are all combinations valid, or only specific ones?
    • Is there a "decline" / "choose nothing" option?
    • For large choice sets, does the AI need a subset? (e.g., SearchChoice limits to first N)

Phase 5 — Multiplayer Routing

  • crates/server-core/src/session.rs — acting_player()
    Add a match arm extracting the player field from your WaitingFor variant:

    WaitingFor::YourChoice { player, .. } => Some(*player),

    This ensures the server only accepts actions from the correct player.

Phase 6 — Frontend

  • client/src/adapter/types.ts — WaitingFor + GameAction types
    Add TypeScript discriminated union variants. tsify may auto-generate these from Rust — check client/src/wasm/ for generated types and whether manual overrides exist in types.ts.

  • client/src/pages/GamePage.tsx or client/src/components/ — UI component
    Render the choice when waitingFor.type === "YourChoice". Follow existing patterns:

    • CardChoiceModal → ScryModal — card reordering (top/bottom)
    • CardChoiceModal → DigModal — card selection (keep N)
    • CardChoiceModal → SurveilModal — binary per-card choice (library/graveyard)
    • ChoiceModal — simple option selection (buttons)
    • CardChoiceModal → SearchModal — filtered card selection from list
    • NamedChoiceModal — named choices including CardName, NumberRange, Labeled, and LandType
  • Internationalize all frontend-authored text (titles, prompts, buttons, labels) via t() — const { t } = useTranslation("game"), with keys added to client/src/i18n/locales/en/game.json first. Card names and Oracle text stay raw (content pipeline). Boundary rule and conventions: client/src/i18n/README.md. See $add-frontend-component Phase 2.5 for the full checklist.

Phase 7 — Multiplayer State Filtering (if hidden info)

  • crates/server-core/src/filter.rs — filter_state_for_player()
    If your interactive effect reveals hidden information (opponent's hand, library cards, face-down cards):
    1. Track revealed cards via state.revealed_cards during the choice
    2. Clear revealed status after the choice is made
    3. Ensure filter_state_for_player() respects the revealed set

Phase 8 — Tests

  • Resolver test: effect sets correct WaitingFor with expected choices
  • Engine round-trip test: set up waiting state → submit action → verify state change
  • Production setup test: when correctness depends on how the waiting state is produced, enter WaitingFor through the real effect / cast / ability / replacement path before submitting the action. A manually constructed waiting state is not enough for that claim.
  • Continuation test: effect with sub_ability → interactive pause → resume → sub_ability executes
  • Empty/decline test: for optional choices or "up to" selections, submit the empty choice through the real GameAction path and verify tracked sets, continuations, and final waiting_for state are correct.
  • Tracked-set test: if the choice feeds "those", "them", "this way", or a downstream tracked-set consumer, publish or clear a fresh tracked set on first choice resolution, including all-decline/empty paths, or prove no tracked-set consumer exists.
  • Resume-state test: verify downstream continuations execute and priority / next waiting state is restored; do not only assert that the pending queue was cleared.
  • AI test: get_legal_actions() returns valid options for the waiting state
  • Verify per CLAUDE.md § "Canonical verification pattern" — cargo fmt --all, then if tilt get uiresource clippy >/dev/null 2>&1: ./scripts/tilt-wait.sh --timeout 240 clippy test-engine card-data; else: cargo clippy --all-targets -- -D warnings + cargo test -p phase-engine + ./scripts/gen-card-data.sh.

Extending ChoiceType (Named Choice System)

The NamedChoice system is a well-contained interactive pattern with low blast radius for adding new choice types. Current ChoiceType variants: CreatureType, Color, OddOrEven, BasicLandType, CardType, CardName, NumberRange { min, max }, Labeled { options }, LandType, Opponent, Player, TwoColors.

Architecture (all touchpoints)

# File What to change
1 crates/engine/src/types/ability.rs — ChoiceType enum Add variant with typed fields
2 crates/engine/src/game/effects/choose.rs — compute_options() Add match arm to generate options for the new variant
3 crates/engine/src/game/engine.rs — ChooseOption handler May need custom validation (e.g., NumberRange validates parsed u8 in range)
4 client/src/adapter/types.ts Already generic (choice_type: string, options: string[]) — no change needed
5 client/src/components/modal/NamedChoiceModal.tsx Add rendering branch only if the existing button grid / card-name search is insufficient
6 client/src/components/modal/NamedChoiceModal.tsx — CHOICE_TYPE_TITLE_KEYS Map the new ChoiceType key → an i18n leaf (e.g. Keyword: "keyword"). The title renders via t(\namedChoice.title.${leaf}`), so also add namedChoice.title.toclient/src/i18n/locales/en/game.json`. Never hardcode the label.
7 crates/engine/src/ai_support/candidates.rs — NamedChoice arm Already generates one action per option — works for any choice type with populated options
8 crates/engine/src/parser/oracle_effect/ Add parser patterns for the new choice text
9 crates/engine/src/game/effects/choose.rs — tests Add test for compute_options() with new variant

Key design decisions

  • last_named_choice: Option<String> stores the result for continuations. For NumberRange, the stored string is the number as text (e.g., "3"). Continuations parse it as needed. This avoids changing the continuation protocol.
  • Frontend options: Vec<String> — for NumberRange, compute_options() generates ["0", "1", "2", ..., "7"]. The current ButtonGrid renderer already works; a dedicated number input is optional UX polish, not required.
  • Labeled { options } carries its options in the enum variant — unlike Color or CreatureType where options are hardcoded in compute_options(), Labeled options come from the parser (card-specific text like "fame" / "fortune").
  • Multiplayer filtering: No changes needed — NamedChoice is public information, not filtered by filter_state_for_player().
  • source_id / persist: Existing mechanism for storing choice on the source object via ChosenAttribute. New choice types may not need persistence — use persist: false unless the choice must be remembered across turns.

Interactive Replacement Effects

When a replacement effect (not a regular effect) needs player input, the pattern is different because the choice must happen before the zone change (see add-replacement-effect skill, Interactive Replacements section).

The key difference:

  • Regular interactive effect: resolver sets WaitingFor, engine handler resumes via continuation
  • Interactive replacement: the replacement pipeline pauses, stores the pending ProposedEvent, waits for choice, then executes the zone change with the choice applied

For interactive replacements, you need to:

  1. Add WaitingFor + GameAction as above
  2. Instead of an effect resolver, modify the replacement pipeline in replacement.rs to detect and pause
  3. In engine.rs, handle the response by applying the choice, then resuming the stored zone change
  4. This is more complex — see add-replacement-effect skill for the full pattern

Reference: Existing Interactive Effects

Card Selection Effects (reuse GameAction::SelectCards)

Effect WaitingFor Complexity
Scry ScryChoice { player, cards } Simple — binary per-card (top/bottom)
Dig DigChoice { player, cards, keep_count } Medium — select exactly N cards
Surveil SurveilChoice { player, cards } Simple — binary per-card (library/graveyard)
RevealHand RevealChoice { player, cards, filter } Medium — select from filtered set, clears revealed state after
SearchLibrary SearchChoice { player, cards, count } Complex — filter library, "fail to find" rule, multi-card select
Discover DiscoverChoice { player, cards, discover_value } Medium — exile from top until CMC ≤ discover value, cast or put in hand
Connive ConniveDiscard { player, cards, count } Medium — discard after draw
ChooseFromZone ChooseFromZoneChoice { player, cards, count, zone } Medium — generic zone card selection
ManifestDread ManifestDreadChoice { player, cards } Simple — select card from top to manifest
DiscardToHandSize DiscardChoice { player, cards, count } Simple — select cards to discard down
Proliferate ProliferateChoice { player, permanents } Medium — select permanents to add counters

Targeting & Combat

Effect WaitingFor Complexity
TargetSelection TargetSelection { player, pending_cast, legal_targets } Complex — spell/ability targeting
MultiTargetSelection MultiTargetSelection { player, slots, ... } Complex — multi-slot targeting
TriggerTargetSelection TriggerTargetSelection { player, trigger_index, ... } Complex — trigger target selection
DeclareAttackers DeclareAttackers { player, legal_attackers } Complex — combat phase
DeclareBlockers DeclareBlockers { player, legal_blockers } Complex — combat phase
AssignCombatDamage AssignCombatDamage { player, assignments } Complex — damage assignment order
EquipTarget EquipTarget { player, equipment_id, legal_targets } Simple — target for equip
CopyRetarget CopyRetarget { player, copy_id, ... } Medium — retarget copied spell

Named & Modal Choices

Effect WaitingFor GameAction Complexity
NamedChoice NamedChoice { player, choice_type, options } ChooseOption Medium — creature type, color, etc.
ModeChoice ModeChoice { player, modal, pending_cast } SelectModes Medium — spell modal selection
AbilityModeChoice AbilityModeChoice { player, modal, source_id, ... } SelectModes Medium — activated/triggered modal
Replacement ReplacementChoice { player, count, descriptions } ChooseReplacement Simple — index-based selection
TopOrBottom TopOrBottomChoice { player, card } ChooseTopOrBottom Simple — binary choice
ChooseRingBearer ChooseRingBearer { player, legal_targets } SelectTargets Simple — ring bearer selection
ChooseLegend ChooseLegend { player, legends } SelectCards Simple — legend rule

Cost & Payment

Effect WaitingFor GameAction Complexity
ManaPayment ManaPayment { player } Mana declaration Medium — X costs
OptionalCost OptionalCostChoice { player, costs, ... } ChooseOptionalCosts Medium — kicker, additional costs
DefilerPayment DefilerPayment { player, life_per_pip, ... } DefilerDecision Medium — pay life instead of mana
WarpCost WarpCostChoice { player, options, ... } SelectWarpCost Medium — alternative cost choice
UnlessPayment UnlessPayment { player, cost, ... } PayUnlessCost Medium — "unless you pay"
DiscardForCost DiscardForCost { player, cards, ... } SelectCards Medium — discard as cost
SacrificeForCost SacrificeForCost { player, candidates, ... } SelectCards Medium — sacrifice as cost
ExileFromGraveyardForCost ExileFromGraveyardForCost { player, cards, ... } SelectCards Medium — exile from yard as cost
HarmonizeTapChoice HarmonizeTapChoice { player, candidates, ... } SelectCards Medium — tap creature for cost
WardDiscard WardDiscardChoice { player, ... } SelectCards Simple — ward discard cost
WardSacrifice WardSacrificeChoice { player, ... } SelectCards Simple — ward sacrifice cost

Optional & Opponent Choices

Effect WaitingFor GameAction Complexity
OptionalEffect OptionalEffectChoice { player, description } ChooseOptionalEffect Simple — "you may" effects
OpponentMay OpponentMayChoice { player, description, ... } ChooseOptionalEffect Simple — opponent "may"
AdventureCast AdventureCastChoice { player, card_id } ChooseAdventureCast Simple — cast adventure side
CopyTarget CopyTargetChoice { player, copy_id, legal_targets } SelectTargets Medium — what to copy
CompanionReveal CompanionReveal { player, companion_id } RevealCompanion Simple — companion revelation

Game Structure

Effect WaitingFor Complexity
Mulligan MulliganDecision { player } Simple — keep/mulligan (bottoming is a MulliganDecisionPhase::BottomCards sub-phase of the same variant)
Sideboard BetweenGamesSideboard { player, ... } Medium — sideboard swaps
PlayDraw BetweenGamesChoosePlayDraw { player } Simple — play/draw choice
GameOver GameOver { winners } Terminal state

Note how many effects reuse GameAction::SelectCards — only create a new GameAction variant if the response shape is genuinely different (e.g., ChooseOption for string responses, SelectModes for index-based selection).


Common Mistakes

Mistake Consequence Fix
Missing continuation match in resolve_ability_chain() Sub-abilities after your effect execute immediately, bypassing player choice Add your WaitingFor variant to the match block in effects/mod.rs
Missing acting_player() arm in session.rs Server rejects all actions for this state in multiplayer Add the match arm
Missing AI legal actions AI hangs forever waiting for a response it can't generate Add match arm in engine/src/ai_support/candidates.rs
Not clearing revealed state after choice Opponent's hidden cards remain visible permanently Clear state.revealed_cards in the engine handler
Testing only a manually constructed WaitingFor Bypasses bugs in the real effect/cast/ability path that creates the choice Add at least one production setup test that reaches the waiting state naturally
Resuming continuation without checking state.waiting_for Continuation might set another interactive state, but you overwrite it with Priority Check waiting_for after resolve_ability_chain returns
Clearing pending state without testing downstream continuation The prompt disappears but the rest of the ability never resolves Assert the continuation's effect and final waiting state
Empty choice leaves an old tracked set bound "Those/them/this way" can refer to a prior producer instead of the empty choice Publish/clear a fresh tracked set even for all-decline paths when a consumer exists
Not propagating targets to continuation Sub-ability can't reference the chosen card Copy parent targets when sub_clone.targets.is_empty()
Creating new GameAction when SelectCards works Unnecessary type proliferation Reuse SelectCards unless response shape is genuinely different

Self-Maintenance

After completing work using this skill:

  1. Verify references with the check below
  2. Update if stale: function names, file paths, or enum variants that moved
  3. Add new patterns: if you added a new interactive effect, add it to the reference table

Verification

rg -q "fn resolve_ability_chain" crates/engine/src/game/effects/mod.rs && \
rg -q "pending_continuation" crates/engine/src/types/game_state.rs && \
rg -q "fn resolve_effect" crates/engine/src/game/effects/mod.rs && \
rg -q "fn legal_actions" crates/engine/src/ai_support/mod.rs && \
rg -q "fn acting_player" crates/server-core/src/session.rs && \
rg -q "enum WaitingFor" crates/engine/src/types/game_state.rs && \
rg -q "enum GameAction" crates/engine/src/types/actions.rs && \
rg -q "ScryChoice" crates/engine/src/game/effects/mod.rs && \
echo "✓ add-interactive-effect skill references valid" || \
echo "✗ STALE — update skill references"