Skip to content

fix(engine): add player-counter Ward cost for "Get five poison counters" - #6662

Open
hurryup52 wants to merge 8 commits into
phase-rs:mainfrom
hurryup52:fix/6640-serpent-society-ward-poison-cost
Open

fix(engine): add player-counter Ward cost for "Get five poison counters"#6662
hurryup52 wants to merge 8 commits into
phase-rs:mainfrom
hurryup52:fix/6640-serpent-society-ward-poison-cost

Conversation

@hurryup52

@hurryup52 hurryup52 commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Summary

The Serpent Society's Ward reads "Ward—Get five poison counters." No WardCost variant existed for "give yourself N counters," so the parser fell through to mana-cost parsing, found no mana symbols, and silently produced WardCost::Mana(generic: 0) — a free, always-paid Ward that never gave the targeting opponent any poison counters. Adds WardCost::GetPlayerCounters { counter_kind, count } / AbilityCost::GetPlayerCounters and wires it through the existing Ward/unless-pay machinery — no new interactive round-trip needed.

Files changed

  • crates/engine/src/types/keywords.rs — new WardCost::GetPlayerCounters variant
  • crates/engine/src/types/ability.rs — matching AbilityCost::GetPlayerCounters variant + 4 helper-method arms
  • crates/engine/src/game/triggers.rsward_cost_to_ability_cost arm + test case
  • crates/engine/src/game/costs.rssupported_at_resolution, can_pay_resolution, pay_ability_cost_inner arms
  • crates/engine/src/game/engine_payment_choices.rshandle_unless_payment arm
  • crates/engine/src/parser/oracle_keyword.rs — new nom-combinator parser branch + 3 unit tests
  • crates/engine/src/analysis/ability_graph.rs, crates/engine/src/game/ability_scan.rs, crates/engine/src/game/cost_payability.rs, crates/engine/src/game/printed_cards.rs, crates/engine/src/game/replacement.rs, crates/engine/src/parser/oracle_effect/lower.rs — arms for the six other exhaustive AbilityCost matches in the crate (found via the compiler's own exhaustiveness checking, not manual audit)
  • client/src/pages/GamePage.tsx + client/src/i18n/locales/en/game.json — Ward-payment prompt label (would otherwise show a generic "Pay a cost" fallback for this new cost shape)
  • crates/engine/tests/integration/serpent_society_ward_poison_cost.rs (new) + crates/engine/tests/integration/main.rs — integration tests

Track

Non-developer

LLM

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

Implementation method (required)

Method: not-applicable — implemented directly. Traced the full Ward pipeline (parser → ward_cost_to_ability_cost → the resolution-time AbilityCost payment authority → the UnlessPayment/PayUnlessCost round trip) against the existing "enchanted player is attacked"-style precedents before writing any code, per the add-keyword/card-test skills, rather than spawning the /engine-implementer pipeline.

CR references

  • CR 702.21a — Ward definition.
  • CR 122.1 — general counter rules (the GetPlayerCounters cost/effect shape).
  • CR 104.3d — ten-or-more-poison loss condition (an existing, unmodified SBA; not exercised by the cost itself, which has no affordability gate).

All three verified against docs/MagicCompRules.txt directly before writing (104.3d at line 346, 702.21a at line 4114).

Verification

  • cargo fmt --all — clean.
  • cargo check -p engine --lib — clean (Finished dev profile ... in ~37s on the final pass), confirming every production-code match arm (including the 6 additional exhaustive AbilityCost sites found only by the compiler) type-checks.
  • cargo test -p engine / cargo clippycould not run to completion in this environment, same limitation already disclosed on PR fix(engine): parse "one or more of your opponents are attacked" triggers #6658 this session: Tilt isn't running here, and every direct cargo test/cargo check --tests invocation for this crate gets OOM-killed (17GB RAM, no swap) regardless of flags — this is a cold, non-incremental, non-Tilt build of a very large crate, not something local retries fix. The new parser unit tests (oracle_keyword.rs) and integration tests (serpent_society_ward_poison_cost.rs) were verified by hand against the exact nom-combinator functions and scenario/runner API calls they use (confirmed each helper's real signature by reading the source, and modeled the integration test line-for-line on the existing phyrexian_fleshgorger_ward.rs), but this is not a substitute for an actual compile+run. Flagging explicitly rather than claiming green — real CI will run the full suite.
  • Gate A / Final review-impl — not run (no /engine-implementer pipeline invocation).

Anchored on

  • crates/engine/src/parser/oracle_keyword.rs (parse_ward_cost_single's existing "pay N life"/"sacrifice ..." branches) — the direct structural precedent for the new "get N counter(s)" branch, same nom-combinator idiom.
  • crates/engine/src/game/costs.rs (EffectCost/Effect::PutCounter arm using effects::counters::add_counter_with_replacement) — the precedent for routing GetPlayerCounters's payment through effects::player_counter::add_player_counter_with_replacement rather than a raw state mutation, so replacement effects still apply.
  • crates/engine/tests/integration/phyrexian_fleshgorger_ward.rs — the exact GameScenario/GameRunner Ward test pattern (add_creature_from_oracle + add_spell_to_hand_from_oraclecast(..).target_objects(..).commit()advance_until_stack_empty() → assert WaitingFor::UnlessPaymentact(PayUnlessCost{pay})) the new test file mirrors.

Claimed parse impact

The Serpent Society: WardCost::Mana(generic: 0)WardCost::GetPlayerCounters { counter_kind: Poison, count: 5 }.

Scope Expansion

None — WardCost::GetPlayerCounters is parameterized over PlayerCounterKind (not poison-only) per this repo's "parameterize, don't proliferate" principle, since the shape is identical for any player-counter kind, but no other card's parse output is claimed as changed by this PR.

Validation Failures

None known — see the Verification section for the one open item (local test execution blocked by sandbox memory, not a known validation failure).

CI Failures

None yet observed (PR not yet run through CI at the time of writing).

Summary by CodeRabbit

  • New Features
    • Added ward/cost support for “Get N counter(s)” (poison, experience, rad, ticket), with correct singular/plural UI text and localized counter-kind labels across supported languages.
  • Bug Fixes
    • Fixed “unless payment” behavior for these ward costs so paying grants the intended counters, declining leaves state consistent, and resolution/parsing correctly handle the poison threshold and related game-over outcomes.
  • Tests
    • Added integration coverage for “Ward—Get five poison counters” (The Serpent Society), along with parsing/mapping updates for the new counter-cost format.

@hurryup52
hurryup52 requested a review from matthewevans as a code owner July 26, 2026 10:51
@github-actions github-actions Bot added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Jul 26, 2026
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The engine adds GetPlayerCounters Ward and ability costs, parses counter-based Oracle text, supports payment and replacement handling, updates cost classification and display formatting, extends AI policy handling, and adds parser and integration coverage for The Serpent Society’s poison-counter Ward.

Changes

Player-counter Ward costs

Layer / File(s) Summary
Counter-cost contracts and parsing
crates/engine/src/types/..., crates/engine/src/parser/..., crates/mtgish-import/src/convert/action.rs
Adds counter-based Ward and ability cost variants, parses numeric and article-based counter costs, and excludes the cost from where-X binding and rewriting.
Ward conversion and payment
crates/engine/src/game/triggers.rs, crates/engine/src/game/costs.rs, crates/engine/src/game/engine_payment_choices.rs, crates/engine/src/game/cost_payability.rs, crates/engine/src/game/replacement.rs
Converts Ward costs, pays counters through replacement handling, and preserves paid, failed, or paused unless-payment outcomes.
Engine classification and display
crates/engine/src/analysis/..., crates/engine/src/game/..., client/src/pages/GamePage.tsx, client/src/i18n/locales/*/game.json
Classifies the new cost as having no modeled resource reads or axes and renders localized singular/plural counter descriptions.
AI Ward-cost handling
crates/phase-ai/src/policies/..., crates/phase-ai/src/tactical_gate.rs
Treats player-counter Ward costs as payable and scores poison severity by counter count, including lethal poison in compound costs.
Ward payment integration coverage
crates/engine/tests/integration/...
Tests the poison-counter Ward prompt, declining payment, successful payment, and poison-ten game-over behavior while resolving a targeted destroy spell.

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

Sequence Diagram(s)

sequenceDiagram
  participant OracleParser
  participant WardTrigger
  participant PaymentEngine
  participant PlayerCounters
  participant GameState
  OracleParser->>WardTrigger: parse Get five poison counters
  WardTrigger->>PaymentEngine: create unless payment
  PaymentEngine->>PlayerCounters: add five poison counters
  PlayerCounters->>GameState: apply replacement and poison state
  GameState-->>PaymentEngine: Paid, Failed, Paused, or GameOver
Loading

Suggested labels: enhancement

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 matches the main change: adding a player-counter Ward cost for the "Get five poison counters" ability.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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

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: 4

🤖 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 `@client/src/pages/GamePage.tsx`:
- Around line 3349-3355: Keep engine counter values opaque in the frontend:
remove any lowercasing or fallback substitution for counter_kind and count, and
pass the engine-provided values unchanged into display rendering. Update the
GetPlayerCounters payload type to a discriminated union with required fields per
counter variant instead of optional ad hoc properties, and use raw engine enum
strings when interpolating translated templates.

In `@crates/engine/src/analysis/ability_graph.rs`:
- Around line 1572-1577: The AbilityCost::GetPlayerCounters arm currently
discards counter_kind and count, so update the loop-analysis handling to
classify supported counter kinds explicitly and preserve their semantic axis and
quantity. Keep poison counters excluded only where justified, and handle
unsupported kinds conservatively rather than treating every variant as a no-op;
extend the relevant parser/loop tests to cover Experience and unsupported kinds.

In `@crates/engine/src/parser/oracle_keyword.rs`:
- Around line 753-786: Update the Ward-cost parsing branch before the mana
fallback to return None for counter-shaped “get … counter” or “get … counters”
text whenever the count or counter kind cannot be parsed. Preserve the existing
GetPlayerCounters result for valid inputs, and add negative tests covering
unknown counter kinds and malformed counts so unsupported text never becomes a
free Mana Ward.

In `@crates/engine/tests/integration/serpent_society_ward_poison_cost.rs`:
- Around line 115-156: The existing Serpent Society Ward test only covers poison
increasing from zero to five. Add a paid-Ward scenario using the same setup and
payment flow, initialize P1 with five poison counters, and assert that reaching
ten causes state-based elimination: P1 is no longer an active priority holder
and the targeted destroy spell does not continue resolving after the loss.
🪄 Autofix (Beta)

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: 698b3451-a1d0-4198-860d-e6fa5c1e1da8

📥 Commits

Reviewing files that changed from the base of the PR and between eae0582 and 0e2e2f5.

📒 Files selected for processing (16)
  • client/src/i18n/locales/en/game.json
  • client/src/pages/GamePage.tsx
  • crates/engine/src/analysis/ability_graph.rs
  • crates/engine/src/game/ability_scan.rs
  • crates/engine/src/game/cost_payability.rs
  • crates/engine/src/game/costs.rs
  • crates/engine/src/game/engine_payment_choices.rs
  • crates/engine/src/game/printed_cards.rs
  • crates/engine/src/game/replacement.rs
  • crates/engine/src/game/triggers.rs
  • crates/engine/src/parser/oracle_effect/lower.rs
  • crates/engine/src/parser/oracle_keyword.rs
  • crates/engine/src/types/ability.rs
  • crates/engine/src/types/keywords.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/serpent_society_ward_poison_cost.rs

Comment thread client/src/pages/GamePage.tsx Outdated
Comment on lines +1572 to 1577
// CR 702.21a: a Ward player-counter cost, like the effect-side
// `Effect::GivePlayerCounter` above, carries no modeled axis here —
// poison accumulation is a loss condition, not a combo resource this
// loop detector tracks.
| AbilityCost::GetPlayerCounters { .. }
| AbilityCost::Unimplemented { .. } => {}

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.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Preserve counter_kind in loop analysis.

GetPlayerCounters is parameterized, but this arm discards both counter_kind and count. The supplied parser tests create the same variant for Experience, while the comment only justifies ignoring poison counters; this can produce incorrect net-axis and loop conclusions for other player-counter kinds. Classify supported kinds explicitly, or make unsupported kinds conservatively handled and tested.

As per path instructions, parameterized variants must retain their semantic axis; the parser coverage demonstrates this is not a poison-only variant.

🤖 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/analysis/ability_graph.rs` around lines 1572 - 1577, The
AbilityCost::GetPlayerCounters arm currently discards counter_kind and count, so
update the loop-analysis handling to classify supported counter kinds explicitly
and preserve their semantic axis and quantity. Keep poison counters excluded
only where justified, and handle unsupported kinds conservatively rather than
treating every variant as a no-op; extend the relevant parser/loop tests to
cover Experience and unsupported kinds.

Source: Path instructions

Comment thread crates/engine/src/parser/oracle_keyword.rs
Comment thread crates/engine/tests/integration/serpent_society_ward_poison_cost.rs
@matthewevans matthewevans self-assigned this Jul 26, 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.

🔴 High — the new counter cost is not fully threaded through the workspace

Reviewed head 0e2e2f5eea6ba3cf11fad16716c11b984f77a348. The submitted claude-sonnet-5 / Frontier declaration is accepted by current policy; it is not a review finding. The implementation still has these blockers:

  1. Rust lint is terminal red with E0004: WardCost::GetPlayerCounters is missing from both severity matches in crates/phase-ai/src/policies/anti_self_harm.rs:784-811 and from strategy_helpers.rs:300-343; AbilityCost::GetPlayerCounters is missing from crates/mtgish-import/src/convert/action.rs:346 and the exhaustive representative test in crates/engine/src/game/costs.rs:1985. Thread the variant through every workspace match without a wildcard escape hatch. In particular, define and test intentional AI semantics for a poison Ward payment that reaches ten: the AI must not treat a lethal self-poison payment as an ordinary affordable/low-severity ward cost. Make compound and non-poison player-counter behavior explicit as well.

  2. crates/engine/src/parser/oracle_keyword.rs:758-786 recognizes get … counter(s) but falls through to mana parsing when its counter shape is malformed or unknown. That can silently lower unsupported text to WardCost::Mana(0). Counter-shaped get Ward text must strict-fail before the mana fallback when either count or kind is unsupported, with negative tests proving malformed counts and unknown counter kinds do not regain free Ward reachability.

  3. Frontend CI fails locale parity: the new English keys game.gamePage.cost.playerCounters_{one,other} are absent from de, es, fr, it, pl, and pt. Add corresponding locale entries.

  4. serpent_society_ward_poison_cost.rs covers only 0→5 poison. Add a production payment-path case beginning P1 at five poison; assert the ten-poison state-based loss removes P1 from active priority and prevents the targeted destroy spell from continuing.

  5. client/src/pages/GamePage.tsx:3349-3393 supplies defaults and lowercases count/counter_kind, reinterpreting engine data in the display layer. Make the counter-cost adapter payload a typed discriminated variant with required fields, and render engine-provided values unchanged (or expose a display-ready engine label); remove frontend fallback/lowercasing behavior.

Required CI is currently red (Rust lint/test shards/WASM/consolidated Rust and frontend). Repair all items, bring the branch current as needed, and request a new review on the resulting head; do not enqueue before required checks pass.

@matthewevans matthewevans added the bug Bug fix label Jul 26, 2026
@matthewevans matthewevans removed their assignment Jul 26, 2026
@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

hurryup52 added a commit to hurryup52/phase that referenced this pull request Jul 26, 2026
CI on phase-rs#6662 caught two gaps my local `cargo check -p engine --lib` never
touched:

- crates/phase-ai has its own exhaustive matches directly on WardCost
  (not AbilityCost) for AI tactical scoring: the Ward-cost "severity"
  pricing in anti_self_harm.rs (both the top-level match and its
  duplicated inline copy inside the Compound arm) and can_pay_ward_cost's
  affordability check in strategy_helpers.rs. Add GetPlayerCounters arms
  to all three: severity scales uncapped with count (voluntarily taking
  poison counters is a real, severe cost, unlike the capped mana/life
  costs), and affordability is unconditionally true (no resource limit
  on giving yourself more counters, mirroring the engine's own
  can_pay_resolution).

- The i18n "locale key parity" test requires es/fr/de/it/pt/pl to have
  the exact same key set as en, not just en as I'd assumed. Add
  cost.playerCounters_one/_other and a cost.playerCounterKind noun map
  to all six locales with real translations (not English placeholders),
  matching the badges section's existing poison/experience/rad counter
  naming conventions. Also redesigned formatUnlessCost's GetPlayerCounters
  case to look the counter-kind noun up through i18n rather than
  interpolating the raw English enum name, so non-English locales render
  correctly.

@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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/phase-ai/src/policies/anti_self_harm.rs (1)

775-797: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Apply Ward-cost scoring to noncreature permanents.

This loop is nested inside the creature-only branch beginning at Line 722, so the new Ward-cost handling is unreachable for artifacts, enchantments, and planeswalkers with Ward. The AI can therefore target a noncreature permanent with Ward—Get ... counters without accounting for the payment cost. Move Ward valuation outside the creature-specific block or share it through a common helper.

🤖 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/phase-ai/src/policies/anti_self_harm.rs` around lines 775 - 797, Move
the Ward-cost valuation loop out of the creature-only branch surrounding it, or
extract it into a shared helper used by all permanent types. Ensure artifacts,
enchantments, and planeswalkers with Ward invoke can_pay_ward_cost and receive
the same severity scoring, including GetPlayerCounters, while preserving the
existing creature scoring behavior.

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/phase-ai/src/policies/anti_self_harm.rs`:
- Around line 793-797: Update the WardCost::GetPlayerCounters scoring in both
the direct branch around the shown match and the compound-cost path near the
referenced secondary location. Score harmful counters such as poison and rad
using their counter kinds and current player totals where relevant, while
assigning appropriate lower or non-harmful values to beneficial/resource
counters such as experience and tickets; do not continue applying count * 3.0
indiscriminately.

---

Outside diff comments:
In `@crates/phase-ai/src/policies/anti_self_harm.rs`:
- Around line 775-797: Move the Ward-cost valuation loop out of the
creature-only branch surrounding it, or extract it into a shared helper used by
all permanent types. Ensure artifacts, enchantments, and planeswalkers with Ward
invoke can_pay_ward_cost and receive the same severity scoring, including
GetPlayerCounters, while preserving the existing creature scoring behavior.
🪄 Autofix (Beta)

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: 543a5e2b-2bf4-4fec-ae3a-1720a50b33d9

📥 Commits

Reviewing files that changed from the base of the PR and between 0e2e2f5 and fe70e11.

📒 Files selected for processing (10)
  • client/src/i18n/locales/de/game.json
  • client/src/i18n/locales/en/game.json
  • client/src/i18n/locales/es/game.json
  • client/src/i18n/locales/fr/game.json
  • client/src/i18n/locales/it/game.json
  • client/src/i18n/locales/pl/game.json
  • client/src/i18n/locales/pt/game.json
  • client/src/pages/GamePage.tsx
  • crates/phase-ai/src/policies/anti_self_harm.rs
  • crates/phase-ai/src/policies/strategy_helpers.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • client/src/pages/GamePage.tsx

Comment thread crates/phase-ai/src/policies/anti_self_harm.rs Outdated
@matthewevans matthewevans self-assigned this Jul 26, 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.

🔴 Blocker — counter Ward is still incomplete on the fresh head

Reviewed commit fe70e11bc3a14b18f1c184c0b9828a4738571c64.

  1. The parse-diff artifact reports no card-parse changes, which conflicts with the PR's claimed The Serpent Society Mana(0)GetPlayerCounters change. Please explain that discrepancy and add actual-card/pipeline evidence that the card parses from card data to the typed Ward cost and reaches the paid-Ward flow; a parser fixture alone is not sufficient.

  2. crates/engine/src/parser/oracle_keyword.rs:758-786 recognizes get N <known> counters but lets malformed or unknown counter-shaped get ... counters text fall through to WardCost::Mana(0). Make this grammar failure explicit (or otherwise prevent the zero-mana fallback) and cover negative reach cases.

  3. The workspace is not exhaustively updated: crates/mtgish-import/src/convert/action.rs and crates/mtgish-import/src/convert/costs.rs omit AbilityCost::GetPlayerCounters; crates/phase-ai/src/ability_graph.rs also discards its kind/count semantics. Update these sites according to their domain contracts rather than treating the new cost as inert.

  4. crates/phase-ai/src/policies/anti_self_harm.rs assigns every player-counter cost count * 3, and crates/phase-ai/src/strategy_helpers.rs calls every such cost payable. Counter kinds are not interchangeable: lethal poison at the current total plus the requested count must be rejected, with explicit behavior for the supported resource kinds and compound costs.

  5. The Serpent Society test covers only poison 0 → 5. Add a discriminating continuation/state-based-action case (for example 5 → 10) that demonstrates the cost is not merely parsed but changes the game outcome correctly.

Please also resolve the corresponding review threads after addressing these points.

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

🔴 Blocker — corrected current-head review for counter Ward

Reviewed commit fe70e11bc3a14b18f1c184c0b9828a4738571c64. This supersedes the inaccurate path claims in my prior review; the following current-head blockers remain:

  1. The parse-diff sticky reports no card-parse changes, contradicting the claimed The Serpent Society Mana(0)GetPlayerCounters conversion. Explain the discrepancy and add actual-card/pipeline proof that card data parses the card to the typed Ward cost and reaches the paid-Ward path. A parser fixture alone does not establish that claim.

  2. crates/engine/src/parser/oracle_keyword.rs:758-786 still lets malformed or unknown counter-shaped get ... counter(s) Ward text drop through to WardCost::Mana(0). Fail closed before the mana fallback and add negative reach tests for malformed counts and unknown counter kinds.

  3. The new AbilityCost::GetPlayerCounters is missing from the rewrite_bound_x_in_ability_cost match in crates/mtgish-import/src/convert/action.rs:346, producing the Rust exhaustive-match failure. Its actual exhaustive test/mirror is crates/engine/src/game/costs.rs:1985-2099, where both the sample_for match and all_variants tags omit the new variant. Thread the no-X semantics through the importer and keep the lockstep test exhaustive.

  4. client/src/pages/GamePage.tsx:3348-3400 silently substitutes a count and converts an unrecognized counter_kind to poison (while lowercasing known kinds). The display layer must not reinterpret an engine cost: make the payload typed with required fields and render engine-provided data unchanged, or provide an engine-owned display value.

  5. crates/phase-ai/src/policies/anti_self_harm.rs:722,775-821 scores Ward only within the creature branch, while crates/phase-ai/src/tactical_gate.rs:308-315 evaluates Ward for every targetable permanent. Give all Ward targets one full typed-cost evaluation: lethal poison must be rejected, supported non-poison kinds must have explicit semantics, compound costs must compose those semantics, and noncreature Ward must receive the same treatment.

  6. serpent_society_ward_poison_cost.rs only tests poison 0 → 5; that does not discriminate the lethal boundary. Add a real payment-path 5 → 10 continuation/SBA test proving the player is eliminated and the targeted spell does not continue.

The required Rust aggregate is still failed in the current recommendation packet. Please address these blockers, resolve the related review threads, and request review on a new head.

@matthewevans matthewevans removed their assignment Jul 26, 2026
hurryup52 added a commit to hurryup52/phase that referenced this pull request Jul 26, 2026
…frontend type

Addresses matthewevans's review on phase-rs#6662:

- Third crate with an exhaustive AbilityCost match my earlier per-crate
  checks missed: crates/mtgish-import/src/convert/action.rs's
  rewrite_bound_x_in_ability_cost (no-op arm — count is a fixed u32, not
  an X-bindable QuantityExpr).
- crates/engine/src/game/costs.rs's exhaustive "lockstep gate" test
  (sample_for + all_variants) only exercised under --tests, which this
  sandbox can't compile, so cargo check --lib alone never caught it.
  Added a real arm so GetPlayerCounters is actually exercised by
  every_ability_cost_variant_has_resolution_support_answer, not just
  made to compile.

- AI severity scoring (anti_self_harm.rs) is now lethality- and
  kind-aware instead of a flat count*3.0: a poison payment that would
  push the AI to LETHAL_POISON (10, per features::poison) scores
  prohibitively (100.0, matching this file's own "never do this"
  sentinel) rather than an ordinary linear severity; non-poison player
  counters (experience/rad/ticket) carry no loss-condition risk and
  score 0. Fixed the Compound arm's sum-then-min(4.0) cap, which would
  otherwise silently clamp a lethal poison sub-cost back down to an
  ordinary-looking severity.

- Parser (oracle_keyword.rs): counter-shaped "get ... counter(s)" text
  that fails to parse (unparseable count, unknown counter kind) now
  fails closed (returns None) instead of falling through to the
  mana-cost fallback — the same silent-free-Ward bug class phase-rs#6640 was
  about, for different malformed input. Added negative tests for both
  failure shapes.

- Frontend (GamePage.tsx): formatUnlessCost's GetPlayerCounters case is
  now a real discriminated union member with required, exact-cased
  fields (count: number, counter_kind: "Poison"|"Experience"|"Rad"|
  "Ticket") — no more toLowerCase()/fallback-default reinterpretation of
  engine data in the display layer. Renamed the playerCounterKind i18n
  keys in all 7 locales to match the engine's exact PascalCase strings.

- New integration test: a Ward payment that pushes the payer to ten
  poison counters triggers the CR 104.3d loss SBA immediately (before
  the targeted destroy spell can resolve), mirroring sba.rs's own
  sba_poison_10_player_loses unit test shape.

Verified with cargo check --lib on all three affected crates (engine,
phase-ai, mtgish-import) — all clean. Full cargo test still blocked by
this sandbox's memory ceiling (disclosed previously on this PR); the
new/changed test code was hand-verified against the real APIs it calls.

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

🧹 Nitpick comments (1)
crates/phase-ai/src/policies/anti_self_harm.rs (1)

793-874: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse the existing reaches_lethal helper instead of duplicating the threshold check.

Both the direct arm (Lines 811-813) and the Compound has_lethal_poison closure (Lines 834-838) hand-roll current.saturating_add(*count) >= LETHAL_POISON. crates/phase-ai/src/policies/poison.rs already exposes pub(crate) fn reaches_lethal(current_poison: u32, added: u32) -> bool doing exactly this. Reusing it here removes the duplication and the exact kind of drift risk that the earlier review comment on this file already had to flag (the direct vs. compound scoring paths falling out of sync).

♻️ Proposed refactor
-                            let current =
-                                ctx.state.players[ctx.ai_player.0 as usize].poison_counters;
-                            if current.saturating_add(*count)
-                                >= crate::features::poison::LETHAL_POISON
-                            {
+                            let current =
+                                ctx.state.players[ctx.ai_player.0 as usize].poison_counters;
+                            if crate::policies::poison::reaches_lethal(current, *count) {
                                 100.0
                             } else {
                                 *count as f64 * 3.0
                             }
-                            let has_lethal_poison = costs.iter().any(|c| {
-                                matches!(
-                                    c,
-                                    WardCost::GetPlayerCounters {
-                                        counter_kind:
-                                            engine::types::player::PlayerCounterKind::Poison,
-                                        count,
-                                    } if ctx.state.players[ctx.ai_player.0 as usize]
-                                        .poison_counters
-                                        .saturating_add(*count)
-                                        >= crate::features::poison::LETHAL_POISON
-                                )
-                            });
+                            let poison_counter = ctx.state.players[ctx.ai_player.0 as usize].poison_counters;
+                            let has_lethal_poison = costs.iter().any(|c| {
+                                matches!(
+                                    c,
+                                    WardCost::GetPlayerCounters {
+                                        counter_kind:
+                                            engine::types::player::PlayerCounterKind::Poison,
+                                        count,
+                                    } if crate::policies::poison::reaches_lethal(poison_counter, *count)
+                                )
+                            });

Please verify reaches_lethal's exact module path (crate::policies::poison) and its pub(crate) visibility resolves from anti_self_harm.rs.

As per path instructions, "any new helper that duplicates an existing building block... Prefer composing std primitives and reusing existing helpers over re-implementation."

🤖 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/phase-ai/src/policies/anti_self_harm.rs` around lines 793 - 874, In
the direct Poison GetPlayerCounters arm and the Compound arm’s has_lethal_poison
closure, replace the duplicated saturating threshold checks with
crate::policies::poison::reaches_lethal, passing the current poison total and
added count. Preserve the existing lethal severity behavior and verify the
pub(crate) helper is accessible from anti_self_harm.rs.

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.

Nitpick comments:
In `@crates/phase-ai/src/policies/anti_self_harm.rs`:
- Around line 793-874: In the direct Poison GetPlayerCounters arm and the
Compound arm’s has_lethal_poison closure, replace the duplicated saturating
threshold checks with crate::policies::poison::reaches_lethal, passing the
current poison total and added count. Preserve the existing lethal severity
behavior and verify the pub(crate) helper is accessible from anti_self_harm.rs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d6003ea1-89f8-4975-adf3-a754068fd09e

📥 Commits

Reviewing files that changed from the base of the PR and between fe70e11 and 42a538c.

📒 Files selected for processing (13)
  • client/src/i18n/locales/de/game.json
  • client/src/i18n/locales/en/game.json
  • client/src/i18n/locales/es/game.json
  • client/src/i18n/locales/fr/game.json
  • client/src/i18n/locales/it/game.json
  • client/src/i18n/locales/pl/game.json
  • client/src/i18n/locales/pt/game.json
  • client/src/pages/GamePage.tsx
  • crates/engine/src/game/costs.rs
  • crates/engine/src/parser/oracle_keyword.rs
  • crates/engine/tests/integration/serpent_society_ward_poison_cost.rs
  • crates/mtgish-import/src/convert/action.rs
  • crates/phase-ai/src/policies/anti_self_harm.rs
🚧 Files skipped from review as they are similar to previous changes (10)
  • client/src/i18n/locales/pt/game.json
  • client/src/i18n/locales/de/game.json
  • client/src/i18n/locales/en/game.json
  • client/src/i18n/locales/pl/game.json
  • client/src/i18n/locales/fr/game.json
  • client/src/i18n/locales/it/game.json
  • crates/engine/tests/integration/serpent_society_ward_poison_cost.rs
  • client/src/i18n/locales/es/game.json
  • crates/engine/src/parser/oracle_keyword.rs
  • crates/engine/src/game/costs.rs

@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: 1

🤖 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 `@client/src/pages/GamePage.tsx`:
- Around line 3376-3378: Update the counter-kind branch in the cost formatting
logic to pass cost.counter_kind unchanged as the kind interpolation value,
removing the gamePage.cost.playerCounterKind translation. Keep localization
limited to the surrounding gamePage.cost.playerCounters template.
🪄 Autofix (Beta)

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: 3f017326-411f-4ad4-b8ec-96ffa40a7fa4

📥 Commits

Reviewing files that changed from the base of the PR and between 42a538c and 4af0dd7.

📒 Files selected for processing (2)
  • client/src/pages/GamePage.tsx
  • crates/engine/tests/integration/serpent_society_ward_poison_cost.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/engine/tests/integration/serpent_society_ward_poison_cost.rs

Comment on lines +3376 to +3378
if ("counter_kind" in cost) {
const kind = t(`gamePage.cost.playerCounterKind.${cost.counter_kind}`);
return t("gamePage.cost.playerCounters", { count: cost.count, kind });

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.

⚠️ Potential issue | 🟠 Major

Keep engine-provided counter_kind unchanged.

This still transforms the engine enum into an i18n key and passes the translated noun as kind. Localize only the surrounding template; pass cost.counter_kind through unchanged or expose a display-ready label from the engine/adapter.

Suggested fix
-    const kind = t(`gamePage.cost.playerCounterKind.${cost.counter_kind}`);
-    return t("gamePage.cost.playerCounters", { count: cost.count, kind });
+    return t("gamePage.cost.playerCounters", {
+      count: cost.count,
+      kind: cost.counter_kind,
+    });
🤖 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 `@client/src/pages/GamePage.tsx` around lines 3376 - 3378, Update the
counter-kind branch in the cost formatting logic to pass cost.counter_kind
unchanged as the kind interpolation value, removing the
gamePage.cost.playerCounterKind translation. Keep localization limited to the
surrounding gamePage.cost.playerCounters template.

Sources: Path instructions, Learnings

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

🔴 Blocker — current head still treats player-counter Ward as safe when it can lose the game

Reviewed commit 4af0dd76762a91769a782cadff20f4f7e609272b.

  1. HIGH — payability is still split from scoring. crates/phase-ai/src/policies/strategy_helpers.rs:337-346 returns true for every GetPlayerCounters, and crates/phase-ai/src/tactical_gate.rs:307-315 uses that result as a hard target-choice gate. anti_self_harm.rs:805-876 only changes the later score, so the AI can still select a Ward payment that takes poison from 5 to 10. Its compound logic also checks each poison component against the same starting total, so individually nonlethal components can be jointly lethal. Put typed player-counter payability in the shared authority, aggregate poison across Compound, and add target-choice tests proving both direct and compound lethal poison Ward are rejected.

  2. MED — Rad is incorrectly valued as harmless. anti_self_harm.rs:801-819 assigns non-poison player counters zero harm, but Rad is a typed admitted counter kind (oracle_nom/quantity.rs:5387-5393) whose engine resolution mills and can cause life loss (game/effects/rad_counters.rs:13-15,99-159). Give every supported kind explicit typed valuation and test Rad directly and inside Compound.

  3. MED — Ward counter parsing remains structurally outside the nom grammar. oracle_keyword.rs:758-788 now fails closed, but it recognizes its counter(s) tail with strip_suffix rather than composable nom productions. Refactor the count/article, kind, and singular/plural axes into nom combinators so this parser family has one grammatical authority rather than ad-hoc string dispatch.

  4. The parse-diff sticky predates this head and still reports zero card changes. Refresh/reconcile the artifact with the claimed The Serpent Society conversion and provide current-head actual-card pipeline evidence.

Lower priority but confirmed: client/src/pages/GamePage.tsx:3367-3378 still localizes/interprets the engine counter kind in the display layer; keep the transport/display contract explicit while fixing the blocking engine/AI behavior. I am not repeating the previously refuted ability-graph claim.

@matthewevans

Copy link
Copy Markdown
Member

Correction to review #4781781812: its statement that the parse-diff sticky predates the current head is no longer accurate. The refreshed parse-diff artifact was updated at 2026-07-26T13:07:37Z for head 4af0dd76762a91769a782cadff20f4f7e609272b and reports no card-parse changes.

This corrects only the artifact-freshness claim. Reconciliation and actual-card pipeline evidence remain required, and all other blockers in the current changes-requested review stand.

@matthewevans matthewevans removed their assignment Jul 26, 2026
@hurryup52

Copy link
Copy Markdown
Contributor Author

@matthewevans — pushed d5d0f44 addressing all three blockers plus the parse-diff evidence and the lower-priority note:

1. Payability split from scoring (HIGH) — fixed. total_poison_from_ward_cost (strategy_helpers.rs) now recursively sums poison across the whole Compound cost tree, and can_pay_ward_cost checks that aggregate once against the current total, instead of checking each sub-cost against the same unchanged starting total. Added rejects_targeting_ward_with_jointly_lethal_compound_poison in tactical_gate.rs (P0 at 4 poison, Compound([GetPlayerCounters{Poison,3}, GetPlayerCounters{Poison,3}])) — neither 3 alone is lethal from 4, but 4+3+3=10 is — asserting Reject. Also added rejects_targeting_noncreature_ward_that_would_be_lethal_poison since the hard gate is target-type-agnostic.

2. Rad valued as harmless (MED) — fixed. Rad now scores (count * 0.2).min(2.0) with a CR 728.1 rationale (mills + possible life loss), Experience/Ticket score 0.0 (no loss condition), Poison keeps count * 3.0 uncapped since lethal payments never reach this scoring at all now (rejected upstream). Tested directly and inside Compound.

3. Ward parsing outside the nom grammar (MED) — fixed. parse_get_player_counters_ward_cost in oracle_keyword.rs composes the count/article, kind, and singular/plural axes as nom combinators (tag, alt, value, space0, opt, all_consuming) instead of strip_suffix dispatch, and fails closed (None, no fallback to a free Mana Ward) on unparseable count or unknown kind — added ward_get_counters_with_unparseable_count_fails_closed and ward_get_counters_with_unknown_kind_fails_closed asserting exactly that.

4. Parse-diff reconciliation / actual-card pipeline evidence — ran oracle-gen directly against cached MTGJSON data (--filter "The Serpent Society") at current head d5d0f44fd:

{
  "name": "The Serpent Society",
  "keywords": [
    "Deathtouch",
    { "Ward": { "type": "GetPlayerCounters", "data": { "counter_kind": "Poison", "count": 5 } } }
  ]
}

Before this issue's fix (verified against upstream/main's parse_ward_cost_single): WardCost::GetPlayerCounters doesn't exist on main at all, so "Ward—Get five poison counters." fell through every recognized pattern (pay life / discard / sacrifice / waterbend) straight to the final parse_mtgjson_mana_cost fallback, producing WardCost::Mana(generic: 0) — a free Ward that was silently wrong. The sticky parse-diff artifact reports coverage-status (supported: true/false), not raw AST, and this was a "supported-aspect defect" (already marked supported: true pre-fix despite parsing wrong) — invisible to that diff by design. The oracle-gen output above is the direct pipeline proof the artifact can't show.

Lower priority — GamePage.tsx counter_kind display: fixed. Replaced t(`gamePage.cost.playerCounterKind.${cost.counter_kind}`) (interpolating the raw engine value into the i18n key template) with an exhaustive switch over cost.counter_kind mapping each literal to its key explicitly — a future PlayerCounterKind variant now fails to compile here instead of silently missing a translation at runtime.

Also fixed (from an earlier round, not repeated in your last review but still open): the Ward severity-pricing block in anti_self_harm.rs was nested inside the creature-only branch, so a Ward-bearing noncreature permanent (artifact/enchantment/planeswalker) was never priced by the AI's judgment layer at all — only the hard lethal-poison reject gate covered it. Moved the pricing block to run for every permanent type.

Verification this round: cargo check -p engine --lib, cargo check -p phase-ai --lib, cargo clippy -p engine --lib -- -D warnings, cargo clippy -p phase-ai --lib -- -D warnings, cargo fmt --all all clean; frontend tsc -b --noEmit clean and the full vitest run suite green (270 files / 2366 tests). As before, cargo test/cargo check --tests reliably OOMs in my sandbox on this workspace's size, so full-suite Rust proof relies on this PR's own CI shards.

@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: 1

🤖 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/phase-ai/src/policies/strategy_helpers.rs`:
- Around line 324-329: Update can_pay_ward_cost’s lethal-poison check to
calculate the effective poison counter increase using the same player-counter
replacement logic as add_player_counter_with_replacement, rather than
total_poison_from_ward_cost alone. Compare the post-replacement count against
LETHAL_POISON, and add a regression test covering a poison Ward cost modified by
a replacement effect.
🪄 Autofix (Beta)

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: 0ff40a5f-e6d1-4e95-84a4-a352a6e55287

📥 Commits

Reviewing files that changed from the base of the PR and between 4af0dd7 and d5d0f44.

📒 Files selected for processing (5)
  • client/src/pages/GamePage.tsx
  • crates/engine/src/parser/oracle_keyword.rs
  • crates/phase-ai/src/policies/anti_self_harm.rs
  • crates/phase-ai/src/policies/strategy_helpers.rs
  • crates/phase-ai/src/tactical_gate.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • client/src/pages/GamePage.tsx
  • crates/engine/src/parser/oracle_keyword.rs

Comment thread crates/phase-ai/src/policies/strategy_helpers.rs Outdated
@matthewevans matthewevans self-assigned this Jul 26, 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 new noncreature Poison/Rad Ward behavior reaches target scoring without a production-seam regression.

🟡 Non-blocking

[MED] Missing AntiSelfHarmPolicy target-selection coverage for payable noncreature player-counter Ward costs. Evidence: crates/phase-ai/src/policies/anti_self_harm.rs:845-939 scores payable Ward costs on every permanent type, while the new noncreature Ward cases in crates/phase-ai/src/tactical_gate.rs:1137-1269 only assert GateDecision affordability/rejection. Why it matters: a change can preserve the tactical gate yet omit or mis-rank the Poison/Rad Ward penalty in the production target scorer. Suggested fix: add a target-selection regression through AntiSelfHarmPolicy using a noncreature permanent with a payable, nonlethal GetPlayerCounters Poison or Rad Ward and an unwarded equivalent; assert the Ward target receives the intended lower score/ranking.

Recommendation: request changes — add the production target-scoring regression, then request another review.

@matthewevans matthewevans removed their assignment Jul 26, 2026
@hurryup52

Copy link
Copy Markdown
Contributor Author

Pushed 86c8d26 addressing the target-scoring coverage gap:

Added `noncreature_ward_target_scores_lower_than_unwarded_equivalent` in `anti_self_harm.rs` — two identical opponent-owned artifacts (one bare, one with a small nonlethal payable poison-counter Ward: 0+2 poison, well under the lethal threshold) both as legal targets of the same removal (exile) effect. Asserts `AntiSelfHarmPolicy.score` ranks the unwarded artifact higher than the warded one, confirming the severity-pricing block I moved out of the creature-only branch last round actually reaches the production target scorer's ranking output for a noncreature target, not just the tactical_gate accept/reject gate.

Verified: `cargo check -p phase-ai --lib`, `cargo test -p phase-ai --lib noncreature_ward_target_scores_lower` (1 passed), `cargo clippy -p phase-ai --lib -- -D warnings`, `cargo fmt --all` — all clean.

The Serpent Society's Ward reads "Ward—Get five poison counters." No
WardCost variant existed for "give yourself N counters", so the parser
fell through to mana-cost parsing, found no mana symbols, and silently
produced WardCost::Mana(generic: 0) — a free, always-paid Ward that
never gave the targeting opponent any poison counters (issue phase-rs#6640).

Add WardCost::GetPlayerCounters { counter_kind, count } (parameterized
over PlayerCounterKind rather than poison-only, so a future Ward cost of
a different counter kind reuses the same shape) and a matching
AbilityCost::GetPlayerCounters variant. Wire it through:

- ward_cost_to_ability_cost (triggers.rs)
- supported_at_resolution + can_pay_resolution + pay_ability_cost_inner
  (costs.rs) — the resolution-time payment authority; pay_ability_cost_inner
  routes through the existing add_player_counter_with_replacement so
  "can't get counters" replacement effects still apply
- handle_unless_payment (engine_payment_choices.rs) — the unless-pay
  round trip, unchanged in shape otherwise
- parse_ward_cost_single (oracle_keyword.rs) — new nom-combinator branch
  for "get N <kind> counter(s)", reusing the same parse_number/
  parse_player_counter_kind combinators the GivePlayerCounter effect
  parser already uses, inserted before the mana-cost fallback that
  caused the silent misparse

Every other exhaustive AbilityCost match in the crate (ability_graph.rs,
ability_scan.rs, cost_payability.rs, printed_cards.rs, replacement.rs,
oracle_effect/lower.rs, plus four helper methods on AbilityCost itself)
now has an explicit arm, found and fixed via the compiler's own
exhaustiveness checking rather than manual auditing.

Also fixes the frontend Ward-payment prompt, which would otherwise show
a useless generic "Pay a cost" label for this new cost shape.

Closes phase-rs#6640
CI on phase-rs#6662 caught two gaps my local `cargo check -p engine --lib` never
touched:

- crates/phase-ai has its own exhaustive matches directly on WardCost
  (not AbilityCost) for AI tactical scoring: the Ward-cost "severity"
  pricing in anti_self_harm.rs (both the top-level match and its
  duplicated inline copy inside the Compound arm) and can_pay_ward_cost's
  affordability check in strategy_helpers.rs. Add GetPlayerCounters arms
  to all three: severity scales uncapped with count (voluntarily taking
  poison counters is a real, severe cost, unlike the capped mana/life
  costs), and affordability is unconditionally true (no resource limit
  on giving yourself more counters, mirroring the engine's own
  can_pay_resolution).

- The i18n "locale key parity" test requires es/fr/de/it/pt/pl to have
  the exact same key set as en, not just en as I'd assumed. Add
  cost.playerCounters_one/_other and a cost.playerCounterKind noun map
  to all six locales with real translations (not English placeholders),
  matching the badges section's existing poison/experience/rad counter
  naming conventions. Also redesigned formatUnlessCost's GetPlayerCounters
  case to look the counter-kind noun up through i18n rather than
  interpolating the raw English enum name, so non-English locales render
  correctly.
…frontend type

Addresses matthewevans's review on phase-rs#6662:

- Third crate with an exhaustive AbilityCost match my earlier per-crate
  checks missed: crates/mtgish-import/src/convert/action.rs's
  rewrite_bound_x_in_ability_cost (no-op arm — count is a fixed u32, not
  an X-bindable QuantityExpr).
- crates/engine/src/game/costs.rs's exhaustive "lockstep gate" test
  (sample_for + all_variants) only exercised under --tests, which this
  sandbox can't compile, so cargo check --lib alone never caught it.
  Added a real arm so GetPlayerCounters is actually exercised by
  every_ability_cost_variant_has_resolution_support_answer, not just
  made to compile.

- AI severity scoring (anti_self_harm.rs) is now lethality- and
  kind-aware instead of a flat count*3.0: a poison payment that would
  push the AI to LETHAL_POISON (10, per features::poison) scores
  prohibitively (100.0, matching this file's own "never do this"
  sentinel) rather than an ordinary linear severity; non-poison player
  counters (experience/rad/ticket) carry no loss-condition risk and
  score 0. Fixed the Compound arm's sum-then-min(4.0) cap, which would
  otherwise silently clamp a lethal poison sub-cost back down to an
  ordinary-looking severity.

- Parser (oracle_keyword.rs): counter-shaped "get ... counter(s)" text
  that fails to parse (unparseable count, unknown counter kind) now
  fails closed (returns None) instead of falling through to the
  mana-cost fallback — the same silent-free-Ward bug class phase-rs#6640 was
  about, for different malformed input. Added negative tests for both
  failure shapes.

- Frontend (GamePage.tsx): formatUnlessCost's GetPlayerCounters case is
  now a real discriminated union member with required, exact-cased
  fields (count: number, counter_kind: "Poison"|"Experience"|"Rad"|
  "Ticket") — no more toLowerCase()/fallback-default reinterpretation of
  engine data in the display layer. Renamed the playerCounterKind i18n
  keys in all 7 locales to match the engine's exact PascalCase strings.

- New integration test: a Ward payment that pushes the payer to ten
  poison counters triggers the CR 104.3d loss SBA immediately (before
  the targeted destroy spell can resolve), mirroring sba.rs's own
  sba_poison_10_player_loses unit test shape.

Verified with cargo check --lib on all three affected crates (engine,
phase-ai, mtgish-import) — all clean. Full cargo test still blocked by
this sandbox's memory ceiling (disclosed previously on this PR); the
new/changed test code was hand-verified against the real APIs it calls.
…wing

Two real compile/type errors caught by CI on the previous push:

- crates/engine/tests/integration/serpent_society_ward_poison_cost.rs:
  `WaitingFor::GameOver { winner: Some(p) if p == P0 }` nests the guard
  inside the `Some(p)` sub-pattern, which is the unstable `guard_patterns`
  feature (rust-lang/rust#129967), not a stable match-arm guard. Moved
  the guard to the whole-pattern position:
  `WaitingFor::GameOver { winner: Some(p) } if p == P0` — standard,
  stable Rust. Verified in isolation with a standalone rustc invocation
  (this sandbox can't compile the full integration test binary).

- client/src/pages/GamePage.tsx: `switch (cost.type)` / a `cost.type ===
  "GetPlayerCounters"` check can't actually narrow the discriminated
  union to the `GetPlayerCounters` member, because the sibling member's
  `type: string` is too wide for literal-comparison narrowing to
  exclude it — `cost.counter_kind` doesn't type-check inside that
  branch. Switched to a `"counter_kind" in cost` property-presence check
  before the switch, which narrows correctly since the sibling member
  doesn't declare that property at all.

Verified locally this time (pnpm/tsc/vitest are available in this
sandbox, unlike full Rust test codegen): `tsc -b --noEmit` clean,
`eslint` clean (pre-existing warning only, unrelated line), and the
full `vitest run` suite green (270 files / 2366 tests passed).
… Ward correctly

- can_pay_ward_cost now aggregates poison across the whole Compound cost
  tree before checking lethality once, instead of checking each sub-cost
  against the same starting total (missed jointly-lethal combinations).
- anti_self_harm.rs gives Rad, Experience, and Ticket counters explicit
  typed severity valuations instead of a zero-cost wildcard fallback.
- The Ward severity-pricing block now runs for every permanent type, not
  just creatures, so an affordable Ward tax on a noncreature target is
  priced by the AI's judgment layer too.
- oracle_keyword.rs's "get N <kind> counter(s)" ward-cost parsing is now
  fully composed from nom combinators instead of ad hoc strip_suffix
  dispatch, and fails closed (no fallback to a free Mana Ward) on
  malformed count/kind text.
- GamePage.tsx maps the engine's player-counter kind to its i18n key via
  an exhaustive switch instead of interpolating the raw engine value into
  the key template, so a future counter kind fails to compile here rather
  than silently missing a translation.

Addresses matthewevans's review on phase-rs#6640.
The prior fix moved Ward severity pricing out of the creature-only
branch so it runs for every permanent type, but nothing exercised the
production target scorer directly for a noncreature case -- only
tactical_gate's accept/reject GateDecision tests covered it. Adds a
regression asserting an unwarded artifact scores higher (a more
attractive target) than an otherwise-identical artifact with a small,
payable, nonlethal poison-counter Ward.
@matthewevans matthewevans self-assigned this Jul 26, 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 current head has one compile error and one replacement-safety gap in lethal poison Ward scoring.

🔴 Blocker

[HIGH] The new PendingTrigger fixture supplies an unboxed ability where the current type requires Box<ResolvedAbility>. Evidence: crates/phase-ai/src/policies/anti_self_harm.rs:3492 constructs PendingTrigger, and :3496 passes bare ResolvedAbility::new(...); current Rust lint and test shard 2 report E0308. Why it matters: the PR cannot compile. Suggested fix: wrap the ResolvedAbility::new(...) value in Box::new(...) at the fixture construction site.

🟡 Non-blocking

[MED] Lethal poison Ward scoring uses the nominal counter total rather than the replacement-aware applied amount. Evidence: crates/phase-ai/src/policies/strategy_helpers.rs:324-329 compares current + total_poison_from_ward_cost, while crates/engine/src/game/costs.rs:1336-1346 pays GetPlayerCounters through add_player_counter_with_replacement. Why it matters: an amount-changing player-counter replacement can make a Ward payment lethal after the AI accepts it. Suggested fix: project the replacement-aware poison result before scoring, or conservatively reject when it cannot be projected; add a doubled-poison boundary regression.

Recommendation: request changes — repair the boxed fixture and make lethal-poison affordability replacement-aware before requesting another review.

@matthewevans matthewevans removed their assignment Jul 26, 2026
…am rebase

Rebased onto upstream/main, which boxed GameState.pending_trigger and
PendingTrigger.ability since this branch was created. The new
noncreature Ward scoring test still constructed both unboxed.
@hurryup52
hurryup52 force-pushed the fix/6640-serpent-society-ward-poison-cost branch from 86c8d26 to 816f749 Compare July 26, 2026 22:46
@hurryup52

Copy link
Copy Markdown
Contributor Author

The CI failures on the last push (`Rust lint`, `Rust tests shard 1/2`, `shard 2/2` on run 30222057052) were all a single root cause, not separate issues: this branch was 20 commits behind `upstream/main`. Since this branch was created, main boxed `GameState.pending_trigger` (`Option<Box>`) and `PendingTrigger.ability` (`Box`) as part of unrelated upstream work (workspace bumped to v0.38.0). The noncreature Ward scoring test added last round still constructed both unboxed, which failed to compile — and since every one of those CI jobs builds `phase-ai`'s test binary, the single compile error surfaced as multiple red checks.

Fixed in `816f749eb`: rebased onto `upstream/main` and wrapped both construction sites in `Box::new(...)`.

Verified against the rebased code: `cargo check -p phase-ai --lib` clean, `cargo test -p phase-ai --lib noncreature_ward_target_scores_lower` (1 passed), `cargo clippy -p phase-ai --lib -- -D warnings` clean, `cargo fmt --all` clean (no changes).

@matthewevans matthewevans self-assigned this Jul 26, 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 earlier fixture boxing issue is fixed, but two counter-cost paths remain rules-incorrect.

🔴 Blockers

[HIGH] Ward poison affordability uses the printed count instead of the replacement-adjusted result. crates/phase-ai/src/policies/strategy_helpers.rs:324-329 gates lethal poison with total_poison_from_ward_cost, while the cost path at crates/engine/src/game/costs.rs:1335-1344 applies counters through the replacement-aware helper. A doubling or +N replacement can make the AI accept a lethal Ward payment. Project the result side-effect-free through replacement handling, or conservatively decline when it cannot be projected; add a doubled/+N boundary regression test.

[HIGH] A prevented player-counter payment is currently accepted as paid. crates/engine/src/game/effects/player_counter.rs:36-58 returns true for ReplacementResult::Prevented, and crates/engine/src/game/costs.rs:1911-1914 makes GetPlayerCounters unconditionally payable. The parser already models “Players can't get counters” as counter prevention in oracle_replacement.rs:9145-9163, so a Ward counter cost can be paid for free under that effect. Model a distinct payment result (or map prevention to a failed cost) and cover Solemnity plus The Serpent Society.

The current CI/parse artifacts are still incomplete, but these blockers are independently reproducible from the current source and do not depend on them.

…paid

Two rules-correctness bugs in the Ward player-counter cost path:

- can_pay_ward_cost trusted WardCost::GetPlayerCounters's printed count
  when checking poison lethality, while actual payment routes counter
  additions through the replacement pipeline. A doubler or +N effect on
  the payer could make a lethal payment look safe. Added
  preview_player_counter_addition (mirrors the existing object-counter
  preview_counter_addition) to project the real, replacement-adjusted
  result side-effect-free; total_poison_from_ward_cost now uses it and
  conservatively declines when the outcome can't be cleanly projected.

- add_player_counter_with_replacement returned true for a replacement-
  prevented addition (e.g. Solemnity's "players can't get counters"),
  identical to a genuinely applied one. Effect-resolution callers
  correctly treat prevented and applied the same way (nothing more to
  wait for), but the GetPlayerCounters cost arm in costs.rs used that
  same true to mean "paid" -- so a Ward's poison-counter cost could be
  paid for free under a counter-prevention effect, bypassing Ward
  entirely. The function now returns a PlayerCounterAdditionOutcome
  (Applied/Prevented/NeedsChoice); the cost arm maps Prevented to a
  failed payment so Ward correctly counters the spell instead.

Adds a doubling-replacement boundary test (tactical_gate.rs) and a
Solemnity + Serpent Society integration test asserting the payment
fails and the targeting spell is countered.
@hurryup52

Copy link
Copy Markdown
Contributor Author

@matthewevans — pushed afb85f4 addressing both HIGH blockers.

1. Affordability used the printed count instead of the replacement-adjusted result.

Added preview_player_counter_addition in crates/engine/src/game/effects/player_counter.rs — mirrors the existing object-counter preview_counter_addition (used by phase-ai's self-counter-cost preview), running the proposed counter addition through the real replacement pipeline on an isolated state clone (no side effects), returning Applied{count} / Prevented / ChoiceRequired{player} / Transformed{count} / Unsupported.

total_poison_from_ward_cost (strategy_helpers.rs) now projects each GetPlayerCounters{Poison,..} sub-cost through this preview instead of trusting the printed count, and returns Option<u32>: Applied/Transformed give the real adjusted count, Prevented is genuinely 0 (safe, not just unproven), and ChoiceRequired/Unsupported return Nonecan_pay_ward_cost treats None as "can't prove this is safe" and conservatively declines, per your suggested fallback. Added rejects_targeting_ward_with_lethal_poison_after_doubling_replacement (tactical_gate.rs): a permanent the AI controls doubles poison it would receive; printed-count math (4 existing + 3 printed = 7) would have wrongly called this safe, but the doubled result (4 + 6 = 10) is lethal — asserts Reject. Passed locally (cargo test -p phase-ai --lib).

2. A prevented player-counter payment was accepted as paid.

add_player_counter_with_replacement returned true for ReplacementResult::Prevented, identical to a genuinely applied addition. That's actually correct for its 4 effect-resolution callers (deal_damage's infect/toxic, proliferate, the pending-counter-addition drain in counters.rs) — a prevented effect still means "nothing more to wait for," so I kept that behavior for those, now via an explicit PlayerCounterAdditionOutcome enum (Applied/Prevented/NeedsChoice) instead of a bare bool. But costs.rs's GetPlayerCounters cost arm was using that same true to mean "the cost was paid" — so Solemnity (or any "players can't get counters" effect) let a Ward poison-counter cost be paid for free, since the caster never actually gave up anything. That arm now matches on the outcome explicitly: Prevented maps to Ok(payment_failed(...)), a genuine failed payment — handle_unless_payment's existing GetPlayerCounters arm (from an earlier round) already correctly treats Failed as "the unless-cost wasn't paid," so Ward now correctly counters the spell instead of giving a free bypass.

Added serpent_society_ward_payment_prevented_by_solemnity_counters_the_spell in serpent_society_ward_poison_cost.rs: Solemnity ("Players can't get counters." — verified against its real Oracle text) + Serpent Society, opponent attempts to pay the Ward cost, asserts poison stays 0 and the targeting spell gets countered (Serpent Society survives), mirroring the existing declined-payment test's assertions.

Verification this round: cargo check/cargo clippy -p engine --lib -- -D warnings and -p phase-ai --lib -- -D warnings both clean, cargo fmt --all clean, cargo test -p phase-ai --lib passing including the new doubling test. I could not locally compile-verify the new engine integration test (serpent_society_ward_poison_cost.rs) — this workspace's engine crate integration-test binary reliably OOMs in my sandbox regardless of job/codegen flags, a limitation I've disclosed each round this PR. It closely mirrors the file's 4 existing sibling tests (same scenario/cast/act pattern), and I'll rely on CI's Rust tests shards for confirmation.

@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 engine now rejects a prevented player-counter payment, but the AI still treats it as a free safe Ward cost.

🔴 Blocker

[HIGH] A prevented counter preview becomes zero poison and leaves the Ward cost payable. crates/phase-ai/src/policies/strategy_helpers.rs:324-328 maps PlayerCounterAdditionPreview::Prevented to Some(0), while :411-415 continues to mark GetPlayerCounters payable. The payment path now correctly fails a prevented counter addition in crates/engine/src/game/costs.rs:1345-1358; the AI can therefore target into Solemnity/player-counter prevention, receive the optimistic Ward evaluation, and have its spell countered.

Treat prevented or unprojectable player-counter preview as unpayable recursively, and add a tactical-gate regression for the prevention case.

🟡 Evidence requested

The sticky parse-diff artifact predates this head even though parser files changed. Please refresh it against this commit so the production impact is attributable to the submitted source.

@matthewevans matthewevans removed their assignment Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants