fix(parser): preserve "who <look-back> this turn" target clause as non-duration (Admiral Beckett Brass #4735) - #5517
Conversation
…non-duration (Admiral Beckett Brass phase-rs#4735) Admiral Beckett Brass's end-step steal — "gain control of target nonland permanent controlled by a player who was dealt combat damage by three or more Pirates this turn" — wrongly parsed with duration: UntilEndOfTurn, so the control change expired at end of turn. A control-change with no stated duration is permanent (CR 611.2a). Root cause: strip_trailing_duration mis-stripped the trailing "this turn" from the target's look-back relative clause and stamped it as the effect's duration. The existing target_relative_clause_owns_suffix guard already prevents this for "that"-introduced object-property clauses, but not "who"-introduced player look-back clauses. Add its sibling, player_lookback_relative_clause_owns_suffix: a self-standing structural recognizer for "… who <look-back verb> … this turn" (CR 608.2i). It does NOT delegate to parse_that_clause_suffix (whose vocabulary is object-property clauses, not the "controlled by a player who <verb>" player shape). It fires only when the relative clause consumes through the trailing "this turn" to end-of-input, so a genuine OUTER duration after the clause ("… who lost life this turn until end of turn") still strips correctly. Scope note: the full target restriction ("controlled by a player dealt combat damage by 3+ Pirates") remains an over-broad coverage gap — the object→player controller-predicate filter and the ≥N-source count axis it would require each serve only this one card (verified via Scryfall), so per the build-for-the-class rule they are deliberately not added. The clause still parses to a valid GainControl (no Unimplemented); only the phantom duration — the actually-broken behavior — is fixed, for the whole "who <look-back> this turn" clause class. Tests: Beckett end-to-end (GainControl, duration None); guard preserves the who-look-back clause; hostile fixture (who-look-back + genuine trailing "until end of turn" still strips); the five existing quantity/relative-clause guard tests stay green. Full lib suite (15,978) + parser suite (2,564) green, clippy clean. CR 611.2a + CR 608.2i. Closes phase-rs#4735 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a new helper function player_lookback_relative_clause_owns_suffix to prevent the parser from incorrectly stripping "this turn" as an effect duration when it belongs to a player look-back relative clause (such as in Admiral Beckett Brass). Tests are added to verify this behavior and ensure genuine outer durations are still correctly parsed. The review feedback highlights a critical issue in the alt combinator ordering where a shorter prefix is matched before a longer one, which would prevent the longer phrase from ever being parsed.
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.
| let lookback_verb = alt(( | ||
| tag::<_, _, OracleError<'_>>("was dealt "), | ||
| tag("were dealt "), | ||
| tag("was dealt combat damage "), | ||
| tag("lost life "), | ||
| tag("gained life "), | ||
| tag("has lost life "), | ||
| tag("has gained life "), | ||
| tag("controls "), | ||
| )); |
There was a problem hiding this comment.
[HIGH] Incorrect alt combinator ordering can lead to parsing ambiguity. Evidence: crates/engine/src/parser/oracle_effect/lower.rs:5289.
nom::branch::alt tries parsers in order and returns the first success. Placing a shorter prefix like "was dealt " before a longer match like "was dealt combat damage " can lead to incorrect parsing and is brittle. This goes against the style guide's emphasis on idiomatic Rust (line 13).
To ensure correct and robust parsing, more specific matches should always precede their prefixes in an alt block.
| let lookback_verb = alt(( | |
| tag::<_, _, OracleError<'_>>("was dealt "), | |
| tag("were dealt "), | |
| tag("was dealt combat damage "), | |
| tag("lost life "), | |
| tag("gained life "), | |
| tag("has lost life "), | |
| tag("has gained life "), | |
| tag("controls "), | |
| )); | |
| let lookback_verb = alt(( | |
| tag::<_, _, OracleError<'_>>("was dealt combat damage "), | |
| tag("was dealt "), | |
| tag("were dealt "), | |
| tag("lost life "), | |
| tag("gained life "), | |
| tag("has lost life "), | |
| tag("has gained life "), | |
| tag("controls "), | |
| )); |
References
- The code should use library idioms to their fullest. In this case,
nom::branch::altrequires longer matches to be placed before shorter prefixes to avoid ambiguity. (link) - Avoid verbatim string equality for parsing Oracle phrases. Instead, decompose compound phrases into modular, reusable parsers and compose them using idiomatic combinator aggregates (like nested
altandtagsequences) to prevent combinatorial explosion and improve maintainability.
|
Reviewed — the architecture is sound and I'll approve+enqueue once CI is green (the branch is currently What's right here:
One non-blocking tidy-up (optional): in the Holding only on CI. |
matthewevans
left a comment
There was a problem hiding this comment.
Thanks for the drift-clear — I re-checked on the now-current head (branch is 0 commits behind main), and the coverage gate is still red. That's the discriminating result: the earlier failure was a real regression, not stale-baseline drift. Precise picture:
The duration guard itself is correct. player_lookback_relative_clause_owns_suffix rightly stops strip_trailing_duration from amputating " this turn" and stamping a phantom Duration::UntilEndOfTurn on a steal that's permanent (CR 611.2a — no stated duration). The hostile fixture (a genuine outer "until end of turn" still strips) proves the positional discipline. No objection to the guard.
But it surfaces an unmodeled target-filter clause on its own target card. The card-data coverage gate reports:
REGRESSED (coverage honesty) — 1 card:
Admiral Beckett Brass [Swallow:Duration_ThisTurn]
Before this PR, strip_trailing_duration swallowed " this turn" (as the wrong duration), so the parse completed and the card counted as supported-but-misparsed. Now that the guard correctly leaves " this turn" in place, nothing downstream consumes the "controlled by a player who was dealt combat damage by three or more Pirates this turn" look-back — the target parser has no filter for that player condition — so " this turn" is swallowed and the card drops supported → unsupported. The gate hard-blocks on that (coverage-regression-check.sh --fail-on-engine, exit 1).
Net: the PR improves correctness (no phantom duration) but regresses coverage, and the gate treats supported → unsupported as a block even when the prior "support" was a misparse.
To land it, the look-back target clause needs to be consumed so the card stays supported — i.e. model "player who was dealt combat damage by three or more Pirates this turn" as a target filter. The engine already tracks combat-damage-this-turn state in GameState (e.g. creature_types_dealt_combat_damage_this_turn), but there's no target-filter parser path for the "player who was dealt combat damage by ≥N of a subtype this turn" shape yet. Closing that keeps your duration fix and the card's coverage.
Happy to help scope the target-filter piece. The guard is good work — it just can't ship alone without regressing the very card it targets.
…isTurn swallow detector The Beckett fix removed the phantom `Duration::UntilEndOfTurn` that `strip_trailing_duration` was mis-injecting from the trailing "this turn" of the unparsed "controlled by a player who was dealt combat damage ... this turn" look-back clause (control-change is permanent, CR 611.2a). With no duration now in the AST, the coverage-honesty `Duration_ThisTurn` swallow detector newly flagged Admiral Beckett Brass: it saw "this turn" in the text but no `Duration` node, tripping the swallowed-clause budget (+1 over baseline). This is the detector honestly surfacing that the look-back clause is deferred — but the "this turn" there is a turn-history predicate on the target's controller (CR 608.2i look-back), not a swallowed effect duration. Mirror the parser-side guard in the detector: promote `player_lookback_relative_clause_owns_suffix` to `pub(crate)` and reuse it as the single authority for recognizing this clause structure. The exemption is occurrence-balanced (only when the sole "this turn" is the one the look-back clause owns), so a card with a genuine earlier duration AND a trailing look-back clause still fires. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed a follow-up commit fixing the card-data CI failure ( Root cause: this PR's core fix removed the phantom Fix: mirror the parser-side guard inside the detector. |
matthewevans
left a comment
There was a problem hiding this comment.
Thanks for the follow-up — commit 3dfb5ccc is the right local fix for what it targets: mirroring player_lookback_relative_clause_owns_suffix into detect_duration_this_turn so the swallow detector stops false-flagging the deferred "this turn" as a phantom duration. It's occurrence-balanced (total_this_turn == 1), CR-annotated (608.2i + 611.2a), and the test discriminates. Good.
The open blocker is one level up, though, and this doesn't close it. The card's target is target nonland permanent controlled by a player who was dealt combat damage by three or more Pirates this turn — the controlled by a player who <look-back> clause is a controller predicate on the target, and the target parser has no bridge for it. PlayerFilter::OpponentDealtDamage (the look-back filter this needs) is only ever produced in oracle_quantity.rs (counts) and oracle_nom/condition.rs (conditions); oracle_target.rs has no controlled by a player who … path (only anaphoric controlled by that player / the chosen player).
So with just the detector exemption, the risk is a false-green: if the coverage gate now marks Admiral Beckett Brass "supported," the effect would gain control of any nonland permanent, ignoring the Pirate-combat-damage restriction on legal targets — a card that's "supported" but mis-targets is worse than one left deferred. To land this, the target needs to carry the controller look-back predicate as a TargetFilter (wiring the existing OpponentDealtDamage filter into the target-controller path), with a runtime test showing the parsed target restricts to controllers who took ≥3 Pirate combat damage this turn. Happy to review that as the next push.
Parse changes introduced by this PR · 14 card(s), 15 signature(s) (baseline: main
|
…s (Admiral Beckett Brass phase-rs#4735) Addresses the CHANGES_REQUESTED review on PR phase-rs#5517. Beckett's steal target — "target nonland permanent controlled by a player who was dealt combat damage by three or more Pirates this turn" — previously dropped the controller restriction entirely, so the parse targeted ANY nonland permanent. Marking that "supported" is a false-green: a mis-targeting steal is worse than an honest deferral. Add a reusable object->player controller-predicate bridge: FilterProp::ControllerMatches { player: Box<PlayerFilter> } the object-axis generalization of the whole PlayerFilter enum (the existing FilterProp::ControllerChoseLabel is the single hard-coded predicate this generalizes). Runtime eval delegates to the single-authority `matches_player_scope` against `obj.controller` (CR 109.4 + CR 608.2c), so ANY player look-back can scope a target's controller — "who was dealt combat damage by <subtype>", "who lost life this turn", etc. Not a one-card variant: it unlocks the full class of "controlled by a player who <predicate>" cards. Parser: a nom combinator arm in `parse_ownership_or_controller_suffix` recognizes "controlled by a player who <look-back> this turn" and pushes ControllerMatches wrapping the recognized PlayerFilter (longest-match-first alt, mirrors the duration guard). Beckett now lowers to a GainControl target carrying ControllerMatches{OpponentDealtDamage{CombatOnly, Some(Pirate)}} with zero Unimplemented. DEFERRED (documented gap): the numeric "three or more" is consumed but not enforced — the bridge carries >=1 combat-damage-by-a-Pirate semantics. This is a strict correct tightening over "any permanent" (the true >=3 set is a subset of >=1), never over-broad; the count threshold is annotated as a known gap, not silently dropped. Also fixes the Gemini-flagged alt-ordering in the duration guard (`player_lookback_relative_clause_owns_suffix`): "was dealt combat damage " now precedes its "was dealt " prefix so the longer branch is reachable. New tests: parser shape + zero-Unimplemented reach-guard on Beckett's verbatim Oracle text; runtime filter eval (positive Pirate-combat-damage, negative non-Pirate / noncombat / undamaged); serde round-trip. All exhaustive-match sites (filter classification, ability_rw profile, coverage formatting, ai_support) carry explicit arms — no wildcard fallbacks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed The bridge (addresses the false-green)New reusable object→player filter, the object-axis generalization of the whole FilterProp::ControllerMatches { player: Box<PlayerFilter> }This is exactly the missing object-side analogue you identified — Beckett now lowers to One documented gap: the numeric "three or more"Per a scope decision, the count threshold is consumed but not enforced — the bridge carries ≥1 combat-damage-by-a-Pirate semantics. This is a strict, correct tightening over the old "any nonland permanent" (the true ≥3 set ⊆ ≥1 set), never over-broad, and it's annotated as a known add-engine-variant gate
Gemini's alt-ordering flagFixed in the same push — Full lib + parser + coverage + swallow suites green locally. |
matthewevans
left a comment
There was a problem hiding this comment.
Thanks for the substantial rework — this is a big step up from the prior head, and the core design is right.
What's strong (please keep):
FilterProp::ControllerMatches { player: Box<PlayerFilter> }is exactly the correct seam: a general object-axis bridge into the wholePlayerFilterenum rather than a one-offDealtCombatDamageBy…variant. Any controller look-back predicate ("lost life this turn", "was dealt combat damage", …) now composes through it, and evaluation delegates to the single-authoritymatches_player_scopeagainstobj.controller. That's parameterize-don't-proliferate done properly.- The inline tests are genuinely discriminating on the axes the filter enforces: Pirate-vs-Goblin source, combat-vs-noncombat damage, damaged-vs-undamaged controller, plus serde round-trip. Good coverage.
Blocker — the "three or more" threshold is dropped, and that's the card's defining restriction.
Oracle text: "controlled by a player who was dealt combat damage by three or more Pirates this turn." The parser consumes the three or more quantifier but does not thread it into the filter, so the engine enforces "≥1 Pirate." That isn't a rare edge — a player taking damage from one or two Pirates is the modal board state, and "three or more" is the whole build-around point of the card. With the clause now fully parsing, Admiral Beckett Brass is marked supported while it can illegally steal from a player hit by a single Pirate — a false-green, one layer deeper than the unrestricted-target version this replaces. A deferral that lives only in a // count deferred comment isn't a real boundary; the type still says "any Pirate."
Path to land (either works):
- Enforce the threshold — give
OpponentDealtDamagean optional count/comparator and count distinct combat-damaging Pirate sources inmatches_player_scope; or - If the count is genuinely out of scope for this PR, don't silently consume it — let the unhandled quantifier keep the clause from being claimed as fully supported, so the coverage report reflects what the engine actually enforces.
Everything else here is ready. (Also: CI is still pending and the branch is behind main by 9 — a rebase will want a fresh green regardless.)
|
@shin-core if you can land threading the quantity through on this PR I'll add both the
|
…Damage (Beckett min_sources) Addresses matthewevans's CHANGES_REQUESTED on PR phase-rs#5517: the controller look-back bridge parsed Beckett's target but silently dropped the "three or more" count, so the engine enforced ">=1 Pirate" while the type said "any Pirate" — a false-green (a player hit by 1-2 Pirates is the modal board state, and "three or more" is the card's whole build-around). Thread the count through end to end instead of deferring it: - types/ability.rs: add `min_sources: u32` (serde default 1, skip-if-1) to `PlayerFilter::OpponentDealtDamage`. A parameterization on the already- consolidated single damage variant (CR 120.9), not a new sibling; default 1 preserves every existing card's ">=1 matching source" semantics and keeps serialized card-data byte-identical. - game/quantity.rs (`opponent_dealt_damage_matches`): when min_sources > 1, count DISTINCT damaging sources by `DamageRecord::source_id` (a single Pirate across two combat steps is ONE source, CR 120.9) and require at least min_sources; the min_sources <= 1 path stays the allocation-free `.any()`. - parser/oracle_target.rs: parse the "<N> or more " quantifier via the shared `nom_primitives::parse_number` (handles "three" -> 3) into min_sources (absent -> 1), so Beckett lowers to min_sources: 3. - All destructure/construction sites (effects deal_damage/mod/speed_effects, ability_scan, ability_rw, oracle_quantity count paths, tests) thread the new field; ability_scan/ability_rw classify it as axis-free / no extra read. Tests: parser now asserts min_sources: 3 on Beckett; runtime filter tests prove 3 distinct Pirates match, 2 distinct do NOT (the reviewer's exact missing case), and the same Pirate twice counts once. Serde legacy roundtrip (no min_sources -> 1) stays green. Beckett is now genuinely correct — no false-green, no dropped restriction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed What changed
The discriminating tests you asked for
All destructure/construction sites (effects, ability_scan, ability_rw, the count-quantity paths) thread the field; Also: the branch is now 0 behind main, and the Gemini |
matthewevans
left a comment
There was a problem hiding this comment.
Approving — the second-layer false-green is fully resolved. The prior head parsed the clause but dropped the "three or more Pirates" count, so the filter enforced ≥1. This head threads the count as a typed min_sources field on PlayerFilter::OpponentDealtDamage, parsed from the Oracle text with a combinator (opt(terminated(parse_number, tag(" or more ")))) rather than hardcoded — so "three or more" → min_sources = 3 and the fix generalizes to any "N or more
Enforcement is rules-correct: min_sources <= 1 keeps the allocation-free ≥1 path for every existing card; min_sources > 1 counts DISTINCT damaging sources by source_id (CR 120.9 + CR 608.2i — one Pirate dealing combat damage across two combat steps is one source, not two) and requires ≥ min_sources. All enumeration seams (matches_player_scope, both deal_damage paths, speed_effects, ability_rw/ability_scan) delegate to the single opponent_dealt_damage_matches authority. Tests are discriminating: a parse-path test asserts Beckett emits min_sources: 3, and runtime negatives (2 distinct Pirates; the same Pirate counted twice) fail closed below the threshold. Both CR citations grep-verified. CI green, merge CLEAN. Thanks for threading the count through cleanly.
Summary
Fixes #4735. Admiral Beckett Brass's end-step steal — "gain control of target nonland permanent controlled by a player who was dealt combat damage by three or more Pirates this turn" — wrongly parsed with
duration: UntilEndOfTurn, so the control change expired at end of turn (making the steal useless). A control-change with no stated duration is permanent (CR 611.2a).Root cause
strip_trailing_durationmis-stripped the trailing"this turn"from the target's look-back relative clause and stamped it as the effect's duration.The existing
target_relative_clause_owns_suffixguard already prevents this forthat-introduced object-property clauses — but notwho-introduced player look-back clauses. Beckett uses "a player who was dealt combat damage … this turn".Fix
Add
player_lookback_relative_clause_owns_suffix— thewho-sibling of the existing guard. A self-standing structural recognizer for "… who<look-back verb>… this turn" (CR 608.2i). It deliberately does not delegate toparse_that_clause_suffix(whose vocabulary is object-property clauses like "that's enchanted", not the "controlled by a player who<verb>" player shape). It fires only when the relative clause consumes through the trailing"this turn"to end-of-input, so a genuine outer duration after the clause still strips.Scope note (deliberate coverage decision)
The full target restriction ("controlled by a player dealt combat damage by 3+ Pirates") stays an over-broad coverage gap. The object→player controller-predicate filter and the ≥N-source count axis it would require each serve only this one card (verified via Scryfall — Beckett is the sole match for both). Per the build-for-the-class rule, they are deliberately not added. The clause still parses to a valid
GainControl(noUnimplemented); only the phantom duration — the actually-broken behavior — is fixed, and that fix generalizes to the whole "who <look-back> this turn" clause class.Tests
GainControlwithduration: None.strip_trailing_durationpreserves the who-look-back clause (yields no duration).Verification
parser::oracle_effect(2,564), clippy clean.CR 611.2a (no-duration continuous effect lasts until end of game) + CR 608.2i (look-back effects).
🤖 Generated with Claude Code