fix(engine): make the "before attackers declared" window phase-based (CR 508.1/508.2)#5192
Merged
Merged
Conversation
…(CR 508.1/508.2)
`is_before_attackers_declared` gated the window on `priority_seat == active_player`,
which is only ever true for the active player. Per CR 508.1/508.2 attackers are
declared as the first turn-based action of the declare-attackers step, before any
player receives priority, so "before attackers are declared" is a pure phase
property (PreCombatMain | BeginCombat) independent of who holds priority.
The old clause wrongly blocked non-active actors' "before attackers" cast/activation:
- Master Warcraft ("Cast this spell only before attackers are declared") was
uncastable by a non-active player during another player's combat.
- Siren's Call ("only during an opponent's turn, before attackers are declared")
was entirely uncastable front-door (the two casting restrictions were mutually
exclusive under the priority-seat clause).
Safe for the 26 `[DuringYourTurn, BeforeAttackersDeclared]` cards: DuringYourTurn
(AND-composed) independently pins them to the active player, so the phase-only
window cannot widen them. Adds three runtime restriction-check tests (non-active
caster admitted with an active-player reach-guard; window closes at/after
declaration; DuringYourTurn cards stay active-player-gated across both phases).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Contributor
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
Parse changes introduced by this PR✓ No card-parse changes detected. |
matthewevans
approved these changes
Jul 6, 2026
matthewevans
left a comment
Member
There was a problem hiding this comment.
Approved. I checked the restriction seam and CR boundary: BeforeAttackersDeclared is enforced in the shared casting/activation restriction predicate, and CR 508.1/508.2 make this a phase boundary before declare-attackers priority, not an active-player priority-seat check.
The added tests are discriminating: the non-active caster case fails on the old priority-seat gate, the declare-attackers/post-combat phases stay closed, and [DuringYourTurn, BeforeAttackersDeclared] activations remain active-player gated.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
is_before_attackers_declared(the shared predicate behind theBeforeAttackersDeclaredcasting and activation restrictions) gated the window onpriority_seat(state) == state.active_player. That clause is only ever satisfiable by the active player, so it wrongly closed the "before attackers are declared" window for non-active actors.Per CR 508.1 ("First, the active player declares attackers. This turn-based action doesn't use the stack") + CR 508.2 ("Second, the active player gets priority"), attackers are declared as a turn-based action before any player receives priority. So "before attackers are declared" is a pure phase property — the
PreCombatMain | BeginCombatrun-up — independent of who currently holds priority. This PR drops the priority-seat clause and keeps the phase check.What was broken (and is now fixed)
DuringOpponentsTurnneedsactive_player != caster, but the window needed the caster's seat == active player) → entirely uncastable front-door.Why it's safe
26 of the 28
BeforeAttackersDeclaredcards are[DuringYourTurn, BeforeAttackersDeclared](Portal Three Kingdoms tap-ability cycle, King's Assassin, etc.). Restrictions are AND-composed, andDuringYourTurn => active_player == playerindependently pins those to the active player — so a phase-only window cannot widen them. The only twoBeforeAttackersDeclared-without-DuringYourTurncards (Master Warcraft, Siren's Call) are exactly the intended beneficiaries. No existing test exercises the old priority-gated behavior; turn-control (CR 723) can't affect a check that never reads priority.Anchored on
is_before_combat_damage/is_sorcery_speed_window: sibling timing predicates that key onstate.phase(phase-based window pattern).ActivationRestriction::DuringYourTurn => state.active_player == player: the AND-composed active-player gate that keeps the 26 turn-qualified cards pinned.Test Plan
Three runtime restriction-check tests drive the production entry points (
check_casting_restrictions/check_activation_restrictions):before_attackers_window_admits_non_active_caster— a non-active player is inside the window in both PreCombatMain and BeginCombat (with an active-player reach-guard). Fail-on-revert measured: restoring the priority-seat clause makes only this test fail.before_attackers_window_closes_at_and_after_declaration— window closed at DeclareAttackers/DeclareBlockers/PostCombatMain, open at BeginCombat (reach-guard).during_your_turn_before_attackers_stays_active_player_gated—[DuringYourTurn, BeforeAttackersDeclared]stays legal on the controller's own turn / illegal on an opponent's turn, across both phases (non-regression).Verification (Non-developer track; Tilt down → direct cargo)
cargo test -p engine— 196 test binaries, 0 failures--workspace --exclude phase-tauri --all-targets --features engine/proptest -- -D warnings) — exit 0cargo check -p engine-wasm --target wasm32-unknown-unknown— exit 0cargo fmt --all— clean./scripts/check-parser-combinators.sh) — exit 0 (no parser change)Model: claude-opus-4-8
Thinking: high
Tier: Frontier
Track: Non-developer (card-data.json integration deferred to CI per docs/AI-CONTRIBUTOR.md §2)