fix(engine): anchor "after this phase" to the resolving phase (#7240) - #7505
fix(engine): anchor "after this phase" to the resolving phase (#7240)#7505mike-theDude wants to merge 1 commit into
Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…rs#7240) Overpowering Attack cast in a postcombat main untapped its creatures but added no combat phase. The parser chose `Effect::AdditionalPhase`'s `after` anchor by scanning for the literal "after this main phase", so the bare "after this phase" grammar fell through to `Phase::EndCombat` -- an anchor already in the past at resolution, leaving an unreachable entry that was cleared at end of turn. CR 500.8 inserts an extra phase directly after the specified phase, and CR 608.2c binds "this phase" at resolution. The parser now emits a resolution-time sentinel for every "after this [main|combat] phase" form and the resolver maps it via `last_step_of_phase(state.phase)`. Fixing the anchor exposed that `advance_phase_once` REPLACED the natural successor instead of inserting before it. That was invisible while every anchor in use had `next_phase(anchor) == next_phase(terminal of inserted)`, which holds for EndCombat/Upkeep/End/Untap and breaks only for main-phase anchors. `extra_phase_resume` is generalized from inserted beginning phases to all inserted phases so the turn resumes at the anchor's own successor. Also fixed, each a defect this change would otherwise have introduced: - CR 500.8: the "after this main phase" class creates nothing outside a main phase (Gatherer, Fury of the Horde: "No new phases are created"). A typed `ThisPhaseQualifier` carries the qualifier and gates that class with `CurrentPhaseIs`, clause-scoped so the untap still resolves. - The turn-boundary wrap no longer keys on `Phase` values, so a live insertion cannot skip `start_next_turn`. - The resume walk threads an unwind boundary through popped anchors, so a bundle created inside an already-inserted phase runs directly after its own combat (Aggravated Assault re-activated in the main phase it created). Behavior-preserving for the 36 combat-resolved cards: `last_step_of_phase` of any combat step is `EndCombat`, identical to the previous default. Closes phase-rs#7240 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VeUwZuMcyVaPL1BA5xQUgz
|
Hold — the conflicted branch cannot be safely ported at Merging current No maintainer push was made. The next step is an isolated maintainer port that regenerates the fixture from the merged current source, followed by fresh CI, a SHA-bound parse-diff artifact, and a new implementation review after the PR is marked ready for review. |
|
Hold — maintainer-owned port required for exact head An isolated merge of current No contributor rebase or implementation review is requested. This PR remains a draft, and CodeRabbit skipped the draft head. A maintainer must port the source and regenerate the fixture through the project’s approved, verification-capable workflow; the resulting new head then needs fresh required CI, a SHA-bound parse-diff artifact, CodeRabbit review, and implementation review before it can leave hold. |
ef5e8b0 to
99ae31f
Compare
Closes #7240
Model: claude-opus-5[1m]
Tier: Frontier
Thinking: High
The bug
Overpowering Attack cast in a postcombat main untapped its creatures but added no combat phase. The parser selected
Effect::AdditionalPhase'safteranchor by scanning for the literal"after this main phase", so the bare"after this phase"grammar fell through toPhase::EndCombat— an anchor already in the past when the sorcery resolves in a postcombat main.advance_phase_oncenever matched it and the entry was cleared at end of turn.CR 500.8 inserts an extra phase directly after the specified phase; CR 608.2c binds "this phase" at resolution. The parser now emits a resolution-time sentinel for every
"after this [main|combat] phase"form, and the resolver maps it vialast_step_of_phase(state.phase).Why the diff is larger than a parser patch
Fixing the anchor exposed that
advance_phase_oncereplaced the natural successor with the inserted phase rather than inserting before it. That was invisible because anchor-successor and inserted-terminal-successor coincide for every anchor previously in use, and diverge only for main-phase anchors — exactly this issue's class:next_phase(anchor)next_phase(terminal of inserted)Evidence this was already shipping broken, not hypothetical:
PreCombatMainpath and already lost its natural combat. Thebundle_anchorEndCombatre-anchor existed only to paper over this.rposition(anchor == PostCombatMain)never matchesanchor == PreCombatMain) — orphaned, then cleared at the turn boundary.GameState.extra_phase_resumeis therefore generalized from inserted beginning phases only to all inserted phases (Vec<Phase>→Vec<ExtraPhaseResume { anchor, inserted }>), so the turn resumes at the anchor's own successor.Defects fixed that this change would otherwise have introduced
Each was found by review and is pinned by a revert-failing test.
CR 500.8 — the "after this main phase" class must create nothing outside a main phase. Gatherer is explicit (Fury of the Horde: "If it's somehow not a main phase when Fury of the Horde resolves, all it does is untap all creatures that attacked that turn. No new phases are created."; Relentless Assault: "creates an additional combat and main phase only if it resolves during a main phase."). The old code satisfied this by accident — the sentinel stayed
PreCombatMain, a dead anchor. A typedThisPhaseQualifier { Unqualified, Main, Combat }now carries the qualifier the combinator was discarding, andthis_phase_anchor_gategates theMainclass withAbilityCondition::CurrentPhaseIs { [PreCombatMain, PostCombatMain] }. The gate is clause-scoped, so the untap still resolves, per the rulings. BareCurrentPhaseIswithoutIsYourTurn, because Relentless Assault's other ruling confirms casting in an opponent's main phase works.The turn-boundary wrap could be skipped by a live insertion.
state.phase == Cleanup && next == Untapmisses when an insertion is consumed leavingCleanup, and misses again when the unwind resumes.start_next_turnwould not run: no turn increment, no active-player rotation, no reset of the per-turn ledger. Now keyed on "no extra phase consumed and no resume owed" rather than onPhasevalues. Not reachable with printed cards — every instant in the bare-anchor class is combat-restricted (ActionNotAllowed("Casting restriction not satisfied: DuringCombat")) and Great Train Heist'scombat phasegate is false in an end step — but the resolver does produceanchor: Cleanup, so the guard is hardened and pinned.A bundle created inside an already-inserted phase ran out of order. Aggravated Assault re-activated in the additional main phase its first activation created produced
C1, Main, C2, C3, Main, Maininstead ofC1, Main, C2, Main, C3, Main. The resume walk now threads an unwind boundary through popped anchors so each insertion keeps its own frame. Both stacks always drained, so this was pure mis-ordering — invisible to any count-based assertion.Serde compat.
extra_phase_resume's element type changed andPhaseEntryOutcome::Pausedis a durable save point, so a session persisted mid-insertion would fail to restore. AnExtraPhaseResumeCompatuntagged shim accepts the legacy bare-Phasepayload. All committed fixtures record[]; the risk was live sessions across a deploy.Card class
48 cards. 36 bare
"after this phase"/"after this combat phase"(Aurelia, Godo, Combat Celebrant, Najeela, Scourge of the Throne, …) — all resolve inside a combat step, wherelast_step_of_phaseisEndCombat, bit-identical to the previous default. 10"after this main phase"(Relentless Assault, Full Throttle, Aggravated Assault, …) — now correctly gated. 1"after this combat phase"(Raphael, Tag Team Tough). The Group C cards this issue is about — Overpowering Attack, Moraug, All-Out Assault, Grim Reaper's Sprint, Sokenzan, Valor's Reach — are fixed.afterflips fromEndCombatto the sentinel for 47 cards incard-data.json; only Bear with Set's Mechanic, Save Point, Swinging Ship, Throat Wolf and World at War keep the legacy default.Behavior changes worth maintainer attention
Anchored on
crates/engine/src/parser/oracle_effect/imperative.rs:7624— existingnom_primitives::scan_at_word_boundariesword-boundary scan feeding a composed combinator; the newparse_this_phase_anchordispatch mirrors it.crates/engine/src/parser/oracle_effect/imperative.rs:9797— existingscan_at_word_boundariesuse forparse_additional_phase_count, the sibling axis of the same producer.crates/engine/src/game/effects/additional_phase.rs:54— the inserted-beginning-phase branch already resolving its anchor aslast_step_of_phase(state.phase); the new sentinel branch is the same pattern generalized.crates/engine/src/parser/oracle_effect/imperative.rs:1056— existingunreachable!()convention in the same lowering function, followed for theGatedEffectarm.Gate A
Verification
All run directly in the worktree (Tilt does not watch it), against the committed head:
cargo fmt --all -- --checkcargo clippy --all-targets -- -D warningscargo nextest run -p phase-engine --libcargo nextest run -p phase-engine --test integrationcargo nextest run --workspace --no-fail-fast./scripts/check-parser-combinators.sh origin/main./scripts/check-interaction-bindings.sh./scripts/check-prelowered-ratchet.shcargo coverageThe single workspace failure is
mtgish-import::manifest_coverage every_list_field_is_in_ordering_manifest, which is pre-existing onorigin/main— the fields it names (ResolvedAbility::selected_target_incarnations,SpellContext::parent_target_iteration_members) exist untouched at the base commit and this diff contains nomtgish-importchange.Every fix in this PR was revert-verified: the fix was reverted locally, the named test confirmed red, then restored.
Disclosures
review-implgate (AI-CONTRIBUTOR §5) was not satisfied. Two independent review passes were run against an earlier revision of this diff and both produced findings that were addressed (they are the source of defects 1–4 above). Two further review passes over the final revision did not complete — one was killed to break a cargo-fingerprint deadlock, the other lost its API connection. The final revision is therefore self-verified and gate-verified but not independently reviewed, and is opened as a draft for that reason.crates/engine/tests/fixtures/integration_cards.json.gzwas patched surgically, not regenerated. A fullgen-test-fixture.pyrun in this environment changed 150 entries and dropped 5 cards other tests reference, because the only localAtomicCards.jsonis older than the vintage the committed fixture was built from. The applied delta is exactly 15 cards (12afterflips + 3 newconditiongates), byte-canonicalgzip -9 -n, with zero card additions or removals. Please regenerate on a machine with current MTGJSON before merge.python3 scripts/gen-test-fixture.py --checkfails in this worktree with 26 uncovered cards; verified pre-existing by restoring the committed fixture and re-running (identical failure). It is not wired into CI.CurrentPhaseIsis classifiedHandled, theafterchange is a value change, and the condition arms remove swallowed-clause warnings (verified: Sokenzan, Valor's Reach and Great Train Heist each now showparse_warnings: 0).Out of scope (documented, unchanged)
additional_phase.rs— it drops any extra phase whencontroller != active_player, but CR 500.10a restricts only the "you get" wording, so Take the Bait and Illusionist's Gambit are wrongly dropped on an opponent's turn. Behavior identical before and after this change."additional upkeep step"/"additional end step"producers still hard-code their anchors and were left out of this unification; aKNOWN GAPcomment records that Obeka's entries anchor in the past and never fire.🤖 Generated with Claude Code
https://claude.ai/code/session_01VeUwZuMcyVaPL1BA5xQUgz