feat(engine): roll-a-d20-for-each-player-keep-highest attack mechanic (Iron Mastiff #5928)#6228
Conversation
… (Iron Mastiff phase-rs#5928) Iron Mastiff: "Whenever this creature attacks, roll a d20 for each player being attacked and ignore all but the highest roll. / 1—9 | ... deals damage equal to its power to you. / 10—19 | ... to defending player. / 20 | ... to each opponent." The Attacks trigger parsed but its effect was `Unimplemented("roll", "roll a d20 for each player being attacked and ignore all but the highest roll")`, and the three outcome rows landed as detached abilities — the parser deliberately bailed on any roll suffix other than "and add/subtract X", so `try_parse_die_roll_table` never attached the table. New mechanic on the existing `Effect::RollDie` result-table infrastructure: - Types: `DieRollAggregate { EachIndependently, Highest }` + a `keep` field on `Effect::RollDie` (serde default `EachIndependently`, skip-if-default so all existing d20 card-data stays byte-identical and every existing construction site keeps today's per-die semantics). New enum classified in ability_scan / ability_rw (axis-free result-consumption aggregate). - Resolver (roll_die.rs): for `Highest`, roll all `count` dice — each still emits its own `DieRolled` event and applies the modifier — then consult the result table EXACTLY ONCE for the maximum actual result (CR 706.6: the ignored rolls are treated as never having happened). `EachIndependently` (every existing d20 card) is unchanged: the table is consulted per die. - Parser: a nom combinator recognizes "roll a d<N> for each player being attacked and ignore all but the highest roll" so the ranged outcome table attaches, lowering to `RollDie { sides: 20, keep: Highest, count: PlayerCount { OpponentAttacked { subject: Source, scope: ThisCombat } } }` — the count is the players THIS creature is attacking this combat (CR 508.6), so a creature forced to attack multiple players honestly rolls N dice, not a hardcoded 1. Zero Unimplemented. General building block: covers the "roll a d20 for each player attacked → outcome table, keep highest" dice-attack class, not just Iron Mastiff. The existing per-die d20 cards (Ancient Dragons, Deck of Many Things, Attraction rolls) are untouched. CR annotations (706.3a, 706.6, 508.6, 119.3) grep-verified — note the keep-highest rule is CR 706.6 ("if a player is instructed to ignore a roll"), not the nonexistent 706.4b. Tests (integration, real combat + seeded-roll pipeline; each revert-probed): - parser shape: RollDie{sides:20, keep:Highest, 3 branches 1-9/10-19/20}, no detached abilities, each branch is damage-bearing not Unimplemented; - low roll (1—9) → power to the controller; mid (10—19) → to the defending player; max (20) → to each opponent (each asserts the correct player's life dropped and the others' did not); - keep-highest unit test: two distinct dice + a max-only branch → the table is consulted ONCE for the max (reverting to per-die makes it fire twice); - Ancient Brass/Copper/Bronze Dragon + Vexing Puzzlebox + Bucknard's regressions stay green. Full lib: 17300 passed. Integration: 3589 passed. Clippy clean. Fixes phase-rs#5928. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
🚨 Contributor flagged. Click here for more info: Superagent Dashboard |
There was a problem hiding this comment.
Code Review
This pull request implements support for the "keep-highest" die-rolling aggregation mechanism (CR 706.6), specifically addressing the parsing and resolution of abilities like Iron Mastiff's trigger ("roll a d20 for each player being attacked and ignore all but the highest roll"). It introduces the DieRollAggregate enum, updates the RollDie effect to handle both independent and keep-highest resolution, and adds comprehensive unit and integration tests. Feedback on the changes highlights a potential parsing bug in try_parse_roll_keep_highest_for_each where trimming trailing whitespace after punctuation matches is performed in the wrong order, which could cause valid inputs with trailing spaces to fail parsing.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if !tail.trim_end_matches(['.', ',', ';']).trim().is_empty() { | ||
| return None; | ||
| } |
There was a problem hiding this comment.
Trimming the tail after calling trim_end_matches can cause parsing to fail if there is any trailing whitespace after the punctuation (e.g., ". " or ". "). Since trim_end_matches only strips from the very end of the string, the trailing whitespace prevents it from matching the punctuation, leaving the punctuation intact and causing the subsequent .trim().is_empty() check to return false (and thus returning None). Trimming the whitespace first resolves this robustly.
| if !tail.trim_end_matches(['.', ',', ';']).trim().is_empty() { | |
| return None; | |
| } | |
| if !tail.trim().trim_end_matches(['.', ',', ';']).is_empty() { | |
| return None; | |
| } |
matthewevans
left a comment
There was a problem hiding this comment.
Blocked: the keep-highest path exposes ignored dice as real DieRolled events.
🔴 Blocker
[HIGH] resolve calls roll_die for every die (crates/engine/src/game/effects/roll_die.rs:65-69), and roll_die immediately pushes GameEvent::DieRolled (:16-29). The later Highest branch only suppresses result-table lookup (:114-122), so a multiplayer Iron Mastiff attack still triggers every “whenever you roll a die” ability for dice that were instructed to be ignored. CR 706.6 says an ignored roll is considered never to have happened and that no abilities trigger because of it. The new unit test codifies the opposite expectation by asserting two DieRolled events (:1147-1153); the integration fixture is two-player only (iron_mastiff_roll_d20_attack_5928.rs:45-100), so it never covers this path.
Please sample all dice without publishing roll events, then publish/state-stamp only the kept highest result for DieRollAggregate::Highest (while retaining the existing per-die behavior). Add a 3-player/multiple-defender regression that asserts exactly one numeric d20 roll event and that only the kept roll can trigger a die-roll witness.
✅ Clean
The reported trailing-whitespace parser concern does not reproduce through the public parser path: parse_effect_clause_inner normalizes with trim() before it reaches this recognizer (crates/engine/src/parser/oracle_effect/mod.rs:7316-7320). The Oracle text matches Scryfall’s current Iron Mastiff record.
Recommendation: request changes before enqueueing.
Parse changes introduced by this PR · 1 card(s), 3 signature(s) (baseline: main
|
Fixes #5928 (claimed there). Verified Iron Mastiff's Oracle text on Scryfall and reproduced the gap via a parse probe before building.
The feature
Iron Mastiff: "Whenever this creature attacks, roll a d20 for each player being attacked and ignore all but the highest roll. / 1—9 | ... deals damage equal to its power to you. / 10—19 | ... to defending player. / 20 | ... to each opponent." The
Attackstrigger parsed, but its effect wasUnimplemented("roll", "roll a d20 for each player being attacked and ignore all but the highest roll")and the three outcome rows landed as detached abilities — the roll-header parser deliberately bailed on any suffix other than "and add/subtract X", sotry_parse_die_roll_tablenever attached the table.Design (on the existing
Effect::RollDieresult-table infra)DieRollAggregate { EachIndependently, Highest }+ akeepfield onEffect::RollDie(serde defaultEachIndependently, skip-if-default so all existing d20 card-data stays byte-identical and every construction site keeps today's per-die semantics). add-engine-variant gate: Stage 1 DOES_NOT_EXIST, Stage 2 EXTEND_OK (leaf axis on result-lookup), Stage 3 WITHIN_SECTION (CR 706).Highest, roll allcountdice (each still emitsDieRolled+ applies the modifier), then consult the result table exactly once for the maximum actual result — CR 706.6 ("the ignored rolls are treated as never having happened").EachIndependently(every existing d20 card) is unchanged.RollDie { sides: 20, keep: Highest, count: PlayerCount { OpponentAttacked { subject: Source, scope: ThisCombat } } }. The count is the players this creature is attacking this combat (CR 508.6) — so a creature forced to attack multiple players honestly rolls N dice, not a hardcoded 1. Zero Unimplemented.General building block — covers the "roll a d20 for each player attacked → outcome table, keep highest" dice-attack class, not just Iron Mastiff.
A rule correction worth noting
The plan I worked from cited "CR 706.4b" for keep-highest — that subrule doesn't exist in the CR. The correct rule is CR 706.6 ("If a player is instructed to ignore a roll, that roll is considered to have never happened..."), grep-verified and used throughout.
Tests (integration, real combat + seeded-roll pipeline; each revert-probed)
RollDie{sides:20, keep:Highest}with 3 branches (1-9 / 10-19 / 20), no detached abilities, each branch damage-bearing (not Unimplemented).Verification
Full engine lib: 17300 passed, 0 failed. Integration: 3589 passed, 0 failed. Clippy
-D warningsclean; fmt clean; parser combinator gate PASS. Card-data regenerates Iron Mastiff with the correct RollDie shape and zero Unimplemented; the diff is feature-only (unrelated token-data regeneration churn reverted).🤖 Generated with Claude Code