Skip to content

Add Angel's Grace#6093

Merged
matthewevans merged 4 commits into
phase-rs:mainfrom
mcbradd:card/angels-grace
Jul 17, 2026
Merged

Add Angel's Grace#6093
matthewevans merged 4 commits into
phase-rs:mainfrom
mcbradd:card/angels-grace

Conversation

@mcbradd

@mcbradd mcbradd commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds engine support for Angel's Grace.

Built for the class, not the card:

  • The "You can't lose the game this turn and your opponents can't win the game this turn." conjunction now splits at the clause boundary via a composed subject × negation axis (players /your opponents × can't /cannot ) in the bare-and splitter — the same seam and shape as the existing Everybody Lives! arms — and each half lowers through the existing parse_restriction_modesCantLoseTheGame/CantWinTheGame transient-continuous-effect machinery (no new effects, no new enum variants).
  • "Until end of turn, damage that would reduce your life total to less than 1 reduces it to 1 instead." lifts to the existing Effect::AddTargetReplacement { target: None } (Rankle and Torbran / Don't Blink seam), delegating the sentence parse to parse_replacement_line (the Worship/Fortune Thief life-floor authority). Angel of Grace (same sentence in an ETB trigger) flips to supported by the same arm.
  • Latent-bug fix found in plan review: the floating (pending-registry) damage-replacement scan hardcoded PlayerId(0) as the controller authority in its damage_target_filter check; it now threads the install-time source_controller, so DamageTargetPlayerScope::Controller/Opponent bind to the actual installing player for the whole floating-replacement class.
  • Class parity for the shared primitive: player_has_cant_lose now scans game_active_statics (battlefield + command zone, CR 114.4) instead of battlefield-only, so a command-zone emblem's "you can't lose the game" (Gideon of the Trials' emblem) is honored — matching player_has_cant_win, which already had command-zone scope.
  • Second review-found gap closed: resolve_lose (CR 104.3e effect-stated losses — Pacts, Door to Nothingness) now gates each loss through player_has_cant_lose, mirroring resolve_win's existing player_has_cant_win gate; previously "can't lose the game" only blocked SBA losses. Concede (CR 104.3a) and the SBA loop are unaffected, and resolve_win's opponent elimination deliberately stays ungated (CR 104.1: a win ends the game; opponents don't "lose" per CR 104.3 — which is why these cards print both halves).

Known remaining sibling gaps (distinct root causes, intentionally out of scope, coverage stays honest/red for them): Gideon of the Trials' emblem parse (single-def emblem static path + short-name normalization of the "Gideon planeswalker" subtype adjective), Serra the Benevolent's emblem (replacement text inside an emblem). Courageous Resolve (comma-list conjunct form)fixed in the review-response commit below, see ## Review response section.

Review response (head 765c12b39)

matthewevans requested changes on two points; both are addressed in 765c12b39, appended on top of c8c3572af (not rebased — a sibling branch, card/gideon-of-the-trials, is based on this one).

1. Coverage-parse-diff verification (4 cards / 8 signatures, not 2). Re-verified against fresh Scryfall Oracle text (not memory) for all four cards. Full discrimination posted as PR comment (<!-- coverage-parse-diff -->). Summary:

  • Angel's Grace / Angel of Grace — the two intended additions; unchanged from the original analysis.
  • Courageous Resolve — the same bare-and boundary Angel's Grace exercises also fires on Courageous Resolve's fateful-hour Oxford-comma list ("...you can't lose the game this turn, and your opponents can't win the game this turn"), which is correct and in-scope. Discriminating it surfaced a real regression the widening exposed: peeling the opponents-clause off the list left the preceding item with a dangling list comma ("can't lose the game this turn,"), which broke parse_restriction_modes's all_consuming match and silently fell to Effect::Unimplemented. Fixed at the shared push_clause_chunk (every chunk boundary in the module routes through it, so this is the root-cause fix, not a one-arm patch) — it now trims a trailing list comma the same way it already trims the sentence-final period. Courageous Resolve now parses fully: supported=true, gap_count=0 (previously left partially Unimplemented; the "known remaining sibling gap" note above is stale and struck through).
  • Celestine Reef — a partial, non-regressing improvement: the "your opponents can't win the game" clause now resolves to CantWinTheGame via the same arm. The "you can't lose the game" clause stays Unimplemented for an unrelated, pre-existing, out-of-scope reason: this engine has no support for "until a player planeswalks" as a recognized duration/until-clause (a Planechase-specific event duration). Not caused by this PR's diff and not fixed here — supported=false, gap_count=1 (down from a fully-unparsed trigger body before this PR).

2. Class boundary at sequence.rs:2707. Reviewed the prior agent's uncommitted documentation/test diff critically rather than trusting it: independently re-verified every cited card (Platinum Angel, Abyssal Persecutor, Herald of Eternal Dawn, Cloudsteel Kirin, Gideon of the Trials' emblem, The Book of Exalted Deeds, The Bird Champion, Adventurer Beguiler) via fresh Scryfall lookups, and re-ran gemini's suggested determiner-generic widen (each/an/their/your + opponent(s)) against Scryfall oracle-text search for "and each/an/their/your opponent(s) can't" — zero matches for every sibling form. The static-line "can't win/lose" compounds are intercepted by is_cant_win_lose_compound (oracle.rs, gated behind is_granted_static_line) before ever reaching this splitter — confirmed by reading both call sites, not assumed. Verdict: kept the documentation and boundary test as committed (both positive and negative cases hold up), and declined gemini's widen — it has no card to exercise or test, matching CLAUDE.md's evidence bar.

Review response 2 (head 0724b2a7b) — 2HG team propagation, CR 810.8a

matthewevans's second CHANGES_REQUESTED review blocked on: CantLoseTheGame protected only the named player, not that player's Two-Headed Giant team.

Rule verified fresh against docs/MagicCompRules.txt:6733 (CR 810.8a), quoted verbatim: "Players win and lose the game only as a team, not as individuals. If either player on a team loses the game, the team loses the game. If either player on a team wins the game, the entire team wins the game. If an effect says that a player can't win the game, that player's team can't win the game. If an effect says that a player can't lose the game, that player's team can't lose the game." The rule's own Platinum Angel example is the exact clause Angel's Grace prints: "Neither that player nor their teammate can lose the game while Platinum Angel is on the battlefield, and neither player on the opposing team can win the game."

Verdict on the can't-win side: the CR text states the identical team-propagation requirement for can't-win as for can't-lose (same sentence, parallel construction — not something I inferred), and the code evidence showed player_has_cant_win (static_abilities.rs) had the identical single-player defect as player_has_cant_lose. So the can't-win side is fixed in this response too, not just documented as a gap — both sides of Angel's Grace's own clause needed it.

Fix, single authority point. player_has_cant_lose (crates/engine/src/game/sba.rs) and player_has_cant_win (crates/engine/src/game/static_abilities.rs) each now fold in the 2HG teammate's grant, mirroring the existing player_has_cant_gain_life / player_has_cant_lose_life teammate fold already in static_abilities.rs (CR 810.9g/810.9h siblings — the reviewer's "adjacent life-lock helpers" reference): true for player_id if EITHER player_id itself or their teammate (topology::has_two_headed_giant_shared_resources + players::teammates) has the grant. Each single-player check was extracted into a small helper (cant_lose_active_for / cant_win_active_for) that the team-aware public function wraps — no duplicated scanning logic.

Both player_has_cant_lose and player_has_cant_win are the single shared authority already consulted by every caller (the SBA loop's collect_life_losers/collect_draw_from_empty_losers in sba.rs, win_lose.rs's resolve_lose/resolve_win, and the loop-shortcut firewall in analysis/loop_check.rs), so the fix applies at one seam and every caller inherits it — no other file needed to change.

Tests added (crates/engine/tests/integration/angels_grace_2hg.rs, registered in tests/integration/main.rs): install the transient CantLoseTheGame/CantWinTheGame grant (the same SpecificPlayer-scoped TCE shape Angel's Grace's own resolution installs) on only ONE 2HG teammate, then exercise the OTHER teammate through the real runtime entry points:

  • sba_loss_blocked_for_2hg_team_when_only_one_teammate_granted — SBA path (check_state_based_actions), team life total ≤ 0, only one teammate granted → team survives.
  • sba_loss_still_applies_to_ungranted_2hg_team — negative control: identical life shape, no grant anywhere → team is eliminated (reach guard proving the SBA path is live).
  • effect_stated_loss_blocked_for_non_granted_2hg_teammate — effect-stated path (resolve_lose, "target player loses the game" aimed at the non-granted teammate) → team survives, with an in-test reach guard against the opposing (ungranted) team.
  • effect_stated_win_blocked_for_non_granted_2hg_teammate — can't-win discrimination (resolve_win, effect-stated win attempted by the non-granted teammate of the CantWinTheGame holder) → no-op, with a reach guard proving an unprotected win still eliminates normally.

The parser/coverage reconciliation from the first review response is untouched — no changes to sequence.rs, mod.rs, or oracle_tests.rs in this commit.

Files changed

  • crates/engine/src/parser/oracle_effect/sequence.rs
  • crates/engine/src/parser/oracle_effect/mod.rs
  • crates/engine/src/game/replacement.rs
  • crates/engine/src/game/effects/win_lose.rs
  • crates/engine/src/game/sba.rs
  • crates/engine/src/game/static_abilities.rs
  • crates/engine/src/parser/oracle_tests.rs
  • crates/engine/tests/integration/angels_grace.rs (new)
  • crates/engine/tests/integration/angels_grace_2hg.rs (new)
  • crates/engine/tests/integration/main.rs

CR references

CR 104.2b, CR 104.3a, CR 104.3b, CR 104.3e, CR 109.4, CR 114.4, CR 117.3d, CR 119.7, CR 119.8, CR 514.2, CR 604.1, CR 611.2c, CR 614.1, CR 614.1a, CR 614.3, CR 810.8a, CR 810.9g, CR 810.9h — all verified by grep against docs/MagicCompRules.txt (July 2026).

Implementation method (required)

Method: /engine-implementer

Track

Developer

LLM

Model: claude-sonnet-5
Thinking: high
Tier: Frontier

Verification

  • Required checks ran clean, or the exact CI-owned alternative is stated below.

  • Gate A output below is for the current committed head.

  • Final review-impl below is clean for the current committed head.

  • Both anchors cite existing analogous code at the same seam.

  • cargo fmt --all — clean, no changes

  • cargo clippy --all-targets -- -D warnings (workspace) — Finished, zero warnings (exit 0)

  • cargo test -p engine --test integration -- angels_grace — 10 passed; 0 failed (6 prior + 4 new 2HG tests)

  • cargo test -p engine (full suite, at 0724b2a7b) — lib: 16857 passed; 0 failed; 7 ignored. Integration: 3301 passed; 0 failed; 2 ignored (3297 baseline + 4 new).

  • cargo coverage — Angel's Grace: supported=true gap_count=0; Angel of Grace: supported=true gap_count=0; Courageous Resolve: supported=true gap_count=0; Celestine Reef: supported=false gap_count=1 (unrelated pre-existing gap, see Review response section) — no regression, this response touches no parser code.

  • cargo semantic-audit — zero findings for Angel's Grace / Angel of Grace (grep -i "angel's grace\|angel of grace" data/semantic-audit.md — no match)

  • git status --short / git diff --stat 765c12b39...HEAD — clean tree, exactly the 4 files this response touches

Review-response-2 commit (0724b2a7b), re-run at that head:

  • cargo fmt --all — clean, no changes
  • cargo clippy --all-targets -- -D warnings (workspace) — Finished, zero warnings (exit 0)
  • cargo test -p engine (full suite) — lib: 16857 passed; 0 failed; 7 ignored. Integration: 3301 passed; 0 failed; 2 ignored.
  • cargo coverage — Angel's Grace / Angel of Grace unregressed (supported=true, gap_count=0 both); no card regressed (no parser files touched by this commit)
  • cargo semantic-audit — zero findings for Angel's Grace / Angel of Grace

Gate A

Gate A PASS head=0724b2a7b360e31e04c64344dcf25b6774a0c59f base=765c12b39b9c7e7149d9797296372e0588b29309

Anchored on

  • crates/engine/src/parser/oracle_effect/sequence.rs:2725 — existing composed subject × verb preceded(alt(..), value((), alt(..))) boundary axis (gendered-pronoun arm) in the same starts_bare_and_clause_lower function; the new plural-player × negation axis mirrors it and replaces the enumerated players can't/cannot tags
  • crates/engine/src/parser/oracle_effect/mod.rs:2031 — parse_enter_from_zone_redirect_replacement, the existing duration-stripped floating-replacement lift to Effect::AddTargetReplacement { target: None } at the same dispatch slot after strip_leading_duration; try_parse_global_life_floor_replacement mirrors its contract (expiry left None, derived from ability duration at install) and its parse_replacement_line delegation follows try_parse_global_damage_modification_replacement (mod.rs:1923)
  • crates/engine/src/game/static_abilities.rs:712 — check_static_ability's CR 114.4 battlefield + command-zone scan (game_functioning_statics), the authority player_has_cant_win already uses; player_has_cant_lose now mirrors it via game_active_statics
  • crates/engine/src/parser/oracle_effect/sequence.rs:3300 (review-response commit) — push_clause_chunk's pre-existing .trim_end_matches('.') sentence-final-period trim is the anchor pattern; the fix extends it to a char-class trim (['.', ','])) at the same call site, not a new suppression arm
  • crates/engine/src/game/static_abilities.rs:1174 and :1192 (review-response-2 commit) — player_has_cant_gain_life / player_has_cant_lose_life's existing has_two_headed_giant_shared_resources(state) && teammates(state, player_id).into_iter().any(...) teammate fold (CR 810.9g/810.9h); player_has_cant_lose and player_has_cant_win now apply the identical fold for CR 810.8a

Final review-impl

Final review-impl PASS head=0724b2a7b360e31e04c64344dcf25b6774a0c59f

Self-review against docs/AI-CONTRIBUTOR.md §5.3 checklist (no repo-local /review-impl skill available in this runtime; performed the checklist directly against git diff 765c12b39..0724b2a7b):

  • (a) correct seam: fix lands exactly at the two authorities the review cited (sba.rs::player_has_cant_lose, static_abilities.rs::player_has_cant_win), not a symptom-patch at a caller.
  • (b) idiomatic: reuses the established player_has_cant_gain_life/player_has_cant_lose_life teammate-fold shape verbatim (same helpers, same short-circuit structure).
  • (c) nom-mandate: n/a — no parser changes in this commit.
  • (d) CR-citation: CR 810.8a is itself the authorizing rule for this behavior (verified fresh against docs/MagicCompRules.txt:6733); no separate layering rule applies.
  • (e) pattern coverage: fixes the shared authority every can't-lose/can't-win caller in the engine routes through (SBA loop, effect-stated loss/win, loop-shortcut firewall) — not an Angel's-Grace-only patch.
  • (f) logic placement: engine-only; no frontend touched.
  • (g) building-block reuse: reuses topology::teammates / topology::has_two_headed_giant_shared_resources, no duplicated scan logic.
  • (h) bool-flag avoidance: no new bool fields/params introduced.
  • (i) test discrimination: all four new tests drive real engine entry points (check_state_based_actions, resolve_lose, resolve_win) and each protected-team assertion is paired with an in-test or sibling reach guard proving the unprotected path still fires — no bare negative assertions.

Claimed parse impact

  • Angel's Grace
  • Angel of Grace
  • Courageous Resolve (incidental fix, review-response commit)

No new parse impact in review-response-2 — that commit touches only runtime authority functions and tests, not the parser.

Validation Failures

None.

CI Failures

None.

mcbradd and others added 2 commits July 17, 2026 04:43
…-floor lift, command-zone can't-lose

- sequence.rs: compose the plural-player subject x negation boundary axis
  (players/your opponents x can't/cannot) in starts_bare_and_clause_lower so
  "You can't lose the game this turn and your opponents can't win the game
  this turn." splits into two subject-restriction clauses (CR 104.2b +
  CR 104.3e); Everybody Lives! behavior preserved.
- oracle_effect/mod.rs: try_parse_global_life_floor_replacement lifts the
  duration-stripped "damage that would reduce your life total to less than N
  reduces it to N instead" clause (CR 614.1a) to
  Effect::AddTargetReplacement { target: None }, delegating the sentence to
  parse_replacement_line; also unlocks Angel of Grace's ETB trigger.
- replacement.rs: thread the install-time source_controller into the floating
  damage_target_filter check instead of a hardcoded PlayerId(0), so
  Controller/Opponent scopes bind to the actual installing player
  (CR 109.4 + CR 614.1a).
- sba.rs: player_has_cant_lose scans game_active_statics (battlefield +
  command zone, CR 114.4) so emblem-granted can't-lose (Gideon of the Trials)
  is honored, matching player_has_cant_win's existing scope.
- tests: SHAPE test with zero-Unimplemented reach guard; command-zone emblem
  SBA unit test; five integration tests casting the verbatim Oracle text from
  P1 (discriminates the floating controller authority) covering can't-lose,
  win-block, per-event life floor, scope split, and cleanup expiry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ace trigger form

Final review-impl findings addressed:
- resolve_lose now filters players through sba::player_has_cant_lose
  (CR 104.3e + CR 810.8a), mirroring resolve_win's player_has_cant_win gate
  (CR 104.2b) — an opponent's "target player loses the game" or the caster's
  own Pact-style loss no longer eliminates a player protected by Angel's
  Grace / Platinum Angel / the Gideon of the Trials emblem. Concede
  (CR 104.3a) and the SBA loop (which pre-filters) are unaffected.
- unit test at the seam (lose_effect_blocked_by_cant_lose_static) plus an
  integration test casting Angel's Grace then resolving loss effects against
  both the protected caster and the unprotected opponent (live reach guard).
- parser SHAPE test pinning Angel of Grace's ETB-trigger form of the
  life-floor lift (AddTargetReplacement + UntilEndOfTurn threaded onto the
  executing ability).
- CR 614.9 citation corrected to CR 614.1 + CR 614.3; expiry test now also
  asserts the can't-lose lock ended at cleanup; win/loss helper spells use
  synthetic names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mcbradd
mcbradd requested a review from matthewevans as a code owner July 17, 2026 12:33

@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 support for "can't lose the game" and "can't win the game" effects, specifically targeting cards like Angel's Grace and Gideon of the Trials' emblem. It updates the loss resolution path to respect "can't lose" continuous effects, expands state-based action checks to scan all active game statics (including command-zone emblems), and fixes a bug where damage target filters for replacement effects were evaluated against a hardcoded player ID instead of the source controller. Additionally, the parser was updated to handle plural-player restriction clauses, accompanied by comprehensive unit and integration tests. The reviewer suggests expanding sibling coverage for opponent-based restriction clauses in the parser using modular, reusable parsers to avoid fragile matches.

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 +2701 to +2707
.or(preceded(
alt((
tag::<_, _, OracleError<'_>>("players "),
tag("your opponents "),
)),
value((), alt((tag("can't "), tag("cannot ")))),
))

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] Expand sibling coverage for opponent-based restriction clauses using modular parsers.

Why it matters: Ensuring that other common opponent-based subjects (like "each opponent", "an opponent", or "their opponents") are covered prevents future parsing failures. To prevent fragile matches and combinatorial explosion, avoid verbatim string equality for compound phrases. Instead, decompose them into modular, reusable parsers for constituent parts (e.g., determiners and subjects) and compose them using idiomatic combinators.

Suggested fix: Refactor the parser to decompose the opponent phrases into an optional determiner followed by the opponent noun.

    .or(preceded(
        alt((
            tag::<_, _, OracleError<'_>>("players "),
            preceded(
                opt(alt((tag("your "), tag("each "), tag("an "), tag("their ")))),
                alt((tag("opponents "), tag("opponent ")))
            ),
        )),
        value((), alt((tag("can't "), tag("cannot ")))),
    ))
References
  1. L2. Sibling coverage: If a parser arm or string was extended, are plural / possessive / negated / 'an opponent's' / 'your' / 'their' / 'non-X' / 'another' variants covered? (link)
  2. Avoid verbatim string equality for parsing Oracle phrases as it bypasses the robust nom-based parser and creates fragile matches. Instead, decompose compound phrases into modular, reusable parsers for constituent parts and compose them using idiomatic combinator aggregates.

@matthewevans matthewevans added the enhancement New feature or request label Jul 17, 2026
@matthewevans matthewevans self-assigned this Jul 17, 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.

Request changes — current parser surface and grammar boundary need reconciliation.

The current-head coverage artifact from CI run 29580637255 (baseline 4a0ffc) reports 4 cards / 8 signatures, while this PR claims Angel's Grace and Angel of Grace only. In addition to the intended additions, it changes the can't duration/signature for Courageous Resolve and the until signature for Celestine Reef. Please explain or discriminate every added/removed signature and restore a current-head <!-- coverage-parse-diff --> evidence comment; no sticky artifact is presently published.

Also, crates/engine/src/parser/oracle_effect/sequence.rs:2707 admits only players / your opponents in the new bare-and can't/cannot classifier. Establish and test the grammar/class boundary with sibling forms, or explain why no other subject form can reach this safe clause boundary. The existing core wiring review is otherwise plausible; CI lint remains in progress and is not the reason for this block.

Evidence: current head c8c3572afc4b54568b2422b6d2f483d497c1d779; CI coverage artifact above. No direct builds were run.

@matthewevans matthewevans removed their assignment Jul 17, 2026
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 5 card(s), 9 signature(s) (baseline: main db82d03b9566)

🟢 Added (4 signatures)

  • 2 cards · ➕ ability/CantLoseTheGame · added: CantLoseTheGame (affects=controller, duration=until end of turn, grants=CantLoseTheGame)
    • Affected (first 3): Angel's Grace, Courageous Resolve
  • 1 card · ➕ ability/AddTargetReplacement · added: AddTargetReplacement (duration=until end of turn, event=DamageDone)
    • Affected (first 3): Angel of Grace
  • 1 card · ➕ ability/if · added: if (duration=until end of turn)
    • Affected (first 3): Multi-Headed
  • 1 card · ➕ ability/until · added: until
    • Affected (first 3): Celestine Reef

🔴 Removed (5 signatures)

  • 1 card · ➖ ability/can't · removed: can't (conditional=life total (you) ≤ 5, duration=until end of turn)
    • Affected (first 3): Courageous Resolve
  • 1 card · ➖ ability/can't · removed: can't (duration=until end of turn)
    • Affected (first 3): Angel's Grace
  • 1 card · ➖ ability/damage · removed: damage (duration=until end of turn)
    • Affected (first 3): Angel of Grace
  • 1 card · ➖ ability/if · removed: if
    • Affected (first 3): Multi-Headed
  • 1 card · ➖ ability/until · removed: until
    • Affected (first 3): Celestine Reef

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

…x Oxford-comma regression

matthewevans requested changes on two fronts: verify the coverage-parse-diff
signature deltas for Courageous Resolve and Celestine Reef, and establish the
grammar/class boundary at sequence.rs:2707 with sibling-form evidence.

Verified against fresh Scryfall Oracle text (never memory):
- Courageous Resolve's fateful-hour list and Celestine Reef's chaos trigger
  both feed "your opponents can't win the game[ this turn]" through the same
  bare-and boundary Angel's Grace exercises — a real, in-scope widening.
- Discriminating the Courageous Resolve delta surfaced an actual regression
  the widening exposed: peeling the opponents-clause off an Oxford-comma list
  ("...this turn, you can't lose the game this turn, and your opponents
  can't...") left the preceding list item with a dangling comma
  ("can't lose the game this turn,"), which broke
  `parse_restriction_modes`'s `all_consuming` match and silently regressed it
  to `Effect::Unimplemented`. Fixed at the shared `push_clause_chunk` (every
  chunk boundary in the module routes through it), not the one call site —
  it now trims a trailing list comma the same way it already trims the
  sentence-final period. Courageous Resolve now parses fully
  (supported=true, gap_count=0, previously left partially Unimplemented).
- Celestine Reef's delta is a partial, non-regressing improvement: the
  opponents clause now resolves to CantWinTheGame; the "you can't lose the
  game" clause stays Unimplemented for an unrelated, pre-existing,
  out-of-scope reason (no support for "until a player planeswalks" as a
  recognized duration/until-clause) — not something this PR's diff caused
  or should fix.

Kept the dead-context agent's class-boundary documentation and boundary
test as-is after independently re-verifying every cited card and every
excluded sibling form via Scryfall search: the static-line "can't win/lose"
compounds (Platinum Angel, Abyssal Persecutor, Herald of Eternal Dawn,
Cloudsteel Kirin, Gideon of the Trials' emblem, The Book of Exalted Deeds,
The Bird Champion) are intercepted by `is_cant_win_lose_compound` before
reaching this splitter; "your opponent can't" (singular) exists only in
stripped reminder text (Adventurer Beguiler); "each opponent"/"an
opponent"/"their opponents" + "can't" appear in no printed Oracle text as a
bare-and conjunct (confirmed by direct search) — gemini's suggested
determiner-generic widen would admit speculative grammar with no card to
exercise or test it, so it is declined and documented instead.

Added crates/engine/src/parser/oracle_tests.rs::
courageous_resolve_fateful_hour_list_zero_unimplemented as the
discriminating regression test (drives the real oracle-text parse, asserts
zero Unimplemented and the exact three-restriction chain in order; fails
against the unfixed push_clause_chunk).

Model: claude-sonnet-5
Thinking: high
Tier: Standard
@mcbradd

mcbradd commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Discrimination of the 4-card / 8-signature parse-diff (head 765c12b39, base c8c3572af on top of main 4a0ffc9fe87d)

The automated <!-- coverage-parse-diff --> comment above (posted after review, head c8c3572af) is accurate: 4 cards, 8 signatures, not the 2 the PR originally claimed. Per the request, here is per-card discrimination against fresh Oracle text (Scryfall via the mtg-commander lookup tool, not memory) plus the actual parsed AST (dumped locally with a throwaway parse_oracle_text call, not from stale notes).

Angel's Grace — intended, correct

You can't lose the game this turn and your opponents can't win the game this turn. Until end of turn, damage that would reduce your life total to less than 1 reduces it to 1 instead. Confirmed verbatim against Scryfall (TSR #4). Parses to CantLoseTheGame (Controller) + CantWinTheGame (Opponent) + AddTargetReplacement (LifeFloor), zero Unimplemented. This is the PR's target card; unchanged from the original analysis.

Angel of Grace — intended, correct

Same life-floor sentence in an ETB trigger (RNA #1, confirmed verbatim). Lifts to AddTargetReplacement via the same arm as Angel's Grace. Unchanged from the original analysis.

Courageous Resolve — in-scope widening; found and fixed a real regression it exposed

Fresh Oracle text (LTC #506): Fateful hour — If you have 5 or less life, you can't lose life this turn, you can't lose the game this turn, and your opponents can't win the game this turn. This is an Oxford-comma list of three restriction clauses under one Fateful-hour conditional, the last of which switches subject to "your opponents" — the same tail Angel's Grace prints, just reached through a comma-list rather than a bare pair. The new players /your opponents arm correctly reaches this tail (in-scope, no widening needed beyond what's already committed).

Discriminating it by actually parsing the card (not just reading the signature label) surfaced a real bug: peeling "your opponents can't win the game this turn" off the end of the list via the bare-and splitter left the preceding list item with its dangling Oxford comma still attached — "can't lose the game this turn,". parse_restriction_modes's dedicated can't lose the game arm requires an exact all_consuming match; the trailing comma broke it, and the clause silently fell to Effect::Unimplemented. This is why the removed signature carried a duration=until end of turn field and the added one didn't — the "added" signature bucket was the new Unimplemented node, not an improvement.

Root-caused and fixed at push_clause_chunk (crates/engine/src/parser/oracle_effect/sequence.rs) — every clause-boundary split in the module routes through it, and it already trimmed the sentence-final period; it now also trims a trailing list comma. Verified before/after with a direct parse_oracle_text dump: before the fix, the middle clause was Unimplemented { name: "can't", description: "can't lose the game this turn," }; after, all three clauses resolve (CantLoseLifeCantLoseTheGameCantWinTheGame, in order, zero Unimplemented). cargo coverage confirms: supported=true, gap_count=0 (previously partially Unimplemented). Permanent regression test: courageous_resolve_fateful_hour_list_zero_unimplemented in oracle_tests.rs.

Celestine Reef — in-scope widening; partial, non-regressing improvement; one unrelated pre-existing gap remains

Fresh Oracle text (OPCA #17): Whenever chaos ensues, until a player planeswalks, you can't lose the game and your opponents can't win the game. Same bare-and shape as Angel's Grace (no Oxford comma this time), so the new arm correctly splits "your opponents can't win the game" off and resolves it to CantWinTheGame. The signature diff (until removed / until added, same label both sides) reflects that: before this PR, the entire trigger body — both restriction clauses together — was one undivided Unimplemented node (the your opponents subject wasn't recognized at all, so the bare-and split never fired); after, one of the two restrictions resolves and the other remains Unimplemented for a wholly separate reason: this engine has no recognized Duration/until-clause for "until a player planeswalks" (a Planechase-specific event duration). That gap is pre-existing, unrelated to the can't/cannot splitter, and out of scope for an Angel's Grace PR — not fixed here. cargo coverage: supported=false, gap_count=1 (down from a fully-unparsed trigger body before this PR — a net improvement, not a regression).

Sibling-form widening request (gemini): declined, with evidence

Gemini's review suggested generalizing the new subject axis to opt(alt(("your", "each", "an", "their"))) + alt(("opponents", "opponent")). Searched Scryfall Oracle text directly for every resulting bare-and conjunct form — "and each opponent can't", "and an opponent can't", "and their opponents can't", "and your opponent can't" (singular), "and opponents can't" (bare) — zero matches for all five. "each opponent can't ..." does exist as real, common printed text (Azor, the Lawbringer; Leovold, Emissary of Trest; Narset, Parter of Veils; Sphinx's Decree; Keen-Eared Sentry), but always sentence-initial after a period, never as a bare-and conjunct feeding this splitter, and never for the lose/win-the-game restriction. Widening admits speculative grammar with no card able to exercise or test it, so it's declined and documented in the boundary comment instead, per the class-boundary instruction.

The whole-line "can't win/lose" compounds the PR's own boundary comment excludes (Platinum Angel, Abyssal Persecutor, Herald of Eternal Dawn, Cloudsteel Kirin, Gideon of the Trials' emblem, The Book of Exalted Deeds, The Bird Champion) were independently re-verified: all print the restriction as part of a whole static ability line, which is_cant_win_lose_compound (oracle.rs, gated behind is_granted_static_line) intercepts and splits before the text ever reaches this effect-sequence splitter — confirmed by reading both call sites, not assumed. Adventurer Beguiler's "your opponent can't." is inside its parenthetical reminder text, stripped pre-parse.

Evidence: head 765c12b39b9c7e7149d9797296372e0588b29309; local cargo coverage + cargo semantic-audit runs against regenerated card data; Scryfall lookups via the card-database tool for all cards named above.

@mcbradd

mcbradd commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Review response — ready for re-review

Addressed both blocking points from @matthewevans's review (head at review time: c8c3572af); new head is 765c12b39b9c7e7149d9797296372e0588b29309, appended on top (not rebased — card/gideon-of-the-trials is based on this branch).

1. Coverage-parse-diff verification. Posted a full per-card discrimination as a <!-- coverage-parse-diff --> comment above, checked against fresh Scryfall Oracle text (not memory). Short version: Angel's Grace / Angel of Grace are the two intended additions, confirmed correct. Courageous Resolve and Celestine Reef both correctly reach the new players /your opponents bare-and arm (they print the same "your opponents can't win the game" tail Angel's Grace does) — that widening is in-scope, not accidental. Discriminating Courageous Resolve's delta surfaced a genuine bug the widening exposed (an Oxford-comma list item left with a dangling separator comma after the tail was peeled off, breaking parse_restriction_modes's exact match and silently regressing to Unimplemented); fixed at the shared push_clause_chunk, with a permanent regression test. Celestine Reef's delta is a partial, non-regressing improvement — the other half of its restriction stays unparsed for an unrelated, pre-existing, out-of-scope reason (no "until a player planeswalks" duration support). Full reasoning and evidence in the comment above.

2. Class boundary at sequence.rs:2707. The uncommitted diff left by the previous session (documentation + a boundary test admitting exactly players/your opponents and excluding every sibling subject form with cited cards) was reviewed critically rather than trusted: every citation was independently re-verified against fresh Oracle text, and gemini's suggested determiner-generic widen (each/an/their/your + opponent(s)) was checked against Scryfall search for the actual bare-and conjunct shape — zero supporting cards for any of the five sibling forms. Kept the documentation and test as committed; declined the wider grammar since nothing exercises or tests it. Both positive (admitted forms) and negative (excluded forms) cases are covered by bare_and_clause_plural_player_restriction_boundary.

Verification at 765c12b39:

  • cargo fmt --all — clean
  • cargo clippy --all-targets -- -D warnings (workspace) — zero warnings
  • cargo test -p engine — lib: 16857 passed, 0 failed, 7 ignored; integration: 3297 passed, 0 failed, 2 ignored
  • ./scripts/gen-card-data.sh — regenerated; incidental known-tokens.toml/oracle-subtypes.json churn reverted before committing (scope discipline)
  • cargo coverage — Angel's Grace / Angel of Grace / Courageous Resolve: supported=true, gap_count=0; Celestine Reef: supported=false, gap_count=1 (unrelated gap, documented above); no other card regressed
  • cargo semantic-audit — zero findings for all four cards
  • Gate A: Gate A PASS head=765c12b39b9c7e7149d9797296372e0588b29309 base=c8c3572afc4b54568b2422b6d2f483d497c1d779
  • Final review-impl: clean at 765c12b39b9c7e7149d9797296372e0588b29309 (checklist in PR body)

PR body's Gate A / Final review-impl / Anchored-on / Claimed-parse-impact sections are updated to this head.

This PR is ready for re-review.

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

Blocked — the new can't-lose enforcement is not team-aware.

🔴 Blocker

[HIGH] CantLoseTheGame protects only the named player, not that player's Two-Headed Giant team. Evidence: crates/engine/src/game/sba.rs:439-461 checks the printed and transient grants only for player_id, and crates/engine/src/game/effects/win_lose.rs:66-80 uses that predicate for effect-stated loss; crates/engine/tests/integration/angels_grace.rs:56-150 exercises only two-player fixtures. CR 810.8a (verified in docs/MagicCompRules.txt:6733) makes a player's can't-lose effect apply to the entire team. Why it matters: after one teammate casts Angel's Grace, an SBA or Target player loses the game aimed at the other teammate can still eliminate the team. Suggested fix: make the shared player_has_cant_lose authority propagate through 2HG teammates, as the adjacent life-lock helpers do, and add runtime SBA and effect-loss fixtures where only one teammate receives the transient grant.

Recommendation: request changes; retain the current parser/coverage reconciliation, then add the team-aware authority and discriminating 2HG coverage before re-review.

… 810.8a)

player_has_cant_lose (sba.rs) and player_has_cant_win (static_abilities.rs)
only checked the named player, so an SBA or effect-stated loss/win aimed at
the OTHER Two-Headed Giant teammate could still resolve even after Angel's
Grace protected their partner. CR 810.8a: "If an effect says that a player
can't win the game, that player's team can't win the game. If an effect says
that a player can't lose the game, that player's team can't lose the game."

Both predicates now fold in the 2HG teammate's grant, mirroring the existing
player_has_cant_gain_life / player_has_cant_lose_life teammate fold
(CR 810.9g/810.9h siblings) already in static_abilities.rs. This is the single
authority point for both the SBA path (sba.rs) and the effect-stated-loss path
(win_lose.rs's resolve_lose/resolve_win), so both inherit the fix without
further changes.

Adds crates/engine/tests/integration/angels_grace_2hg.rs: SBA-loss and
effect-stated-loss/win fixtures where only one 2HG teammate holds the
transient grant, plus negative-control reach guards proving the unprotected
team still loses/wins normally.

Addresses matthewevans's CHANGES_REQUESTED review on PR phase-rs#6093.
@mcbradd

mcbradd commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Review response — 2HG team propagation (CR 810.8a), head 0724b2a7b

Addressing the blocker point-by-point: CantLoseTheGame protected only the named player, not that player's Two-Headed Giant team.

CR text, verified fresh against docs/MagicCompRules.txt:6733 (CR 810.8a), quoted verbatim:

Players win and lose the game only as a team, not as individuals. If either player on a team loses the game, the team loses the game. If either player on a team wins the game, the entire team wins the game. If an effect says that a player can't win the game, that player's team can't win the game. If an effect says that a player can't lose the game, that player's team can't lose the game.

Example: In a Two-Headed Giant game, a player controls Platinum Angel, which reads, "You can't lose the game and your opponents can't win the game." Neither that player nor their teammate can lose the game while Platinum Angel is on the battlefield, and neither player on the opposing team can win the game.

This is the exact clause Angel's Grace prints, and the CR states the propagation requirement identically for both halves — can't-lose and can't-win, same sentence, parallel construction. That settles the can't-win-side question the task raised: the code evidence backed it up too — player_has_cant_win (static_abilities.rs) had the identical single-player defect as player_has_cant_lose (sba.rs), so both are fixed in this response, not just the named one.

Fix, single authority point. Both predicates now fold in the 2HG teammate's grant:

pub(crate) fn player_has_cant_lose(state: &GameState, player_id: PlayerId) -> bool {
    cant_lose_active_for(state, player_id)
        || (super::topology::has_two_headed_giant_shared_resources(state)
            && super::players::teammates(state, player_id)
                .into_iter()
                .any(|teammate| cant_lose_active_for(state, teammate)))
}

(player_has_cant_win in static_abilities.rs mirrors this exactly.) This is the same shape as the existing player_has_cant_gain_life / player_has_cant_lose_life teammate fold (CR 810.9g/810.9h) already in static_abilities.rs — the "adjacent life-lock helpers" the review pointed at. Each single-player check was pulled into a small private helper (cant_lose_active_for / cant_win_active_for) so the team-aware public function wraps it without duplicating the scan.

Both functions are the single shared authority every caller already routes through — the SBA loop (collect_life_losers, collect_draw_from_empty_losers), win_lose.rs's resolve_lose/resolve_win, and the loop-shortcut firewall in analysis/loop_check.rs — so the SBA path and the effect-stated-loss/win path both inherit the fix from this one seam. No other file needed to change.

Tests (crates/engine/tests/integration/angels_grace_2hg.rs, new, registered in tests/integration/main.rs) — each installs the transient grant (the same SpecificPlayer-scoped TCE shape Angel's Grace's own resolution installs) on only ONE 2HG teammate and drives the OTHER teammate through the real runtime entry points, each paired with a reach-guard negative control:

  • sba_loss_blocked_for_2hg_team_when_only_one_teammate_granted / sba_loss_still_applies_to_ungranted_2hg_team — SBA path (check_state_based_actions), team life total ≤ 0.
  • effect_stated_loss_blocked_for_non_granted_2hg_teammate — effect-stated path (resolve_lose, "target player loses the game").
  • effect_stated_win_blocked_for_non_granted_2hg_teammate — can't-win discrimination (resolve_win).

The parser/coverage reconciliation from the prior review response is untouched — this commit touches no parser files.

Verification at 0724b2a7b:

  • cargo fmt --all — clean
  • cargo clippy --all-targets -- -D warnings (workspace) — zero warnings
  • cargo test -p engine — lib: 16857 passed, 0 failed, 7 ignored; integration: 3301 passed, 0 failed, 2 ignored (3297 baseline + 4 new)
  • cargo coverage — Angel's Grace / Angel of Grace unregressed (supported=true, gap_count=0 both); no card regressed
  • cargo semantic-audit — zero findings for Angel's Grace / Angel of Grace
  • Gate A: Gate A PASS head=0724b2a7b360e31e04c64344dcf25b6774a0c59f base=765c12b39b9c7e7149d9797296372e0588b29309
  • Pre-push hook (fmt, clippy, full engine test suite, card-data regen, coverage-diff honesty check, client lint/type-check) — all passed on push to mcbradd/phase (765c12b39..0724b2a7b card/angels-grace -> card/angels-grace)

PR body's Gate A / Final review-impl / Files changed / Verification sections are updated to this head, with a new "Review response 2" section carrying the full detail above.

New head: 0724b2a7b360e31e04c64344dcf25b6774a0c59f. This PR is ready for re-review.

@matthewevans matthewevans self-assigned this Jul 17, 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 on current head 0724b2a7b360e31e04c64344dcf25b6774a0c59f: the Two-Headed Giant correction is at the shared player_has_cant_lose and player_has_cant_win authorities, and the registered team fixtures discriminate both SBA and effect-stated loss/win paths. The current parse artifact is refreshed and CI is green. Approved without the quality label because the PR does not supply an exact current claimed-impact card set matching the five-card artifact.

@matthewevans
matthewevans added this pull request to the merge queue Jul 17, 2026
@matthewevans matthewevans removed their assignment Jul 17, 2026
Merged via the queue into phase-rs:main with commit ce836fa Jul 17, 2026
13 checks passed
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