Skip to content

fix(parser): recognize "the active player has controlled continuously since the beginning of the turn" target clause (Nettling Imp, Norritt, Arcum's Whistle)#5463

Open
rykerwilliams wants to merge 2 commits into
phase-rs:mainfrom
rykerwilliams:fix/nettling-imp-continuity-filter

Conversation

@rykerwilliams

Copy link
Copy Markdown
Contributor

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

  • Nettling Imp
  • Norritt
  • Arcum's Whistle

Total War shares the same continuity phrase but in a structurally different "except for" mass-exemption grammar (a DestroyAll exclusion clause, not a target-selection clause) — confirmed out of scope by tracing parse_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:

  1. Sets controller = Some(ControllerRef::ActivePlayer)
  2. Pushes FilterProp::ControlledContinuouslySinceTurnBegan

Both types are pre-existing, reused verbatim — zero new enum variants. FilterProp::ControlledContinuouslySinceTurnBegan is already fully wired through runtime evaluation at game/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::You variant 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 (all ActivePlayer-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

$ ./scripts/check-parser-combinators.sh
(exit 0, no violations)

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. No contains()/starts_with()/find() dispatch.

Gate B — anchored citations (≥2)

  • crates/engine/src/game/filter.rs:4124FilterProp::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 the parse_controller_suffix delegate fallback (the same seam as the existing "you own and control" precedent arm a few lines above it).

CR references

  • CR 102.1 — defines the active player (the controller scope).
  • CR 302.6 — summoning-sickness rule; !obj.summoning_sick is exactly this rule's continuity test.
  • CR 508.1a — near-verbatim match to the card text: "...must either have haste or have been controlled by the active player continuously since the turn began."
  • CR 608.2i — cited to contrast: this is a live target-selection restriction, not the past-tense look-back pattern 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 0
  • Parser dispatch grep gate (no string-dispatch primitives in added lines) — clean
  • CR-annotation diff gate — all 4 citations verified against docs/MagicCompRules.txt, zero UNVERIFIED
  • cargo clippy -p engine --all-targets -- -D warnings — clean, zero warnings (re-verified post-rebase onto current main)
  • Two discriminating tests, both passing via a full cargo test -p engine compile pre-rebase:
    • parse_target_active_player_controlled_continuously_since_turn_began (parser shape test — asserts the exact TargetFilter emitted)
    • build_target_slots_active_player_controlled_continuously_since_turn_began (runtime test through the real build_target_slots production 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)
  • Independent review (/review-impl) — clean, no blocking findings

Validation NOT performed (disclosed honestly)

cargo coverage and cargo semantic-audit — the corpus-wide audits that would directly confirm Nettling Imp/Norritt flip to supported: true in 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:

  • Not resource contention — new failures kept appearing at the same rate even with the system independently confirmed fully idle.
  • Not a server-side block or 404 — the exact failing URLs return HTTP 200 in <0.3s via direct manual curl.
  • Not a bug in the shared fetch library — mtgjson_download/mtgjson_curl (scripts/lib/mtgjson-fetch.sh) succeeded when invoked interactively, inside a set -euo pipefail subshell, via a detached background task, and in a 7-code sequential loop — every isolated reproduction succeeded.
  • Not session-specific networking — no proxy env vars, and the exact same session's manual curl calls succeeded.

The failure is isolated to something specific to the deep-nested subprocess chain gen-card-data.shfetch-token-sets.shcurl, and remains unexplained. Given the additive, narrowly-scoped nature of this change (a single new tag() 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-rebase clippy (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 Imp and Norritt from docs/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 Whistle was 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

rykerwilliams and others added 2 commits July 9, 2026 17:16
… 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

@gemini-code-assist gemini-code-assist 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.

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.

Comment on lines +5455 to +5463
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());
}

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.

high

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

Suggested change
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
  1. 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)
  2. 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 alt and tag sequences) to prevent combinatorial explosion and improve maintainability.

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

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

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

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 3 card(s), 1 signature(s) (baseline: main 3b9e44ce3f6d)

3 card(s) · ability/TargetOnly · field target: creature non-Wallcontrolled continuously since turn began the active player controls creature non-Wall

Examples: Arcum's Whistle, Nettling Imp, Norritt

1 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.

@matthewevans

Copy link
Copy Markdown
Member

Re-reviewed head 2d7ecccf12055a2dbd536556fffcd08628284ba6 after CI completed. The required <!-- coverage-parse-diff --> evidence is now present and reports exactly the three intended cards (Arcum's Whistle, Nettling Imp, and Norritt), so that earlier evidence blocker is cleared.

The PR remains changes-requested for the two existing MED findings: the parser arm still matches the full Oracle clause as one verbatim tag instead of composable nom atoms, and the added runtime test still constructs the filter directly rather than parsing real Oracle text through the production target-selection path. No new finding was added.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants