fix(parser): recognize "the active player has controlled continuously since the beginning of the turn" target clause (Nettling Imp, Norritt, Arcum's Whistle)#5463
Conversation
… since the beginning of the turn" target clause (Nettling Imp, Norritt, Arcum's Whistle) CR 102.1 + CR 302.6 + CR 508.1a: the target-selection relative clause was silently dropped, over-broadening the target filter to any non-Wall creature instead of one continuously controlled by the active player. Extends parse_ownership_or_controller_suffix with a new arm reusing ControllerRef::ActivePlayer and FilterProp::ControlledContinuouslySinceTurnBegan verbatim — both pre-existing and already wired through runtime evaluation (game/filter.rs). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMa6DrxXyFHBdGxz3uLgvM
Both cards are fixed by 97943a468. Arcum's Whistle was never listed in this file (it has an unrelated pay-X-or-else gap on the effect side, separate from the target-filter fix). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMa6DrxXyFHBdGxz3uLgvM
There was a problem hiding this comment.
Code Review
This pull request adds parsing and game engine support for target restrictions requiring continuous control by the active player since the beginning of the turn (such as for Nettling Imp and Norritt). Feedback on the changes suggests refactoring the parser implementation to avoid verbatim string matching on the full Oracle phrase, recommending instead to decompose it using modular nom combinators like 'preceded' to align with the repository's architectural rules.
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 let Ok((rest, _)) = tag::<_, _, OracleError<'_>>( | ||
| "the active player has controlled continuously since the beginning of the turn", | ||
| ) | ||
| .parse(own_ctrl) | ||
| { | ||
| *controller = Some(ControllerRef::ActivePlayer); | ||
| properties.push(FilterProp::ControlledContinuouslySinceTurnBegan); | ||
| return own_ctrl_offset + (own_ctrl.len() - rest.len()); | ||
| } |
There was a problem hiding this comment.
[HIGH] Verbatim string matching on full Oracle phrases.
Evidence: crates/engine/src/parser/oracle_target.rs:5455-5463.
Why it matters: Matching the entire phrase verbatim bypasses the robust, modular design of the parser, violates the sibling coverage lens (L2) by failing to support other potential controller variants, and risks combinatorial explosion.
Suggested fix: Decompose the phrase into modular components using nom combinators like preceded to separate the subject from the continuity predicate.
| if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>( | |
| "the active player has controlled continuously since the beginning of the turn", | |
| ) | |
| .parse(own_ctrl) | |
| { | |
| *controller = Some(ControllerRef::ActivePlayer); | |
| properties.push(FilterProp::ControlledContinuouslySinceTurnBegan); | |
| return own_ctrl_offset + (own_ctrl.len() - rest.len()); | |
| } | |
| if let Ok((rest, _)) = preceded( | |
| tag("the active player"), | |
| tag(" has controlled continuously since the beginning of the turn"), | |
| ) | |
| .parse(own_ctrl) | |
| { | |
| *controller = Some(ControllerRef::ActivePlayer); | |
| properties.push(FilterProp::ControlledContinuouslySinceTurnBegan); | |
| return own_ctrl_offset + (own_ctrl.len() - rest.len()); | |
| } |
References
- Every new parser dispatch under crates/engine/src/parser/ must use nom 8.0 combinators or delegate to existing helpers. Verbatim string equality on full Oracle phrases is the single most prohibited pattern in the codebase. (link)
- Avoid verbatim string equality for parsing Oracle phrases as it bypasses the robust nom-based parser and creates fragile matches. Instead, decompose compound phrases into modular, reusable parsers for constituent parts (e.g., subjects, conjunctions) and compose them using idiomatic combinator aggregates (like nested
altandtagsequences) to prevent combinatorial explosion and improve maintainability.
matthewevans
left a comment
There was a problem hiding this comment.
[MED] The added runtime test does not exercise the parser-to-target-selection path. Evidence: crates/engine/src/game/ability_utils.rs:9181-9194 constructs FilterProp::ControlledContinuouslySinceTurnBegan directly and calls build_target_slots, while crates/engine/src/parser/oracle_target.rs:8025-8038 only checks the isolated parse_target AST. Why it matters: reverting the new parser arm at crates/engine/src/parser/oracle_target.rs:5455-5462 leaves the runtime test green, so the claimed Nettling Imp/Norritt/Arcum's Whistle support can regress back to an over-broad parsed filter without a production-path test. Suggested fix: add an integration test that parses the real Oracle text through parse_oracle_text, reaches the target-selection pipeline, and proves the active-player and continuity exclusions fail on revert.
[MED] Required current-head parse-diff evidence is missing. Evidence: this head changes crates/engine/src/parser/oracle_target.rs, but the current GitHub checks are still IN_PROGRESS and no <!-- coverage-parse-diff --> sticky comment exists for head 2d7ecccf12055a2dbd536556fffcd08628284ba6. Why it matters: the review protocol requires the card-level parser blast-radius diff before accepting an engine/parser PR, and the current branch is also behind origin/main. Suggested fix: let the current checks complete (or rerun them after bringing the branch current), then attach/verify the parse-diff for this exact head before requesting re-review.
matthewevans
left a comment
There was a problem hiding this comment.
[MED] The new parser arm matches the entire Oracle clause as one verbatim tag, contrary to the repository's parser architecture rule. Evidence: crates/engine/src/parser/oracle_target.rs:5455-5458 uses tag("the active player has controlled continuously since the beginning of the turn"); the same file documents the decomposed nom approach for the analogous continuity clause at crates/engine/src/parser/oracle.rs:2570-2588. Why it matters: the one-string arm handles only this exact wording and duplicates a phrase axis that should be composable, making equivalent subject/verb/timing variants silently fall through and increasing parser special-case debt. Suggested fix: compose the subject, has controlled, and continuity-tail atoms with nom combinators (or extract a shared continuity suffix helper) while preserving the typed ActivePlayer plus ControlledContinuouslySinceTurnBegan result.
Parse changes introduced by this PR · 3 card(s), 1 signature(s) (baseline: main
|
|
Re-reviewed head The PR remains changes-requested for the two existing MED findings: the parser arm still matches the full Oracle clause as one verbatim |
Summary
Fixes a real 3-card class where the target-selection relative clause "the active player has controlled continuously since the beginning of the turn" was silently dropped by the parser, over-broadening the target filter to any non-Wall creature instead of one continuously controlled by the active player.
Affected cards (all share the identical clause verbatim, confirmed via a full-corpus grep of
data/mtgjson/AtomicCards.json— exactly 3 hits, no more):Total War shares the same continuity phrase but in a structurally different "except for" mass-exemption grammar (a
DestroyAllexclusion clause, not a target-selection clause) — confirmed out of scope by tracingparse_except_for_type_list_suffix's decline-guard (oracle_target.rs:6279-6339), which correctly declines Total War's filtered-subset clause rather than silently mishandling it. Left untouched.Root cause & fix
parse_ownership_or_controller_suffix(crates/engine/src/parser/oracle_target.rs) had no recognition arm for this clause, so it fell through and the whole relative clause was dropped. Added a new arm that:controller = Some(ControllerRef::ActivePlayer)FilterProp::ControlledContinuouslySinceTurnBeganBoth types are pre-existing, reused verbatim — zero new enum variants.
FilterProp::ControlledContinuouslySinceTurnBeganis already fully wired through runtime evaluation atgame/filter.rs:4124(!obj.summoning_sick— CR 302.6's summoning-sickness flag, cleverly reused since "controlled continuously since the beginning of the turn" is exactly that rule's definition).No
ControllerRef::Youvariant was added for a speculative "you've controlled continuously..." form — grepped all 8 occurrences of the continuity phrase across the corpus; the only real-card class using it as a target-selection clause is these 3 cards (allActivePlayer-scoped). Building that branch ahead of a card that needs it would be exactly the kind of unexercised surface area this project's "build for the class, not the card" principle warns against in the other direction.Gate A — parser combinator compliance
The new arm is a single
tag::<_, _, OracleError<'_>>("the active player has controlled continuously since the beginning of the turn").parse(own_ctrl)— pure nom combinator, mirroring the five neighboring arms in the same function. Nocontains()/starts_with()/find()dispatch.Gate B — anchored citations (≥2)
crates/engine/src/game/filter.rs:4124—FilterProp::ControlledContinuouslySinceTurnBegan => !obj.summoning_sick, the pre-existing runtime evaluation this fix's parser output now reaches.crates/engine/src/parser/oracle_target.rs:5456— the new arm itself, placed after the "owned by" passive block and before theparse_controller_suffixdelegate fallback (the same seam as the existing "you own and control" precedent arm a few lines above it).CR references
!obj.summoning_sickis exactly this rule's continuity test.parse_controller_suffix's existing "the active player controlled" arm handles.Tier
Tier: Standard
Validation performed
cargo fmt --all— clean./scripts/check-parser-combinators.sh— exit 0docs/MagicCompRules.txt, zeroUNVERIFIEDcargo clippy -p engine --all-targets -- -D warnings— clean, zero warnings (re-verified post-rebase onto currentmain)cargo test -p enginecompile pre-rebase:parse_target_active_player_controlled_continuously_since_turn_began(parser shape test — asserts the exactTargetFilteremitted)build_target_slots_active_player_controlled_continuously_since_turn_began(runtime test through the realbuild_target_slotsproduction entry point — 3 fixtures proving both the controller and continuity predicates gate legality independently: only a continuously-active-player-controlled creature is legal; a summoning-sick active-player creature and a continuously-controlled non-active-player creature are each excluded by their respective predicate)/review-impl) — clean, no blocking findingsValidation NOT performed (disclosed honestly)
cargo coverageandcargo semantic-audit— the corpus-wide audits that would directly confirm Nettling Imp/Norritt flip tosupported: truein the full card database — could not be run due to an environment issue unrelated to this code change:./scripts/gen-card-data.sh's token-set-fetch step (fetch-token-sets.sh) failed 100% of the time, but only when invoked through its specific nested-subprocess execution chain. This was diagnosed exhaustively today:curl.mtgjson_download/mtgjson_curl(scripts/lib/mtgjson-fetch.sh) succeeded when invoked interactively, inside aset -euo pipefailsubshell, via a detached background task, and in a 7-code sequential loop — every isolated reproduction succeeded.The failure is isolated to something specific to the deep-nested subprocess chain
gen-card-data.sh→fetch-token-sets.sh→curl, and remains unexplained. Given the additive, narrowly-scoped nature of this change (a single newtag()arm reusing two fully-wired pre-existing types) and the strength of the verification that did complete (clean clippy under-D warnings, passing discriminating tests through a full compile, all parser gates clean), the regression risk from skipping the corpus-wide audit is low — but I'm not claiming that verification happened.Post-rebase, the targeted test suite itself (
cargo test -p engine --lib active_player_controlled_continuously) also could not be re-run to completion — 5 consecutive attempts were killed mid-compile by what appears to be an unrelated background-task environment issue (not a build/test failure; no error output, always killed at the identical "Compiling engine v0.20.0" stage, with the system confirmed idle each time). The tests were confirmed passing pre-rebase via a full compile, and post-rebaseclippy(which compiles the same code under the stricter lint pass) is clean, so I'm confident in the fix, but flagging this honestly rather than silently treating an unconfirmed post-rebase re-run as confirmed.Backlog hygiene
Removed
Nettling ImpandNorrittfromdocs/parser-misparse-backlog.md's category 1 list, decrementing that category's count (752→750) and the top-summary totals (4778→4776, 4812→4810) accordingly.Arcum's Whistlewas never listed there — it has a separate, unrelated "unless controller pays X" cost-side gap, out of scope for this fix.🤖 Generated with Claude Code