Skip to content

feat(parser): turn-window CDA — leading timing condition + constant P/T form (Angry Mob)#5196

Merged
matthewevans merged 3 commits into
phase-rs:mainfrom
minion1227:minion_angry_mob
Jul 6, 2026
Merged

feat(parser): turn-window CDA — leading timing condition + constant P/T form (Angry Mob)#5196
matthewevans merged 3 commits into
phase-rs:mainfrom
minion1227:minion_angry_mob

Conversation

@minion1227

Copy link
Copy Markdown
Contributor

Angry Mob ("During your turn, ~'s power and toughness are each equal to 2 plus the number of Swamps your opponents control. During turns other than yours, ~'s power and toughness are each 2.") dropped entirely — the CDA-equality handler ignored a leading turn-window timing condition and had no "are each N" constant form, so the off-turn clause never parsed and the multi-sentence splitter (parse_multi_sentence_statics) failed its "every segment yields a static" guard.

Extends parse_cda_pt_equality (test-free oracle_static/cda.rs) with two additions:

  1. Peel a leading timing condition"During your turn,"StaticCondition::DuringYourTurn, "During turns other than yours,"Not{DuringYourTurn} (via nom_tag_lower, mirroring the base-P/T-set path). The CDA-equality form now carries the condition consistently with the base-P/T form.
  2. Constant CDA P/T"~'s power and toughness are each N"SetPower{N} + SetToughness{N} (guarded by !both so the dynamic "equal to" framing keeps priority).

With both clauses parsing, the existing multi-sentence splitter emits the two turn-gated CDA statics automatically:

  • on-turn → dynamic P/T Offset{ObjectCount(Swamp, opponents), +2}, gated DuringYourTurn
  • off-turn → constant 2/2, gated Not{DuringYourTurn}

No new variant, no new runtime. CR 611.3 + CR 613.4c + CR 613.7c.

Verified: new static_angry_mob_two_clause_turn_window_cda test (asserts both CDA statics, conditions, dynamic vs constant); full oracle_static suite 957/957 green (no CDA regressions); parser-combinator gate + fmt clean.

🤖 Generated with Claude Code

…g condition + constant form (Angry Mob)

Angry Mob ("During your turn, ~'s power and toughness are each equal to 2 plus
the number of Swamps your opponents control. During turns other than yours, ~'s
power and toughness are each 2.") dropped entirely: the CDA-equality handler
ignored a leading turn-window timing condition, and had no "are each N" constant
form — so the off-turn clause never parsed and the multi-sentence splitter
(parse_multi_sentence_statics) failed the "every segment yields a static" guard.

Extend parse_cda_pt_equality (oracle_static/cda.rs) with two additions:
1. Peel a leading "During your turn," / "During turns other than yours," and
   attach StaticCondition::DuringYourTurn / Not{DuringYourTurn} (mirrors the
   base-P/T-set path via nom_tag_lower), so the CDA-equality form carries the
   condition consistently.
2. Constant CDA P/T: "~'s power and toughness are each N" -> SetPower{N} +
   SetToughness{N} (guarded by !both so the dynamic "equal to" framing keeps
   priority).

With both clauses now parsing, the existing multi-sentence splitter emits the
two turn-gated CDA statics automatically. No new variant, no new runtime.
CR 611.3 + CR 613.4c + CR 613.7c.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@minion1227 minion1227 requested a review from matthewevans as a code owner July 6, 2026 12:05

@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 support for parsing characteristic-defining abilities (CDAs) with turn-window timing conditions (such as 'During your turn' or 'During turns other than yours') and constant characteristic-defining power and toughness values. The feedback identifies incorrect Comprehensive Rules (CR) citations (citing CR 613.7c instead of CR 611.3a/613.7a, and CR 613.4c instead of CR 613.4b) and highlights a fragile manual slicing implementation that could potentially panic, suggesting a safer parsing approach using nom_tag_lower.

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 +14 to +36
// CR 611.3 + CR 613.7c: peel a leading turn-window timing condition so a CDA
// scoped to "During your turn," / "During turns other than yours," carries
// that condition (Angry Mob), mirroring the base-P/T-set path. Such a card's
// two clauses are split into separate sentences upstream by
// `parse_multi_sentence_statics`, so each clause reaches here independently.
let (lower, text, timing_condition) =
if let Some(rest) = nom_tag_lower(lower, lower, "during your turn, ") {
(
rest,
&text[text.len() - rest.len()..],
Some(StaticCondition::DuringYourTurn),
)
} else if let Some(rest) = nom_tag_lower(lower, lower, "during turns other than yours, ") {
(
rest,
&text[text.len() - rest.len()..],
Some(StaticCondition::Not {
condition: Box::new(StaticCondition::DuringYourTurn),
}),
)
} else {
(lower, text, None)
};

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] Incorrect CR citation and fragile manual slicing. Evidence: crates/engine/src/parser/oracle_static/cda.rs:14-36.

Why it matters: Citing CR 613.7c (which is about counters) is incorrect for timing conditions on static abilities, and manual slicing using text.len() - rest.len() is fragile and can panic if case folding results in different byte lengths for lower and text.

Suggested fix: Use CR 611.3a / CR 613.7a and call nom_tag_lower on both lower and text to safely extract the remaining slices without manual slicing.

    // CR 611.3a + CR 613.7a: peel a leading turn-window timing condition so a CDA
    // scoped to "During your turn," / "During turns other than yours," carries
    // that condition (Angry Mob), mirroring the base-P/T-set path. Such a card's
    // two clauses are split into separate sentences upstream by
    // `parse_multi_sentence_statics`, so each clause reaches here independently.
    let (lower, text, timing_condition) = 
        if let Some(rest_lower) = nom_tag_lower(lower, lower, "during your turn, ") {
            (
                rest_lower,
                nom_tag_lower(lower, text, "during your turn, ").unwrap(),
                Some(StaticCondition::DuringYourTurn),
            )
        } else if let Some(rest_lower) = nom_tag_lower(lower, lower, "during turns other than yours, ") {
            (
                rest_lower,
                nom_tag_lower(lower, text, "during turns other than yours, ").unwrap(),
                Some(StaticCondition::Not {
                    condition: Box::new(StaticCondition::DuringYourTurn),
                }),
            )
        } else {
            (lower, text, None)
        };
References
  1. Every rules-touching line of engine code must carry a comment of the form CR : . The number must be verified against docs/MagicCompRules.txt before writing. (link)

Comment on lines +43 to +46
// CR 613.4c: constant characteristic-defining P/T — "~'s power and toughness
// are each N" (Angry Mob's off-turn clause "... are each 2"): a fixed base
// value, not a dynamic quantity. Guarded by `!both` so the dynamic "are each
// equal to" framing (which also contains "are each ") keeps priority.

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.

medium

[MEDIUM] Incorrect CR citation for P/T setting. Evidence: crates/engine/src/parser/oracle_static/cda.rs:43-46.

Why it matters: Citing CR 613.4c (which is Layer 7c for modifications) is incorrect for setting power and toughness to a specific number or value, which belongs in Layer 7b (CR 613.4b).

Suggested fix: Update the comment to cite CR 613.4b.

Suggested change
// CR 613.4c: constant characteristic-defining P/T — "~'s power and toughness
// are each N" (Angry Mob's off-turn clause "... are each 2"): a fixed base
// value, not a dynamic quantity. Guarded by `!both` so the dynamic "are each
// equal to" framing (which also contains "are each ") keeps priority.
// CR 613.4b: constant characteristic-defining P/T — "~'s power and toughness
// are each N" (Angry Mob's off-turn clause "... are each 2"): a fixed base
// value, not a dynamic quantity. Guarded by !both so the dynamic "are each
// equal to" framing (which also contains "are each ") keeps priority.
References
  1. Every rules-touching line of engine code must carry a comment of the form CR : . The number must be verified against docs/MagicCompRules.txt before writing. (link)

@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 CR annotations point to rules that do not describe this CDA parsing path. Evidence: crates/engine/src/parser/oracle_static/cda.rs:14 cites CR 613.7c, which is counter timestamp ordering, and crates/engine/src/parser/oracle_static/cda.rs:43 / crates/engine/src/parser/oracle_static/tests.rs:78 cite CR 613.4c, which is the modifier/counter sublayer rather than CDA/set P/T; local rules text has CDA P/T in CR 604.3 / CR 613.4a and set P/T in CR 613.4b. Why it matters: engine/parser rule annotations are part of the verification contract, and these comments send future reviewers to the wrong layer/timestamp rules. Suggested fix: replace the 613.7c / 613.4c citations with the verified CDA/static-effect/layer citations that match the intended semantics.

[MED] The new regression test does not prove the on-turn clause carries the claimed quantity. Evidence: crates/engine/src/parser/oracle_static/tests.rs:102 only checks that some SetDynamicPower exists, but it never asserts SetDynamicToughness or that both values are 2 + ObjectCount(Swamps your opponents control). Why it matters: the PR claims to recover Angry Mob's dynamic clause, but the test would still pass if the parser emitted the wrong dynamic quantity or omitted dynamic toughness. Suggested fix: mirror the nearby CDA quantity tests and match both dynamic P/T modifications against the exact QuantityExpr::Offset { offset: 2, inner: Ref(ObjectCount{Swamp, controller: Opponent}) } shape.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

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

1 card(s) · static/Continuous · added: Continuous (CDA=yes, affects=self, conditional=during your turn, mods=dynamic power, dynamic toughness)

Examples: Angry Mob

1 card(s) · static/Continuous · added: Continuous (CDA=yes, affects=self, conditional=not (during your turn), mods=base power 2, base toughness 2)

Examples: Angry Mob

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

Examples: Angry Mob

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

…indow CDA (review)

Review response (matthewevans [MED], gemini [HIGH/MED]):
- Correct CR annotations: the timing-condition peel cited CR 613.7c (counter
  timestamp ordering) — replaced with CR 611.3a (a continuous effect from a
  static ability applies per its text, so the turn window is a StaticCondition)
  + CR 604.3 (CDA). The constant P/T form cited CR 613.4c (Layer 7c modifiers) —
  replaced with CR 604.3 + CR 613.4a (Layer 7a: CDAs that define P/T). Verified
  against docs/MagicCompRules.txt. Test annotation updated to match.
- Replace the fragile `text.len() - rest.len()` manual byte slicing with
  `nom_tag_tp(&TextPair::new(text, lower), ..)`, which slices the original and
  lowercased strings in lockstep — no offset arithmetic, no case-fold panic risk.

No behavior change: 957 oracle_static tests pass, parser gate + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@minion1227

Copy link
Copy Markdown
Contributor Author

Fixed in e90cab496.

CR annotations (matthewevans, gemini) — corrected against docs/MagicCompRules.txt:

  • Timing-condition peel: CR 613.7c (counter timestamp ordering) → CR 611.3a (a continuous effect from a static ability applies at any moment to whatever its text indicates, so the "During your turn," / "During turns other than yours," window is captured as a StaticCondition) + CR 604.3 (CDA).
  • Constant P/T form: CR 613.4c (Layer 7c modifiers) → CR 604.3 + CR 613.4a (Layer 7a: effects from CDAs that define power/toughness). Test annotation updated to match.

Fragile slicing (gemini [HIGH]) — replaced text.len() - rest.len() with nom_tag_tp(&TextPair::new(text, lower), ..), which slices the original and lowercased strings in lockstep, so there is no byte-offset arithmetic and no case-fold panic risk.

No behavior change: full oracle_static suite (957) passes, parser-combinator gate + fmt clean. The two-branch parse is unchanged (dynamic Offset{Swamps, +2} gated DuringYourTurn; constant 2/2 gated Not{DuringYourTurn}).

@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 prior test-proof blocker is still present on the new head. Evidence: crates/engine/src/parser/oracle_static/tests.rs:102 still only checks that the on-turn branch contains some SetDynamicPower { .. }; it does not assert SetDynamicToughness, the +2 offset, the ObjectCount, the Swamp subtype, or ControllerRef::Opponent. Why it matters: the PR claims to recover Angry Mob's on-turn 2 + Swamps your opponents control CDA, but this test would still pass if that quantity were wrong or if toughness were not emitted. Suggested fix: match both on-turn dynamic P/T modifications against the exact QuantityExpr::Offset { offset: 2, inner: Ref(ObjectCount{Typed Swamp, controller: Opponent}) } shape, similar to the nearby CDA quantity tests.

Strengthen the two-clause turn-window CDA test so the on-turn assertion
proves the entire quantity by value — 2 + opponent Swamp count set on BOTH
power and toughness — instead of merely checking that some SetDynamicPower
mod exists. The vector-equality assertion now fails if the +2 offset, the
counted Swamp subtype, the Opponent controller scope, or the toughness
clause regress.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@minion1227

Copy link
Copy Markdown
Contributor Author

Addressed the remaining test-proof blocker in 954392eda.

The on-turn assertion no longer merely checks that some SetDynamicPower exists — it now asserts the full modification vector by value:

let two_plus_opponent_swamps = QuantityExpr::Offset {
    inner: Box::new(QuantityExpr::Ref {
        qty: QuantityRef::ObjectCount {
            filter: TargetFilter::Typed(
                TypedFilter::new(TypeFilter::Subtype("Swamp".to_string()))
                    .controller(ControllerRef::Opponent),
            ),
        },
    }),
    offset: 2,
};
assert_eq!(on_turn.modifications, vec![
    ContinuousModification::SetDynamicPower { value: two_plus_opponent_swamps.clone() },
    ContinuousModification::SetDynamicToughness { value: two_plus_opponent_swamps },
]);

This fails if the +2 offset, the counted Swamp subtype, the Opponent controller scope, or the SetDynamicToughness clause regress. The CR citations from the prior round (CR 611.3a / CR 604.3 for the turn-window peel, CR 604.3 / CR 613.4a Layer 7a for the constant CDA P/T) remain in place. Full oracle_static suite: 1008 passed.

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

Approved. I re-checked head 954392e: the change is scoped to the CDA equality parser and its tests, the parse-diff is limited to Angry Mob, the CR annotations now cite the CDA/static/layer rules, and the test asserts both dynamic P/T modifications with the +2 offset, Swamp subtype, and opponent controller scope.

@matthewevans matthewevans added the enhancement New feature or request label Jul 6, 2026
@matthewevans matthewevans enabled auto-merge July 6, 2026 12:56
@matthewevans matthewevans added this pull request to the merge queue Jul 6, 2026
Merged via the queue into phase-rs:main with commit 4071003 Jul 6, 2026
11 checks passed
@minion1227 minion1227 deleted the minion_angry_mob branch July 9, 2026 17:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants