Skip to content

ENGINE: Support Court of Ambition - #7261

Merged
matthewevans merged 6 commits into
phase-rs:mainfrom
JacobWoodson:claude/court-of-ambition-cbd3aa
Aug 12, 2026
Merged

ENGINE: Support Court of Ambition#7261
matthewevans merged 6 commits into
phase-rs:mainfrom
JacobWoodson:claude/court-of-ambition-cbd3aa

Conversation

@JacobWoodson

@JacobWoodson JacobWoodson commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Implements Court of Ambition (CMR, {2}{B}{B} Enchantment).

Oracle text, verbatim:

When this enchantment enters, you become the monarch.
At the beginning of your upkeep, each opponent loses 3 life unless they discard a card. If you're the monarch, instead each opponent loses 6 life unless they discard two cards.

What was broken

The first sentence already worked end to end. The monarch rider did not — it lowered to Effect::Unimplemented { name: "Unsupported unless clause" }, so the monarch half of the card silently did nothing.

Root cause is two hand-written mirrors of the same grammar that drifted apart. parse_unless_discard_cost (the you payer) had grown a numeric-count axis; parse_unless_they_discard_cost (the anaphoric-player payer) had not — it hard-coded count: 1 and only accepted the singular article. Every card printing a plural discard against a they / that player payer fell out of the grammar entirely.

The fix

Collapse both into one authority, parse_unless_discard_cost_phrase, with the count and type axes composed rather than enumerated:

[<count> | "a" | "an"] [<type phrase>] ("card" | "cards") ["at random"]

parse_number already spans both the numeral and the singular article, so one call covers the whole count axis. The two payer forms now share one vocabulary and cannot diverge again, and "discard two nonland cards" composes for free with no new arm.

Two deliberate calls, both documented at the authority:

  • CR 118.12a — fail closed on a zero count. parse_number folds a bare X to 0, and a zero-card unless-cost is free, so a punisher would silently never fire. An unresolvable count now stays visible as an unsupported clause rather than lowering to a cost every player can always pay. This also tightens the pre-existing you form.
  • CR 701.9b — "at random" is accepted as a tail but deliberately does NOT set CardSelectionMode::Random. The resolution-time unless-payment path (engine_payment_choices.rs) discards selection and always prompts, so emitting Random would claim behavior the engine does not implement. Preserves both mirrors' pre-existing mapping exactly.

No runtime changes were needed — the per-opponent player_scope fan-out, the ScopedPlayer unless-payer arm, and apply_instead_swap preserving player_scope / unless_pay across the CR 614.15 self-replacement swap all already existed.

Resulting parse

BecomeMonarch on the ETB, plus an upkeep trigger:

branch effect scope unless cost payer
base LoseLife 3 Opponent Discard 1 ScopedPlayer
ConditionInstead { IsMonarch } LoseLife 6 Opponent Discard 2 ScopedPlayer

No Unimplemented, no parse warnings.

Tests

Four parser building-block tests:

  • count x type axes across both payer forms (article, numerals, random tail, numeral + type phrase)
  • a plural first branch must still leave a chained " or …" branch for the disjunction combinator
  • the zero-count fail-closed guard, asserted on both payer forms
  • the full Court of Ambition AST — including that the rider is a ConditionInstead swap, not an additive sub

Seven runtime tests in crates/engine/tests/integration/court_of_ambition.rs driving the real upkeep trigger:

  • non-monarch: decline → lose 3, hand intact
  • non-monarch: pay → discard exactly 1, life intact
  • monarch: decline → lose 6, not 3 and not 9 (an additive rider would drain both)
  • monarch: pay → discard exactly 2, life intact
  • monarch with one card in hand → unpayable (CR 118.3), loss happens, the lone card is not taken as partial payment
  • three-player: P1 pays and P2 declines independently — the assertion a controller-bound or first-opponent-bound payer cannot satisfy
  • ETB monarch grant, driven through an actual cast from hand

Verification

  • cargo fmt --all --check clean
  • cargo clippy --all-targets -- -D warnings exit 0
  • full engine suite: 18,858 lib + 4,821 integration, 0 failures

Every CR citation was verified against docs/MagicCompRules.txt.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved parsing of discard-or-life costs with numeric counts, card types, and chained alternatives.
    • Correctly rejects zero-card, variable, and random-discard costs when unsupported.
    • Fixed opponent-specific cost handling and independent choices for Court of Ambition.
  • Tests
    • Added comprehensive coverage for discard costs, alternative branches, monarch effects, unpayable costs, multiplayer prompts, and in-game behavior.

Court of Ambition's monarch rider silently did nothing. The first sentence
("each opponent loses 3 life unless they discard a card") already worked, but
the second ("If you're the monarch, instead each opponent loses 6 life unless
they discard two cards") lowered to
Effect::Unimplemented { name: "Unsupported unless clause" }.

Root cause: two hand-written mirrors of the same grammar drifted.
parse_unless_discard_cost (the "you" payer) had grown a numeric-count axis;
parse_unless_they_discard_cost (the anaphoric-player payer) had not — it
hard-coded count: 1 and only accepted the singular article. Every card printing
a plural discard against a "they" / "that player" payer fell out of the grammar
entirely.

Collapse both into one authority, parse_unless_discard_cost_phrase, with the
count and type axes composed rather than enumerated:

    [<count> | "a" | "an"] [<type phrase>] ("card" | "cards") ["at random"]

so "discard two nonland cards" needs no new arm and the two payer forms cannot
diverge again. parse_number spans both the numeral and the article, so one call
covers the whole count axis.

Two deliberate calls, both documented at the authority:

* CR 118.12a — fail closed on a zero count. parse_number folds a bare X to 0,
  and a zero-card unless-cost is free, so a punisher would silently never fire.
  An unresolvable count stays visible as an unsupported clause instead.
* CR 701.9b — an "at random" tail is accepted but deliberately does NOT set
  CardSelectionMode::Random. The resolution-time unless-payment path
  (engine_payment_choices.rs) discards the selection field and always prompts,
  so emitting Random would claim behavior the engine does not implement. This
  preserves both mirrors' pre-existing mapping exactly.

The runtime plumbing this card needs already existed: the per-opponent
player_scope fan-out, the ScopedPlayer unless-payer arm, and apply_instead_swap
preserving player_scope / unless_pay across the CR 614.15 self-replacement swap.

Court of Ambition now parses to BecomeMonarch on the ETB plus an upkeep trigger
whose base branch is LoseLife 3 / Discard 1 and whose
ConditionInstead { IsMonarch } rider is LoseLife 6 / Discard 2, both scoped
Opponent with a ScopedPlayer payer. No Unimplemented, no parse warnings.

Tests: four parser building-block tests (count x type axes across both payers,
the "or"-chain branch boundary, the zero-count fail-closed guard, and the full
Court of Ambition AST) and seven runtime tests driving the real upkeep trigger —
decline, pay, monarch decline (6, not 3 and not 9), monarch pay, unpayable with
one card in hand, a three-player game where one opponent pays and another
declines independently, and the ETB monarch grant via an actual cast.

Every CR citation verified against docs/MagicCompRules.txt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 79ad9bf7-e8cf-4d77-b1c5-959d4e0cc242

📥 Commits

Reviewing files that changed from the base of the PR and between c3a6ad7 and cf10111.

📒 Files selected for processing (1)
  • crates/engine/tests/integration/main.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/engine/tests/integration/main.rs

📝 Walkthrough

Walkthrough

The parser now shares discard-unless cost parsing for both payer forms. Tests cover discard counts, card types, unsupported random-discard phrases, invalid counts, chained branches, and Court of Ambition resolution.

Changes

Discard-unless parsing and Court of Ambition

Layer / File(s) Summary
Shared discard-unless parser
crates/engine/src/parser/oracle_trigger.rs
Both payer forms use shared parsing for numeric or article counts and typed or untyped cards. Zero counts and random-discard phrases remain unsupported. Chained branches preserve their remaining text.
Parser validation
crates/engine/src/parser/oracle_trigger_tests.rs
Tests cover cost lowering, invalid counts, unsupported random-discard phrases, chained or branches, opponent scope, and monarch replacement handling.
Court of Ambition integration coverage
crates/engine/tests/integration/court_of_ambition.rs, crates/engine/tests/integration/main.rs
Integration tests cover non-monarch and monarch paths, unpayable costs, independent multiplayer prompts, discard payments, and enter-the-battlefield monarch designation.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant OracleText
  participant OracleTriggerParser
  participant GameEngine
  participant Opponent
  OracleText->>OracleTriggerParser: parse discard-unless trigger
  OracleTriggerParser->>GameEngine: create chosen discard cost
  GameEngine->>Opponent: present scoped discard prompt
  Opponent->>GameEngine: discard cards or decline
  GameEngine-->>Opponent: apply discard or life-loss branch
Loading

Suggested labels: enhancement, needs-maintainer

Suggested reviewers: matthewevans

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: adding engine support for Court of Ambition.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
crates/engine/src/parser/oracle_trigger.rs (1)

3362-3382: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Replace the count as i32 cast with a checked conversion.

parse_number returns an unsigned count from untrusted Oracle text. The as cast wraps silently for any value above i32::MAX and produces a negative QuantityExpr::Fixed. Use i32::try_from(count).ok()? so an out-of-range numeral fails closed, consistent with the zero-count arm directly above.

♻️ Proposed change
-    let discard = |filter| AbilityCost::Discard {
-        count: QuantityExpr::Fixed {
-            value: count as i32,
-        },
+    let value = i32::try_from(count).ok()?;
+    let discard = |filter| AbilityCost::Discard {
+        count: QuantityExpr::Fixed { value },
         filter,

As per path instructions: "as-casts or unchecked conversions at trust boundaries" are findings.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/parser/oracle_trigger.rs` around lines 3362 - 3382, Replace
the unchecked count as-cast in the discard closure with a checked i32 conversion
using i32::try_from(count).ok()? so out-of-range Oracle numerals return None.
Preserve the existing zero-count rejection and construct QuantityExpr::Fixed
only from the validated value.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/src/parser/oracle_trigger_tests.rs`:
- Around line 13399-13408: Add a positive reach-guard in
unless_discard_cost_phrase_rejects_zero_count that directly verifies
parse_number returns the zero count and the correct remainder for the tested
input, adjusting the remainder to parse_number’s actual contract. Keep both
existing negative parser assertions so the test proves the count == 0 guard
rejects the phrase after reaching that code path.

In `@crates/engine/src/parser/oracle_trigger.rs`:
- Around line 3384-3397: Update the untyped-noun handling around the cards/card
parser so the “at random” branch succeeds only when the remaining text is
exhausted aside from trailing whitespace or punctuation, rejecting tails such as
additional effects. Preserve random-discard semantics for “unless you discard a
card at random”: do not convert these costs to CardSelectionMode::Chosen, and
fail closed until the unless-cost resolver supports CardSelectionMode::Random.

In `@crates/engine/tests/integration/court_of_ambition.rs`:
- Around line 219-239: Add an opponent-monarch integration test alongside
court_of_ambition_monarch_branch_demands_two_cards_and_drains_six using
build_runner(2, Some(P1), 3). After upkeep, assert P1 receives a one-card
discard prompt; decline the cost, advance the stack, and assert P1 loses three
life, confirming the branch depends on the ability controller being monarch.

---

Nitpick comments:
In `@crates/engine/src/parser/oracle_trigger.rs`:
- Around line 3362-3382: Replace the unchecked count as-cast in the discard
closure with a checked i32 conversion using i32::try_from(count).ok()? so
out-of-range Oracle numerals return None. Preserve the existing zero-count
rejection and construct QuantityExpr::Fixed only from the validated value.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3f18f0bf-1fce-4705-a32e-f1f7ae928f50

📥 Commits

Reviewing files that changed from the base of the PR and between eae73f2 and f2736b3.

📒 Files selected for processing (4)
  • crates/engine/src/parser/oracle_trigger.rs
  • crates/engine/src/parser/oracle_trigger_tests.rs
  • crates/engine/tests/integration/court_of_ambition.rs
  • crates/engine/tests/integration/main.rs

Comment thread crates/engine/src/parser/oracle_trigger_tests.rs
Comment thread crates/engine/src/parser/oracle_trigger.rs
Comment thread crates/engine/tests/integration/court_of_ambition.rs
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

Generated for head cf10111f29da036392f871d41dcf45529b3bcfb2.

Parse changes introduced by this PR · 5 card(s), 6 signature(s) (baseline: main 37848286c93e)

🟢 Added (3 signatures)

  • 3 cards · ➕ ability/Unsupported unless clause · added: Unsupported unless clause
    • Affected (first 3): Balduvian Horde, Minotaur Explorer, Pillaging Horde
  • 1 card · ➕ ability/LoseLife · added: LoseLife (amount=5)
    • Affected (first 3): Remorseless Punishment
  • 1 card · ➕ ability/LoseLife · added: LoseLife (amount=6, conditional=instead if (is monarch))
    • Affected (first 3): Court of Ambition

🔴 Removed (3 signatures)

  • 3 cards · ➖ ability/Sacrifice · removed: Sacrifice (target=self)
    • Affected (first 3): Balduvian Horde, Minotaur Explorer, Pillaging Horde
  • 1 card · ➖ ability/Unsupported unless clause · removed: Unsupported unless clause
    • Affected (first 3): Remorseless Punishment
  • 1 card · ➖ ability/Unsupported unless clause · removed: Unsupported unless clause (conditional=instead if (is monarch))
    • Affected (first 3): Court of Ambition

@matthewevans matthewevans self-assigned this Aug 12, 2026
@matthewevans

Copy link
Copy Markdown
Member

Maintainer fixup applied; holding for current-head evidence.

I pushed 56ab02ad4c8581d4ba79cb61b587656251710f71 to address the three current-head review findings: checked parser count conversion, fail-closed random-discard parsing until the payment path honors random selection, and the controller-vs-opponent-monarch runtime case. Required CI and the parse-diff artifact are now rerunning for that head. I will complete the approval/enqueue decision after those current-head results settle.

@matthewevans matthewevans added the bug Bug fix label Aug 12, 2026
@matthewevans
matthewevans force-pushed the claude/court-of-ambition-cbd3aa branch from 56ab02a to cdc0586 Compare August 12, 2026 02:09
@matthewevans

Copy link
Copy Markdown
Member

Maintainer fixup refreshed; holding for current-head evidence.

The final maintainer-fixup head is cdc05868ff0d7377bc62b69e8df2372f3f2fbdd8; it also corrects the parser documentation so random-discard text is explicitly unsupported until the unless-payment resolver preserves CardSelectionMode::Random. CI has restarted and the existing parse-diff artifact is bound to the previous head, so approval/enqueue remains pending the new current-head checks and artifact.

@matthewevans matthewevans removed their assignment Aug 12, 2026
@matthewevans matthewevans self-assigned this Aug 12, 2026
@matthewevans

Copy link
Copy Markdown
Member

Maintainer test fixup applied; holding for current-head evidence.

The new head c3a6ad7 updates the stale Balduvian Horde regression to assert intentional fail-closed handling of random discard rather than player-chosen lowering. The prior head failed Rust test shard 1 solely because the old assertion contradicted that behavior.

Current-head CI and the coverage parse-diff artifact have not published yet. I will recheck this exact head when they settle; if the parser/runtime evidence is clean, it will proceed to approval and the merge queue.

@matthewevans matthewevans removed their assignment Aug 12, 2026
@matthewevans matthewevans self-assigned this Aug 12, 2026
@matthewevans

Copy link
Copy Markdown
Member

Current-head evidence pending.

The current head is cf10111. Manual re-review found the earlier parser and runtime findings resolved on this tree, but required CI is still pending and the only coverage parse-diff comment identifies prior head c3a6ad7. I will resume approval and merge-queue handling only after the checks and a parse-diff artifact bound to cf10111 settle.

@matthewevans matthewevans removed their assignment Aug 12, 2026
@JacobWoodson

Copy link
Copy Markdown
Contributor Author

Heads-up on the coverage delta: Balduvian Horde is an intentional drop

The coverage-parse-diff report on this PR shows the "unless [you] discard a card at random" class (Balduvian Horde and friends) moving from supported to unsupported. That is deliberate, not a side effect of the Court of Ambition work — please don't bounce the PR for it.

It comes from cdc05868 + c3a6ad76, which made that phrase fail closed instead of lowering it as a player-chosen discard.

Why fail closed is right here. CR 701.9b distinguishes a random discard from a player-selected one, and the unless-payment path (engine_payment_choices.rs) destructures selection: _ — it ignores the mode and always raises WardDiscardChoice. So the old mapping silently let the payer pick which card to pitch on a card that says "at random". That is not a cosmetic gap: on Balduvian Horde it converts a real cost into a strictly cheaper one.

This also matches how the rest of the engine already treats random discard as a cost rather than as an effect. effects/pay.rs deliberately fails such a payment instead of reporting Paid, and has a test pinning that contract:

ability_cost_random_discard_fails_instead_of_silent_paid — "a resolution-time cost shape the authority cannot execute (random discard is not auto-payable) must fail the payment — never silently report Paid"

So the pre-existing lenient parser mapping was the outlier, and this PR brings the parser in line with the cost layer.

Scope note. Court of Ambition itself has no random discard anywhere in it — its clauses are "discard a card" and "discard two cards", and each opponent chooses what to pitch or takes the life loss. The randomness axis is untouched by the card this PR implements; it only surfaced because both payer forms now share one authority (parse_unless_discard_cost_phrase) instead of two drifted mirrors.

Tracked. Follow-up to restore the class correctly rather than by loosening the mapping again: teach the cost layer to honor CardSelectionMode::Random (the effect layer already does this deterministically via state.rng in effects/discard.rs), then flip the parser to emit Random for the "at random" tail. That fixes the whole class, not just Balduvian Horde. Linking here once it's open.

@matthewevans matthewevans self-assigned this Aug 12, 2026
@matthewevans

Copy link
Copy Markdown
Member

Current-head parse evidence pending. Required CI is now green for cf10111f29da036392f871d41dcf45529b3bcfb2, and the current-head parser/runtime review remains clean. Approval and merge-queue handling await the <!-- coverage-parse-diff --> artifact generated for this exact head; the only published artifact is still bound to c3a6ad767ea31f2f398f7fa717ce5a2b1451aeec.

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

Current-head review clean: shared unless-discard grammar preserves Court of Ambition’s per-opponent, monarch-replacement behavior and fails random-discard costs closed until their resolver supports random selection.

@matthewevans
matthewevans added this pull request to the merge queue Aug 12, 2026
@matthewevans matthewevans removed their assignment Aug 12, 2026
Merged via the queue into phase-rs:main with commit affbc91 Aug 12, 2026
14 checks passed
@JacobWoodson
JacobWoodson deleted the claude/court-of-ambition-cbd3aa branch August 14, 2026 15:58
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.

2 participants