Skip to content

fix(parser): support "attacking the last chosen player" statics (Triarch Stalker)#5393

Merged
matthewevans merged 2 commits into
phase-rs:mainfrom
galuis116:feat/attacking-last-chosen-player
Jul 8, 2026
Merged

fix(parser): support "attacking the last chosen player" statics (Triarch Stalker)#5393
matthewevans merged 2 commits into
phase-rs:mainfrom
galuis116:feat/attacking-last-chosen-player

Conversation

@galuis116

Copy link
Copy Markdown
Contributor

Closes #5391.

What

Makes Triarch Stalker and Beckoning Will-o'-Wisp work. Both pair a combat trigger "At the beginning of combat on your turn, choose an opponent" with a continuous static "Creatures attacking the last chosen player have menace / get +1/+0". Neither functioned: the static line failed to parse, and the opponent choice was not persisted for a continuous ability to read.

How (routes to existing runtime, no engine changes)

1. Static scopeparse_attacking_defender_scope gains "the last chosen player" / "the chosen player"ControllerRef::SourceChosenPlayer, alongside the existing you / your-opponents / enchanted-player scopes (same alt, one new axis value). The static parses to FilterProp::Attacking { defender: Some(SourceChosenPlayer) } + the modification, resolved by the existing attacking_defender_matchescontroller_ref_playersource_chosen_player path.

2. Durable persist — new reconcile_persisted_player_choice_for_source_chosen_ref post-pass flips a choose a player / choose an opponent effect to persist: true only when the card carries a durable SourceChosenPlayer reference, so the choice is stored as ChosenAttribute::Player. This mirrors the "persist when referred back to" intent documented on Effect::Choose, and the runtime already re-runs layers for persisted Player/Opponent choices.

Targeted: a same-resolution opponent choice (Ruhan — reads the resolution-scoped ChosenPlayer { index }) has no durable reference and is left persist: false.

Tests

  • Static scope parsing (both phrasings) → Attacking { defender: SourceChosenPlayer } + menace / +1/+0.
  • Full-card persist flip (Triarch Stalker) → trigger Choose { Opponent, persist: true }.
  • Negative regression (Ruhan) → persist: false (pass stays targeted).
  • Runtime attacking_defender_matches resolves against a persisted chosen player (matches the chosen player, rejects others).

CR

CR 508.1b (attacking-player scope) + CR 613.1 / CR 608.2c (persisted "chosen player" read by a continuous ability).

Verification

cargo fmt --all ✓ · engine lib suite 15,812 pass / 0 fail ✓ · clippy -p engine --all-targets -D warnings clean ✓

@galuis116 galuis116 requested a review from matthewevans as a code owner July 8, 2026 21:20

@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 a mechanism to reconcile and persist player choices when they are later referenced by continuous static abilities (such as 'Creatures attacking the last chosen player...'). It introduces parser support for 'the last chosen player' and 'the chosen player' scopes, along with comprehensive unit tests. The review feedback highlights two key areas for improvement: first, a high-severity issue where the detection of source-chosen player references misses checking triggered abilities, and second, a medium-severity issue regarding the incomplete coverage of player-targeting effects in effect_targets_source_chosen_player which currently only matches damage effects.

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.

Comment thread crates/engine/src/parser/oracle.rs Outdated
Comment on lines +1305 to +1312
let references_source_chosen_player = result
.statics
.iter()
.any(static_references_source_chosen_player)
|| result
.abilities
.iter()
.any(ability_references_source_chosen_player);

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.

high

[HIGH] Missing trigger check in references_source_chosen_player detection. Evidence: crates/engine/src/parser/oracle.rs:1305-1312.

Why it matters: If a card has a triggered ability that references the chosen player but has no statics or activated abilities doing so, the choice won't be persisted.

Suggested fix: Include a check for result.triggers using ability_references_source_chosen_player on trigger.execute.

let references_source_chosen_player = result
        .statics
        .iter()
        .any(static_references_source_chosen_player)
        || result
            .abilities
            .iter()
            .any(ability_references_source_chosen_player)
        || result
            .triggers
            .iter()
            .any(|t| {
                t.execute
                    .as_ref()
                    .is_some_and(|a| ability_references_source_chosen_player(a))
            });

Comment on lines +1345 to +1350
fn effect_targets_source_chosen_player(effect: &Effect) -> bool {
match effect {
Effect::DealDamage { target, .. } => filter_references_source_chosen_player(target),
_ => false,
}
}

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

[MEDIUM] Incomplete effect coverage in effect_targets_source_chosen_player. Evidence: crates/engine/src/parser/oracle.rs:1345-1350.

Why it matters: Matching only Effect::DealDamage is a single-case implementation that fails to cover other common player-targeting effects (such as life loss, life gain, drawing, discarding, or milling) that might reference the chosen player.

Suggested fix: Expand the match to cover other player-targeting Effect variants, or add a TODO/comment to ensure they are added when those cards are implemented.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 2 card(s), 4 signature(s) (baseline: main 3a89097a06ba)

2 card(s) · ability/Choose · field persist: yes

Examples: Beckoning Will-o'-Wisp, Triarch Stalker

2 card(s) · ability/static_structure · removed: static_structure

Examples: Beckoning Will-o'-Wisp, Triarch Stalker

1 card(s) · static/Continuous · added: Continuous (affects=attacking scoped player creature, mods=grant Menace)

Examples: Triarch Stalker

1 card(s) · static/Continuous · added: Continuous (affects=attacking scoped player creature, mods=power +1, toughness +0)

Examples: Beckoning Will-o'-Wisp

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

@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.

[MED] SourceChosenPlayer references inside triggers do not make the preceding player choice persistent. Evidence: crates/engine/src/parser/oracle.rs:1305 only scans result.statics and result.abilities before flipping trigger choices to persist: true; result.triggers is only visited later as a writer at crates/engine/src/parser/oracle.rs:1319, and existing trigger parsing can produce TargetFilter::SourceChosenPlayer (crates/engine/src/parser/oracle_trigger.rs:11740, covered by crates/engine/src/parser/oracle_trigger_tests.rs:13285). Why it matters: a card that chooses a player and later has a triggered ability keyed to “the chosen player” would still lose the chosen player after the choose instruction resolves. Suggested fix: include trigger execute abilities in the SourceChosenPlayer-reference scan, with a regression that fails before the trigger scan is added.

[MED] The new effect detector reimplements target discovery for only DealDamage instead of using the existing effect target API. Evidence: crates/engine/src/parser/oracle.rs:1345 only checks Effect::DealDamage { target, .. }, while Effect::target_filter() already surfaces many target-bearing player/object effects such as Draw, GainLife, LoseLife, Token owner, SearchLibrary, etc. at crates/engine/src/types/ability.rs:13042. Why it matters: the persistence pass now silently misses any non-damage ability that reads TargetFilter::SourceChosenPlayer, so the helper is a one-effect special case despite being wired as the general source-chosen-player detector. Suggested fix: drive effect_targets_source_chosen_player through effect.target_filter().is_some_and(filter_references_source_chosen_player) and add at least one non-damage sibling regression.

galuis116 added 2 commits July 8, 2026 19:03
…e-rs#5391)

Triarch Stalker and Beckoning Will-o'-Wisp pair a combat trigger
("At the beginning of combat on your turn, choose an opponent") with a
continuous static ("Creatures attacking the last chosen player have
menace" / "get +1/+0") that reads the chosen player. Neither worked:
the static line failed to parse, and the choice was not persisted for a
continuous ability to read.

Two parser changes route this to existing, tested runtime:

1. `parse_attacking_defender_scope` gains "the last chosen player" /
   "the chosen player" -> `ControllerRef::SourceChosenPlayer`, alongside
   the existing you / your opponents / enchanted-player scopes. The static
   now parses to `FilterProp::Attacking { defender:
   Some(SourceChosenPlayer) }` + the menace / +1/+0 modification, resolved
   by the existing `attacking_defender_matches` -> `controller_ref_player`
   -> `source_chosen_player` path (no runtime change).

2. New `reconcile_persisted_player_choice_for_source_chosen_ref` post-pass
   flips a `choose a player` / `choose an opponent` effect to
   `persist: true` when the card carries a durable `SourceChosenPlayer`
   reference, so the choice is stored as `ChosenAttribute::Player` for the
   static to read. Targeted: a same-resolution opponent choice (Ruhan,
   which reads the resolution-scoped `ChosenPlayer { index }`) is left
   `persist: false`.

Tests: static scope parsing (both phrasings), full-card persist flip,
negative Ruhan-class regression, and runtime `attacking_defender_matches`
resolution against a persisted chosen player.

CR 508.1b + CR 613.1 + CR 608.2c.
…5391)

Address review: the SourceChosenPlayer-reference detection scanned only
statics and activated abilities, so a card whose ONLY chosen-player
reference is a triggered ability (a phase trigger scoped to "the chosen
player's upkeep", or a trigger effect keyed to the chosen player) would
not have its preceding "choose a player" / "choose an opponent" flipped
to persist — the choice would be lost after it resolved.

- `reconcile_persisted_player_choice_for_source_chosen_ref` now also
  scans `result.triggers` as readers via a new
  `trigger_references_source_chosen_player`, which checks both the
  trigger's own `valid_target` (the phase-trigger scope) and its executed
  effect chain.
- `effect_targets_source_chosen_player` now uses the generic
  `Effect::target_filter()` accessor instead of matching only
  `Effect::DealDamage`, so every player-targeting effect variant (life
  loss/gain, draw, discard, mill, ...) is covered.

Regression test: a card whose chosen-player reference lives only in a
phase trigger flips the ETB choose to persist (fails before the trigger
reader scan is added).

CR 613.1 + CR 608.2c + CR 503.1a.
@galuis116 galuis116 force-pushed the feat/attacking-last-chosen-player branch from 8b3c407 to d72814b Compare July 8, 2026 22:03
@galuis116

Copy link
Copy Markdown
Contributor Author

Thanks @matthewevans — good catch, both points addressed in the latest push.

[HIGH/MED] Triggers not scanned as readers: reconcile_persisted_player_choice_for_source_chosen_ref now also scans result.triggers via a new trigger_references_source_chosen_player, which checks both the trigger's own valid_target (the oracle_trigger.rs phase-trigger scope, e.g. "the chosen player's upkeep") and its executed effect chain. So a card whose only chosen-player reference is a triggered ability now correctly persists the preceding choice.

[MEDIUM] Single-case effect coverage: effect_targets_source_chosen_player now uses the generic Effect::target_filter() accessor instead of matching only Effect::DealDamage, so every player-targeting effect variant is covered.

Regression (fails before the trigger-reader scan): choose_persists_when_only_a_trigger_reads_the_chosen_player — a card whose chosen-player reference lives only in a phase trigger flips the ETB choose an opponent to persist: true. Verified the baseline (bare "When enters, choose an opponent" with no reference) stays persist: false.

Full engine lib suite (15,820) + clippy --all-targets green.

@matthewevans matthewevans self-assigned this Jul 8, 2026
@matthewevans matthewevans added the bug Bug fix label Jul 8, 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.

Maintainer review: current head resolves the trigger-reader and generic target-filter findings; parse diff is scoped to the chosen-player attacker statics, and required checks are green.

@matthewevans matthewevans added this pull request to the merge queue Jul 8, 2026
@matthewevans matthewevans removed their assignment Jul 8, 2026
Merged via the queue into phase-rs:main with commit 265c2aa Jul 8, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parser: "Creatures attacking the last chosen player" static is unsupported (Triarch Stalker, Beckoning Will-o'-Wisp)

2 participants