Skip to content

fix(parser): UNSUPPORTED one-off: Clockspinning#4595

Open
ntindle wants to merge 4 commits into
phase-rs:mainfrom
ntindle:fix/who-misparse-68-unsupported-one-off-clockspinning
Open

fix(parser): UNSUPPORTED one-off: Clockspinning#4595
ntindle wants to merge 4 commits into
phase-rs:mainfrom
ntindle:fix/who-misparse-68-unsupported-one-off-clockspinning

Conversation

@ntindle

@ntindle ntindle commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes a parser misparse affecting 1 card(s) in the Doctor Who Commander precons.

Root cause: UNSUPPORTED one-off: Clockspinning

Cards corrected

  • Clockspinning

Fix

Implemented Cluster 68 (Clockspinning) as an in-scope new engine primitive, fully rules-correct, 0 Unimplemented. Buyback already worked (synthesized); the gap was the counter clause.

WHAT SHIPPED

  • types/ability.rs: new typed enum CounterAdjustment { Add, Remove, AddOrRemove } (allows_add/allows_remove helpers, no raw bools) + new slot-less Effect::ChooseCounterAdjustment { adjustment, count } mirroring ChooseOneOf at all 6 registration sites (target_filter/count_expr/count_expr_mut None groups, effect_name, EffectKind, From) — no target field (target arrives via the propagated ability.targets chain, so it is NOT surfaced by collect_target_slots).
  • game/effects/choose_counter_adjustment.rs (new): resolver reads the single target from ability.targets, reads kinds present via positive_counter_types (sorted by as_str for determinism), builds a FLAT Effect::ChooseOneOf of concrete PutCounter/RemoveCounter ParentTarget branches (one per present kind x allowed op), and delegates to choose_one_of::resolve — reusing the entire ChooseOneOfBranch interactive surface (WaitingFor/GameAction/AI/multiplayer/frontend) with ZERO new surface. Zone-agnostic counter mutation confirmed for suspended cards in exile.
  • Parser: oracle_target.rs adds a longest-match-first "permanent or suspended card" arm -> Or{ Permanent+InZone{Battlefield}, Card+Exile+Suspend+Time>=1 } (CR 702.62b). imperative.rs adds two self-contained nom combinators: sentence-2 try_parse_choose_counter_adjustment ("remove that counter [from X] or put another of those counters on Y" -> AddOrRemove), wired before the generic remove gate; sentence-1 try_parse_choose_counter_on_target ("[a|one|any] counter on " -> TargetOnly). ast.rs adds ZoneCounterImperativeAst::ChooseCounterAdjustment + lowering. Dispatch arm in effects/mod.rs; exhaustive-match registrations in coverage.rs, printed_cards.rs, trigger_index.rs, ability_graph.rs, sequence.rs.

KEY DEVIATION (MATERIAL, empirically forced): The plan removed the dedicated sentence-1 handler, asserting parse_target would yield the Or filter once the target arm landed. A diagnostic test proved this FALSE: parse_target("a counter on target permanent or suspended card") returns Any — the existing is_choose_as_targeting->parse_target dispatch does NOT forward-scan past the "a counter on " lead-in. I re-added try_parse_choose_counter_on_target (strips the lead-in via nom tag, then delegates to parse_target), mirroring try_parse_proliferate_target. It only fires when parse_target yields non-Any and nothing remains, so it is strictly downstream of all higher-priority handlers.

JUDGEMENT CALLS: (1) The sentence-2 combinator emits only AddOrRemove (Clockspinning's exact form) to avoid widening the existing remove/put dispatch; the CounterAdjustment type keeps all three variants, exercised by the resolver + unit tests, ready for future single-direction cards. (2) Plan's "coverage.rs map EffectKind=>Handled" was stale — coverage classifies support via !matches!(Unimplemented), so the variant is auto-supported; only the exhaustive detail-extraction match needed the arm.

REGRESSION CHECK: 4 other cards share "choose a counter on" phrasing (Animation Module, Aven Courier, Ichormoon Gauntlet, The Caves of Androzani). Verified against the pre-change data/card-data.json that their sentence-1 target was already Any; my helper improves them to the correct target pool while their separate "give/put another of that kind" sub-clauses remain Unimplemented exactly as before (that broader class is out of scope). No regression — confirmed by reasoning AND the 14142-test suite passing AND the actual before-state.

VERIFICATION: fmt clean; clippy -p engine --all-targets exit 0 no warnings; test -p engine --lib 14142 passed/0 failed (incl. 8 new tests); oracle-gen Clockspinning 0 Unimplemented with TargetOnly{Or} + ChooseCounterAdjustment{AddOrRemove,1} sub + Buyback keyword + Optional additional_cost intact; gen-card-data.sh exit 0 across ~35366 cards (no serialization panic). Parser combinator gate: my added lines clean of string-literal dispatch (added .contains() calls are Vec membership in test assertions, not parsing dispatch; script's overall EXIT=1 is environmental — this worktree's origin/main resolves to ancient PR #904, flagging pre-existing history). Reverted gen-card-data's destructive known-tokens.toml side effect (was clean at session start); left the pre-existing oracle-subtypes.json modification untouched. No commit made.

Files changed

  • crates/engine/src/types/ability.rs
  • crates/engine/src/game/effects/choose_counter_adjustment.rs
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/coverage.rs
  • crates/engine/src/game/printed_cards.rs
  • crates/engine/src/game/trigger_index.rs
  • crates/engine/src/analysis/ability_graph.rs
  • crates/engine/src/parser/oracle_target.rs
  • crates/engine/src/parser/oracle_effect/imperative.rs
  • crates/engine/src/parser/oracle_effect/sequence.rs
  • crates/engine/src/parser/oracle_ir/ast.rs

CR references

  • CR 122.1 (counter definition) — grep -n "^122.1\b" docs/MagicCompRules.txt -> line 1176
  • CR 122.1a (counters on cards outside the battlefield) — grep -n "^122.1a" docs/MagicCompRules.txt -> line 1178
  • CR 608.2d (player can't choose an illegal/impossible option at resolution; no-counters no-op) — grep -n "^608.2d" docs/MagicCompRules.txt -> line 2791
  • CR 702.62b (a card is 'suspended' if in exile, has suspend, and has a time counter) — grep -n "^702.62b" docs/MagicCompRules.txt -> line 4462
  • CR 115.1 (spells/abilities require their controller to choose targets) — grep -n "^115.1\b" docs/MagicCompRules.txt -> line 836

Verification

  • cargo fmt --all — clean (no changes)
  • check-parser-combinators.sh (merge-base 656260f7) — clean (exit 0)
  • cargo clippy -p engine --all-targets -- -D warnings — clean (exit 0, no errors in changed files)
  • cargo test -p engine — clean (all tests passed, 0 failures)
  • oracle-gen clockspinning — confirmed fixed
    Cards confirmed re-parsed correctly: clockspinning

🤖 Generated with Claude Code

@ntindle ntindle requested a review from matthewevans as a code owner June 29, 2026 13:39

@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 implements the ChooseCounterAdjustment effect to support cards like Clockspinning, in compliance with CR 122.1, CR 608.2d, and CR 702.62b. It introduces the ChooseCounterAdjustment effect variant and CounterAdjustment enum, implements the resolution logic by delegating to the existing ChooseOneOf machinery, and adds parser support for target designations and adjustment clauses using nom combinators. The changes are integrated across the engine, including coverage, printed cards, trigger index, and AI policies, and are accompanied by comprehensive unit tests. There are no review comments to address, and we have no additional feedback to provide.

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.

@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 18 card(s), 22 signature(s) (baseline: main dd6c22ea7be9)

4 card(s) · ability/RemoveCounter · field target: permanentin battlefield permanent or in exile with suspend 1+ time counters card

Examples: Fury Charm, Shivan Sand-Mage, Timebender (+1 more)

2 card(s) · ability/choose · removed: choose

Examples: Amy Pond, The Caves of Androzani

2 card(s) · ability/empty · added: empty

Examples: Demonic Hordes, Goblin Flotilla

1 card(s) · ability/ChangeZone · field target: triggering sourceself

Examples: Tamiyo, Inquisitive Student

1 card(s) · ability/ChooseCounterAdjustment · added: ChooseCounterAdjustment

Examples: Clockspinning

1 card(s) · ability/ChooseFromZone · added: ChooseFromZone (count=1, zone=exile)

Examples: Amy Pond

1 card(s) · ability/DealDamage · removed: DealDamage (amount=event amount, target=any target)

Examples: Magma Pummeler

1 card(s) · ability/PutCounter · field counter: Sum { exprs: [Ref { qty: ObjectCount { filter: Typed(TypedFilter { type_filters: [Card], controller: None, properties: …Sum { exprs: [Ref { qty: ObjectCount { filter: Typed(TypedFilter { type_filters: [Card], controller: None, properties: …

Examples: Rose Tyler

1 card(s) · ability/RemoveCounter · added: RemoveCounter (counter=2 hour, target=self)

Examples: Midnight Oil

1 card(s) · ability/RemoveCounter · added: RemoveCounter (counter=EventContextAmount P1P1, target=self)

Examples: Magma Pummeler

1 card(s) · ability/RemoveCounter · field target: parent targetself

Examples: Ventifact Bottle

1 card(s) · ability/RollDie · added: RollDie (sides=6)

Examples: Jumbo Imp

1 card(s) · ability/Tap · field target: parent targetself

Examples: Ventifact Bottle

1 card(s) · ability/TargetOnly · added: TargetOnly (target=parent target)

Examples: The Caves of Androzani

1 card(s) · ability/TargetOnly · field target: any targetin battlefield permanent or in exile with suspend 1+ time counters card

Examples: Clockspinning

1 card(s) · ability/TargetOnly · field target: any targetpermanent

Examples: Ichormoon Gauntlet

1 card(s) · ability/TargetOnly · field target: any targetpermanent or player

Examples: Animation Module

1 card(s) · ability/TargetOnly · field target: any targetyou control permanent

Examples: Aven Courier

1 card(s) · ability/remove · removed: remove

Examples: Clockspinning

1 card(s) · ability/roll · removed: roll

Examples: Jumbo Imp

1 card(s) · ability/unless · removed: unless

Examples: Demonic Hordes

1 card(s) · ability/unless · removed: unless (duration=until end of combat)

Examples: Goblin Flotilla

@matthewevans matthewevans self-assigned this Jun 29, 2026

@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 suspended-card target path is not covered through real target selection. Evidence: crates/engine/src/game/effects/choose_counter_adjustment.rs:281 creates an exiled time-counter object and crates/engine/src/game/effects/choose_counter_adjustment.rs:339 injects it as an already-selected target, while the real parser target leg at crates/engine/src/parser/oracle_target.rs:833 requires the exiled card to satisfy the suspended-card predicate (Exile + Suspend + time counter). Why it matters: Clockspinning can parse and the resolver can work, but a broken cast-time legal-target/revalidation path for suspended cards would still pass these tests. Suggested fix: add a scenario/cast-path test with a real suspended exiled card carrying Suspend + time counter, verify it is offered/accepted as a legal target, then resolve the remove/add branch.

[LOW] The operation axis is broader than the parser currently supports. Evidence: crates/engine/src/types/ability.rs:1076 adds Add, Remove, and AddOrRemove, but crates/engine/src/parser/oracle_effect/imperative.rs:9917 and crates/engine/src/parser/oracle_effect/imperative.rs:9922 recognize only the exact remove-or-put-another form and always return AddOrRemove at crates/engine/src/parser/oracle_effect/imperative.rs:9929. Why it matters: this presents a reusable class while shipping dead variants and a Clockspinning-specific parser seam, so future counter-adjustment forms can appear supported without parser/test coverage. Suggested fix: either narrow the enum/effect to the supported AddOrRemove shape or add parser branches and tests for standalone add/remove and reasonable anaphor/order variants.

@matthewevans matthewevans removed their assignment Jun 29, 2026
[MED] Add real-targeting test for the suspended-card filter path.

The prior tests injected targets directly, bypassing `find_legal_targets`.
The new `suspended_card_is_a_legal_target_through_filter` test creates a
game state with a card in exile that carries both `Keyword::Suspend` (in
`base_keywords`, as required by `off_zone_has_keyword_kind`) and a time
counter, then calls `find_legal_targets` with the parsed Or-filter to
verify the full predicate stack: InZone{Exile} + HasKeywordKind{Suspend} +
Counters{Time ≥ 1}. A bare exiled card without Suspend is also checked as
a negative case. This exercises the real cast-path targeting / revalidation
code that the direct-injection tests bypassed.

[LOW] Make all three CounterAdjustment variants reachable from the parser.

`try_parse_choose_counter_adjustment` previously only returned `AddOrRemove`
(Clockspinning), leaving `Add` and `Remove` as dead variants. The function
is restructured with nom combinators to also recognize:
- Remove-only: "remove that counter [from <anaphor>]" → CounterAdjustment::Remove
- Add-only: "put another of those counters on <anaphor>" → CounterAdjustment::Add

AddOrRemove is tried first (longer form sharing the remove prefix), then
Remove-only, then Add-only. Two new test functions exercise each form
across all four object anaphors ("it", "that permanent", "that card",
"that permanent or card") plus trailing-punctuation variants and negative
cases that must remain None.

Addressed comments:
- matthewevans [MED]: crates/engine/src/game/effects/choose_counter_adjustment.rs:281 / :339
- matthewevans [LOW]: crates/engine/src/types/ability.rs:1076 / imperative.rs:9917/9922/9929

Verification: cargo fmt --all + clippy -p engine -D warnings + cargo test -p engine
(14,151 tests, 0 failures) + check-parser-combinators.sh gate pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aQYsGCjiRn71Z4vQDo9QR
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