fix(parser): enforce the "if it didn't have decayed" intervening-if on dies triggers (Wilhelt, the Rotcleaver)#6333
Conversation
…n dies triggers (Wilhelt, the Rotcleaver)
The past-tense keyword-possession intervening-if ("if it (had | didn't
have | did not have) <keyword>") was silently dropped at parse time —
extract_if_condition_with_card_name had no production for it, so the
clause fell through to ClauseAst::Conditional { condition: None } and
Wilhelt minted 2/2 Zombie tokens for decayed deaths, including its own
tokens (CR 603.4 gate never evaluated).
Adds one nom production building
Not(ZoneChangeObjectMatchesFilter { Battlefield -> Graveyard,
creature + HasKeywordKind }) — the exact shape of the sibling
P/T-comparison and was/wasn't arms — wired as a new first arm of
parse_zone_change_object_filter_predicate. The runtime path pre-existed
end-to-end (record-snapshot keyword match per CR 603.10a look-back;
layer-computed keywords, so continuous grants count). Landwalk kinds are
rejected at the guard (CR 702.14a: each [type]walk is distinct; the
parameter folds to a single KeywordKind) so those forms stay honestly
swallowed, as do non-keyword clauses.
Closes phase-rs#5937
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughThe parser recognizes past-tense keyword possession in dies-trigger zone-change filters, rejects unsupported keyword kinds, and centralizes dies look-back construction. Parser and Wilhelt integration tests cover intervening-if parsing, decayed-token replacement, state-based deaths, layer-derived keywords, and trigger scope. ChangesOracle trigger and Wilhelt regression
Estimated code review effort: 4 (Complex) | ~45 minutes Suggested labels: Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant OracleText
participant DiesTriggerParser
participant KeywordPossessionParser
participant TriggerCondition
OracleText->>DiesTriggerParser: provide dies trigger with had or did not have clause
DiesTriggerParser->>KeywordPossessionParser: parse keyword possession under battlefield-to-graveyard gate
KeywordPossessionParser->>TriggerCondition: construct positive or negated dies look-back condition
TriggerCondition-->>DiesTriggerParser: return intervening-if condition
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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/engine/src/parser/oracle_trigger.rs`:
- Around line 6002-6021: Extract the repeated dies-lookback construction into a
shared helper near the existing trigger-condition parsing logic, including the
Battlefield-to-Graveyard Typed creature filter and the negated/non-negated
wrapping. Update this block and the was/wasn’t axis (and the documented
P/T-comparison arm) to call the helper, preserving their current parsing results
and leaving future non-dies handling able to derive zones from trigger context.
🪄 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: 75c88035-a019-4fdf-b63d-a7ed3bb5de76
📒 Files selected for processing (4)
crates/engine/src/parser/oracle_trigger.rscrates/engine/src/parser/oracle_trigger_tests.rscrates/engine/tests/integration/main.rscrates/engine/tests/integration/wilhelt_decayed_intervening_if_5937.rs
Parse changes introduced by this PR · 1 card(s), 1 signature(s) (baseline: main
|
matthewevans
left a comment
There was a problem hiding this comment.
Blocked — the generalized keyword-lookback grammar is unsound outside dies triggers.
🔴 Blocker
crates/engine/src/parser/oracle_trigger.rs:5378-5381 routes every trigger through the new had/didn't have <keyword> extraction. That production builds dies_lookback_condition at :5911-6011, which pins Battlefield → Graveyard at :5952-5965. Runtime evaluation compares those zones against the actual event in crates/engine/src/game/triggers.rs:8380-8393; for a non-dies event, the inner predicate is false and the negation in “didn't have” becomes true, making the gate fail open.
Thread or derive the trigger's actual zone-change context before creating the filter, or restrict this grammar to proven dies shapes. Add an entering/non-dies negative regression so unsupported forms remain honestly swallowed until generalized.
✅ Clean
The prior helper-extraction feedback is addressed by the current dies_lookback_condition reuse; the current parse diff is limited to Wilhelt.
Recommendation: request changes for correct zone-context binding and a non-dies regression.
… trigger heads Per review: the had/didn't-have <keyword> production was reachable from every trigger's intervening-if extraction, and on a non-dies head the Not-wrapped form failed open (zone mismatch → inner false → Not → true). The trigger head's zone-change shape is now proven before the arm can bind: trigger_head_dies_zone_change() parses the head's final verb phrase through the shared parse_dies_verb_phrase combinator (extracted from parse_zone_change_clause, so prover and event constructor recognize the identical verb set — CR 700.4) and threads Option<(Zone, Zone)> down to parse_zone_change_object_filter_predicate; the keyword-possession arm binds only under a proven (Battlefield, Graveyard) pair. Qualified heads fail conservatively to the swallow diagnostic, never open. Adds the demanded non-dies negative regressions: the same clause on an ETB head and on an LTB head stays honestly swallowed (condition None + Condition_If diagnostic), both red before this gate with the exact fail-open condition bound. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks for the review — the blocker is fixed on head The gate. The demanded regressions. Two new rows mirror the existing swallow tests: the same clause on an ETB head ( Scope note. Only the keyword-possession arm is gated. The sibling was/wasn't and P/T-comparison arms share the same hardcoded zone pair (pre-existing, fail-closed for their positive forms) and can adopt the threaded context in a follow-up — noted in the arm's doc comment, left untouched here to keep the parse diff Wilhelt-only. Verification on Model: claude-fable-5 |
matthewevans
left a comment
There was a problem hiding this comment.
Approved — the zone-context blocker is resolved on 112cb486b1b2020d642068c9b58280f778140cfb.
trigger_head_dies_zone_change now proves the battlefield-to-graveyard head before the keyword-lookback arm can construct its negated condition; the ETB and LTB regressions keep unsupported non-dies forms honestly swallowed. The current parse-diff artifact is Wilhelt-only, matching the claimed scope, and the runtime integration path is discriminating.
…n dies triggers (Wilhelt, the Rotcleaver) (phase-rs#6333) * fix(parser): enforce the "if it didn't have decayed" intervening-if on dies triggers (Wilhelt, the Rotcleaver) The past-tense keyword-possession intervening-if ("if it (had | didn't have | did not have) <keyword>") was silently dropped at parse time — extract_if_condition_with_card_name had no production for it, so the clause fell through to ClauseAst::Conditional { condition: None } and Wilhelt minted 2/2 Zombie tokens for decayed deaths, including its own tokens (CR 603.4 gate never evaluated). Adds one nom production building Not(ZoneChangeObjectMatchesFilter { Battlefield -> Graveyard, creature + HasKeywordKind }) — the exact shape of the sibling P/T-comparison and was/wasn't arms — wired as a new first arm of parse_zone_change_object_filter_predicate. The runtime path pre-existed end-to-end (record-snapshot keyword match per CR 603.10a look-back; layer-computed keywords, so continuous grants count). Landwalk kinds are rejected at the guard (CR 702.14a: each [type]walk is distinct; the parameter folds to a single KeywordKind) so those forms stay honestly swallowed, as do non-keyword clauses. Closes phase-rs#5937 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(PR-6333): share dies lookback condition builder * fix(PR-6333): gate the keyword-lookback grammar on proven dies-shaped trigger heads Per review: the had/didn't-have <keyword> production was reachable from every trigger's intervening-if extraction, and on a non-dies head the Not-wrapped form failed open (zone mismatch → inner false → Not → true). The trigger head's zone-change shape is now proven before the arm can bind: trigger_head_dies_zone_change() parses the head's final verb phrase through the shared parse_dies_verb_phrase combinator (extracted from parse_zone_change_clause, so prover and event constructor recognize the identical verb set — CR 700.4) and threads Option<(Zone, Zone)> down to parse_zone_change_object_filter_predicate; the keyword-possession arm binds only under a proven (Battlefield, Graveyard) pair. Qualified heads fail conservatively to the swallow diagnostic, never open. Adds the demanded non-dies negative regressions: the same clause on an ETB head and on an LTB head stays honestly swallowed (condition None + Condition_If diagnostic), both red before this gate with the exact fail-open condition bound. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: michiot05 <281539540+michiot05@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: matthewevans <matthewevans@users.noreply.github.com> Co-authored-by: Matt Evans <1388610+matthewevans@users.noreply.github.com>
Tier: Frontier
Model: claude-fable-5
Thinking: high
Closes #5937.
Summary
Wilhelt, the Rotcleaver — "Whenever another Zombie you control dies, if it didn't have decayed, create a 2/2 black Zombie creature token with decayed." Discord report: Wilhelt mints tokens for the deaths of Zombies that did have decayed — including his own decayed tokens, creating a value loop the card is explicitly designed to prevent.
Root cause: the past-tense keyword-possession intervening-if has no parser production.
extract_if_condition_with_card_namerecognizeswas/wasn'tzone-change predicates and counter forms, but "if it didn't have decayed" falls through toparse_clause_ast, which strips the conditional head and returnsClauseAst::Conditional { condition: None }— the guard is discarded and the token effect parses unconditionally. The swallow detector correctly flags it (Condition_If, so coverage stays red), but the runtime AST plays without the CR 603.4 gate.Class-level fix, not a Wilhelt special case: one nom production,
parse_zone_change_object_keyword_possession, covering the full tense × polarity × keyword-registry cross-product —if it (had | didn't have | did not have) <keyword>— lowering to the existingNot(ZoneChangeObjectMatchesFilter { Battlefield → Graveyard, creature + HasKeywordKind })shape, wired as a new first arm ofparse_zone_change_object_filter_predicatebeside the existing was/wasn't and P/T-comparison arms. Zero engine changes: the runtime path pre-exists end-to-end (fire-time gate + CR 603.4 resolution re-check both read the immutableZoneChangeRecordsnapshot, which captures layer-computed keywords per CR 603.10a look-back — so continuous grants like "gains decayed until end of turn" count, verified by a hostile test). Guard rejectsKeywordKind::Unknown(the shared-kind alt-cost families) andKeywordKind::Landwalk(the land-type parameter folds to a single kind; a kind-level gate would over-match across land types per CR 702.14a) — those forms stay honestly swallowed with theCondition_Ifdiagnostic intact.Implementation method (required)
Method: /engine-implementer — plan → /review-engine-plan (1 round; 3 findings integrated: dies-shape fail-open caveat, KeywordKind-level guard, overlap note) → implement → /review-impl (2 rounds: round 1 returned one MED finding — the landwalk kind-fold over-match — fixed with a guard extension + honest-swallow test; round 2 clean) → commit, each step in a fresh agent context. Final read-only /review-impl against the committed head: PASS head=4f8c6171e50f04c0a885af90248343a57b3a2f5b (seam + idiom checks explicitly confirmed).
Gate A
./scripts/check-parser-combinators.sh→ Gate A PASS head=4f8c6171e50f04c0a885af90248343a57b3a2f5b base=094d4e5630de0659ee3d62b959cb851b12c3e632 (Gate G PASS as well). The new production is pure nom (alt/value/tag+ the sharedparse_keyword_nameprimitive); no string probes anywhere in the diff.Anchored on
crates/engine/src/parser/oracle_trigger.rs:5782—parse_dying_object_pt_comparison_condition(Massacre Girl, Known Killer): the identical dies-trigger intervening-if shape (ZoneChangeObjectMatchesFilter, Battlefield → Graveyard, LKI record evaluation) this arm mirrors, including theNotnegation idiom of the adjacent was/wasn't axis.crates/engine/src/parser/oracle_trigger.rs:5619—parse_event_object_has_alt_cost_keyword_intervening_if("if it has madness" →FilterProp::HasKeywordKind): the present-tense keyword-possession sibling; the new arm is its past-tense/LKI counterpart.crates/engine/src/game/filter.rs:5069— the pre-existingHasKeywordKindarm ofzone_change_record_matches_property: the runtime consumer, unchanged.Verification
cargo fmt --allclean;cargo clippy -p engine --tests0 warnings.crates/engine/tests/integration/wilhelt_decayed_intervening_if_5937.rs, registered inmain.rs) — 11 passed: parse-fidelity rows (typed condition present;Condition_Ifswallow cleared; token effect intact) + runtime rows through the real cast → death → trigger pipeline with Wilhelt's verbatim Oracle text: decayed death → no token (the reported bug), non-decayed death → exactly one token, the reported token-loop scenario, SBA deaths both ways (CR 704.5f), the hostile LKI row (decayed granted until end of turn, then dies → no token — proves the snapshot reads layer-computed keywords), and scope siblings (opponent's Zombie, Wilhelt itself).l02_bb4_intervening_ifsuite (23 passed), Massacre Girl suite (4 passed), fulloracle_triggerlib subset (1023 passed, 0 failed).FAILED. 5 passed; 6 failed—wilhelt_condition_is_not_had_decayedpanicked ("must carry an intervening-if condition"),wilhelt_decayed_death_creates_no_token/_token_death_does_not_loop/_sba_death_decayed/_granted_decayed_deathall failedleft: 1, right: 0(the live bug). After the fix: 11 passed, 0 failed.docs/MagicCompRules.txtbefore writing — CR 603.4 (:2592), 603.10a (:2638), 603.6c (:2604), 700.4 (:3234), 400.7 (:1950), 608.2h (:2806), 702.147a (:5090), 702.14a (:4015), 613.1f (:2970), 704.5f (:5502). One self-caught correction during the gate: 704.5g (lethal damage) → 704.5f (toughness ≤ 0) for the SBA fixture comment.Claimed parse impact
Regenerated
card-data.jsonand diffed against the pre-change baseline (same base commit): exactly 1 entry changed —wilhelt, the rotcleaver. ItsCondition_Ifswallow warning is removed and the dies trigger'scondition: nullbecomes the typedNot(ZoneChangeObjectMatchesFilter{...}). Corpus sweep confirms Wilhelt is the only current printing with this grammar (every other "if it had …" form is the counter/mana-value family, structurally rejected by the closed keyword table); the production nonetheless covers the whole tense × polarity × keyword class for future printings.Scope Expansion
None.
🤖 Generated with Claude Code
Summary by CodeRabbit
#5937runtime and snapshot/scoping scenarios.