Skip to content

fix(parser): gate Ketramose's batched exile trigger on source zones + own turn (#4952)#5757

Merged
matthewevans merged 1 commit into
phase-rs:mainfrom
nickmopen:fix/ketramose-exile-trigger-4952
Jul 14, 2026
Merged

fix(parser): gate Ketramose's batched exile trigger on source zones + own turn (#4952)#5757
matthewevans merged 1 commit into
phase-rs:mainfrom
nickmopen:fix/ketramose-exile-trigger-4952

Conversation

@nickmopen

Copy link
Copy Markdown
Contributor

Fixes #4952.

Summary

Ketramose, the New Dawn — "Whenever one or more cards are put into exile from graveyards and/or the battlefield during your turn, you draw a card and lose 1 life" — fired on the opponent's turn and when a card was exiled from hand. Both restrictions were dropped by the parser.

Root cause

  • try_parse_one_or_more_put_into_exile_from parsed the zone-set, then bailed on the trailing during your turn clause — so the line fell through to a less-precise parser that produced origin: null, battlefield-only trigger_zones, and no constraint (→ fires on any turn, and on exile from any zone incl. hand).
  • parse_your_zone_token only recognized your library/your graveyard, not the player-agnostic graveyards / the battlefield forms Ketramose uses.

Fix (parser-only; existing runtime-enforced fields)

  • parse_your_zone_token: also accept bare-plural graveyards/libraries, a graveyard, and the battlefield (all lower to the same Zone type the "your" forms do).
  • try_parse_one_or_more_put_into_exile_from: peel an optional trailing during your turn and set TriggerConstraint::OnlyDuringYourTurn instead of bailing.

Ketramose now parses to ChangesZoneAll with origin_zones = [Graveyard, Battlefield] and constraint = OnlyDuringYourTurn — both are already runtime-enforced mechanisms (OnlyDuringYourTurn set elsewhere for "only during your turn" cards; origin_zones used by the batched exile family).

CR references

CR 603.4 (own-turn intervening restriction) + CR 400.1 (source zones). Touched, not invented.

Verification (local, head 91f46db1e181d55b50aa229beb29f41a637438b8)

  • cargo test -p engine --lib ketramose_exile_trigger_gated_on_source_zones_and_own_turn — ok (asserts origin_zones ⊇ {Graveyard, Battlefield} and constraint == OnlyDuringYourTurn)
  • cargo test -p engine --lib parser::oracle7915 passed, 0 failed (full sweep; parse_your_zone_token is used only by the batched-exile family, low blast radius)
  • cargo fmt -p engine -- --check, cargo clippy -p engine --lib (-D warnings) — clean
  • scripts/check-parser-combinators.sh, scripts/check-engine-authorities.sh — pass

Transparency: did not run scripts/ai-gate.sh (Gate A — fresh release build, disk tight). Happy to add a game-pipeline runtime witness (exile-from-graveyard-on-own-turn fires; from-hand / opponent-turn don't) if you'd like it, but this populates existing runtime-enforced fields.

Claimed impact

  • Ketramose, the New Dawn — exile trigger now correctly gated to graveyard/battlefield sources and the controller's own turn.

@nickmopen
nickmopen requested a review from matthewevans as a code owner July 13, 2026 19:55

@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 adds support for parsing player-agnostic source zones (such as 'graveyards' or 'the battlefield') and optional 'during your turn' constraints on batched exile triggers, along with a corresponding test case. The feedback requests correcting the Magic Comprehensive Rules (CR) citations in the comments to adhere to Rule R6 of the style guide: changing CR 400.1 to CR 400.3 for zone types, and CR 603.4 to CR 603.2 for the turn-based trigger restriction.

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 on lines +12049 to +12053
// CR 400.1: source zones expressed player-agnostically — bare plural
// "graveyards"/"libraries" (any player's), "a graveyard", or "the
// battlefield" (Ketramose, the New Dawn: "…from graveyards and/or the
// battlefield"). The batched trigger keys on the zone TYPE the exile
// came from, so these lower to the same `Zone` as the "your" forms.

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 comment cites CR 400.1, which only lists the game's zones. However, the distinction between the singular shared zone ('the battlefield') and the plural player-specific zones ('graveyards', 'libraries') is specifically defined in CR 400.3 ('...the battlefield and the stack are shared zones. Each player has their own library, hand, graveyard, and exile zone.'). Please update the citation to CR 400.3 to adhere to style guide rule R6.

Suggested change
// CR 400.1: source zones expressed player-agnostically — bare plural
// "graveyards"/"libraries" (any player's), "a graveyard", or "the
// battlefield" (Ketramose, the New Dawn: "…from graveyards and/or the
// battlefield"). The batched trigger keys on the zone TYPE the exile
// came from, so these lower to the same `Zone` as the "your" forms.
// CR 400.3: source zones expressed player-agnostically — bare plural
// "graveyards"/"libraries" (any player's), "a graveyard", or "the
// battlefield" (Ketramose, the New Dawn: "…from graveyards and/or the
// battlefield"). The batched trigger keys on the zone TYPE the exile
// came from, so these lower to the same `Zone` as the "your" forms.
References
  1. Every rules-touching line of engine code must carry a comment of the form CR : . The cited rule's body must describe what the code is doing. (link)

Comment on lines +12098 to +12104
// CR 603.4: an optional trailing " during your turn" restricts the batched
// trigger to the controller's own turn (Ketramose, the New Dawn — "…are
// put into exile from graveyards and/or the battlefield during your
// turn"). Peel it here rather than bailing, so the disjunctive origin
// (graveyards + battlefield) is captured AND the own-turn gate applies —
// otherwise the line falls through to a less-precise parser that drops
// both (firing on any turn and on exile from any zone, e.g. hand).

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 comment cites CR 603.4, which governs intervening 'if' clauses. However, 'during your turn' is not an intervening 'if' clause (which requires the literal word 'if' and is checked both on trigger and resolution). Instead, it is a restriction on the trigger event itself, which is governed by CR 603.2 ('These abilities trigger when the game state matches the trigger event.'). Please update the citation to CR 603.2 to adhere to style guide rule R6.

Suggested change
// CR 603.4: an optional trailing " during your turn" restricts the batched
// trigger to the controller's own turn (Ketramose, the New Dawn — "…are
// put into exile from graveyards and/or the battlefield during your
// turn"). Peel it here rather than bailing, so the disjunctive origin
// (graveyards + battlefield) is captured AND the own-turn gate applies —
// otherwise the line falls through to a less-precise parser that drops
// both (firing on any turn and on exile from any zone, e.g. hand).
// CR 603.2: an optional trailing " during your turn" restricts the batched
// trigger to the controller's own turn (Ketramose, the New Dawn — "…are
// put into exile from graveyards and/or the battlefield during your
// turn"). Peel it here rather than bailing, so the disjunctive origin
// (graveyards + battlefield) is captured AND the own-turn gate applies —
// otherwise the line falls through to a less-precise parser that drops
// both (firing on any turn and on exile from any zone, e.g. hand).
References
  1. Every rules-touching line of engine code must carry a comment of the form CR : . The cited rule's body must describe what the code is doing. (link)

@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 1 card(s), 2 signature(s) (baseline: main 7e1ddc9b778c)

1 card(s) · trigger/ChangesZone · removed: ChangesZone (active in=battlefield, to=exile, watches=card)

Examples: Ketramose, the New Dawn

1 card(s) · trigger/ChangesZoneAll · added: ChangesZoneAll (active in=battlefield, constraint=only during your turn, to=exile)

Examples: Ketramose, the New Dawn

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

@matthewevans matthewevans added the bug Bug fix label Jul 13, 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.

Changes requested — the parser fix is correct in shape, but its trigger behavior lacks production-pipeline proof.

🔴 Blocker

  • crates/engine/src/parser/oracle_trigger_tests.rs:23705-23739 only inspects the parsed TriggerDefinition; it never drives ChangesZoneAll through process_triggers/the scenario runner. The new fields are consumed at crates/engine/src/game/trigger_matchers.rs:1133-1154 (origin_zones) and crates/engine/src/game/triggers.rs:6411 (OnlyDuringYourTurn), so an AST assertion cannot prove the issue’s two runtime claims. Add a registered integration test using the real Ketramose Oracle text and actual zone-change events: positive for graveyard/battlefield exile on its controller's turn, negative for exile from hand and for the opponent's turn. The verified Oracle trigger is: “Whenever one or more cards are put into exile from graveyards and/or the battlefield during your turn”.

🟡 Non-blocking

  • crates/engine/src/parser/oracle_trigger.rs:12089 cites CR 603.4 for during your turn, but 603.4 is only the immediate intervening-if rule. The official rules define trigger conditions/events in CR 603.1 and event matching in CR 603.2; update the annotation while adding the runtime coverage. Gemini's proposed CR 400.3 is not correct for zone kinds (that rule concerns an object going to another player's library, graveyard, or hand); the existing CR 400.1 zone reference is appropriate.

✅ Clean

  • parse_your_zone_token and try_parse_one_or_more_put_into_exile_from use the existing composable zone-set and trigger-constraint vocabulary at the correct parser seam. The current parse artifact is scoped to Ketramose alone and records the intended ChangesZoneAll plus own-turn constraint.

Recommendation: request-changes — add the discriminating real-event integration coverage and correct the CR annotation; then resubmit the current head.

@matthewevans matthewevans removed their assignment Jul 13, 2026
… own turn (phase-rs#4952)

Ketramose, the New Dawn's "Whenever one or more cards are put into exile
from graveyards and/or the battlefield during your turn" trigger fired
on the opponent's turn and when exiling a card from hand.

try_parse_one_or_more_put_into_exile_from bailed on the trailing
" during your turn" clause, so the line fell through to a less-precise
parser that dropped the source-zone set and the own-turn gate (origin
null, battlefield-only, no constraint). And parse_your_zone_token only
recognized "your library"/"your graveyard", not the player-agnostic
"graveyards" / "the battlefield" forms.

- parse_your_zone_token: accept bare-plural "graveyards"/"libraries",
  "a graveyard", and "the battlefield".
- try_parse_one_or_more_put_into_exile_from: peel an optional trailing
  " during your turn" and set TriggerConstraint::OnlyDuringYourTurn
  instead of bailing.

Ketramose now parses to ChangesZoneAll with origin_zones =
[Graveyard, Battlefield] and constraint = OnlyDuringYourTurn.
@nickmopen
nickmopen force-pushed the fix/ketramose-exile-trigger-4952 branch from 7b48f62 to da3ce2f Compare July 14, 2026 00:03
@nickmopen

Copy link
Copy Markdown
Contributor Author

Both review points addressed in da3ce2fa5 (rebased onto latest main):

🔴 Blocker — registered game-pipeline integration test. Added crates/engine/tests/integration/ketramose_exile_trigger_gate_4952.rs (registered in main.rs), driving real zone-change events through the cast/stack/process_triggers pipeline. Ketramose is built from its real Oracle text via from_oracle_text (not the card-DB structured rules), so the parser → TriggerDefinition → runtime path is exercised end to end. The witness is Ketramose's distinctive "lose 1 life" (only its trigger drains its controller's life):

  • battlefield_exile_on_own_turn_fires_draw_and_lose_life — Unmake exiles a creature from the battlefield on P0's turn → P0 loses exactly 1 life ✅ (positive, battlefield origin)
  • graveyard_exile_on_own_turn_fires_draw_and_lose_life — Cremate exiles a card from a graveyard on P0's turn → P0 loses exactly 1 life ✅ (positive, graveyard origin)
  • battlefield_exile_on_opponents_turn_does_not_fire — same battlefield exile, but on the opponent's turn → P0's life is unchanged (OnlyDuringYourTurn gate) ✅
  • hand_exile_on_own_turn_does_not_fire — Castigate reveal-and-exiles a card from P1's hand on P0's turn → P0's life is unchanged (origin-zone exclusion) ✅

🟡 Non-blocking — CR annotation. Corrected CR 603.4CR 603.1 + CR 603.2 on the "during your turn" peel (kept CR 400.1 on the player-agnostic source-zone tokens).

Local: all 4 integration tests pass, parser unit test passes, cargo fmt --check clean, clippy --tests clean, parser-combinator gate PASS.

@matthewevans matthewevans self-assigned this Jul 14, 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.

APPROVED — the prior runtime-proof and CR-annotation blockers are resolved on the current head.

🔴 Blocker

  • None.

🟡 Non-blocking

  • The remaining Gemini thread at crates/engine/src/parser/oracle_trigger.rs:12053 is refuted: verified CR 400.1 describes the zone classes and shared/player-owned distinction used by the parser, while CR 400.3 governs an object moving to another player's library, graveyard, or hand.

✅ Clean

  • ketramose_exile_trigger_gate_4952.rs:66-222 drives real spells, zone-change events, trigger matching, and stack resolution from Ketramose's real Oracle text. It discriminates both valid source zones and both excluded paths (hand and opponent's turn).
  • The current parse artifact changes Ketramose alone from the imprecise ChangesZone shape to ChangesZoneAll with the own-turn constraint; no unexplained card impact remains.

Recommendation: approve and enqueue as a parser bug fix.

@matthewevans
matthewevans added this pull request to the merge queue Jul 14, 2026
@matthewevans matthewevans removed their assignment Jul 14, 2026
Merged via the queue into phase-rs:main with commit 66c3a98 Jul 14, 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.

Ketramose, the New Dawn — Ketramose is broken in two ways: It triggers on my opponent's turn, and it triggers when I ex…

2 participants