Skip to content

chore: update coverage stats and badges - #2

Merged
matthewevans merged 1 commit into
mainfrom
chore/update-coverage-badges
Apr 8, 2026
Merged

chore: update coverage stats and badges#2
matthewevans merged 1 commit into
mainfrom
chore/update-coverage-badges

Conversation

@matthewevans

Copy link
Copy Markdown
Member

Automated update of README coverage badges from latest card data.

@matthewevans
matthewevans enabled auto-merge (squash) April 8, 2026 12:53
@matthewevans
matthewevans merged commit c171980 into main Apr 8, 2026
2 checks passed
@matthewevans
matthewevans deleted the chore/update-coverage-badges branch April 8, 2026 12:58
matthewevans added a commit that referenced this pull request Apr 11, 2026
Cards with target-fallback warnings have silently degraded targeting
(TargetFilter::Any instead of a specific filter). These were previously
counted as "supported" despite incorrect runtime behavior.

Now:
- check_parse_warnings() marks cards with target-fallback warnings as
  having gaps, consistent with how Unimplemented/Unknown are handled
- ParseWarning gaps appear in gap_details for per-card reporting
- Coverage corrected from 81.7% → 80.3% (465 false positives removed)
- ParseWarning:target-fallback is now the #2 gap category (687 total,
  429 single-gap cards), giving proper visibility for prioritization
matthewevans added a commit that referenced this pull request Apr 27, 2026
…edicate

The previous version of `drain_pending_repeat_iteration_restashes_on_synchronous_continuation`
constructed a Draw + sub-Draw setup that completed each iteration cleanly
without ever installing a `pending_continuation` synchronously — meaning
the `installed_continuation` predicate was never evaluated true. The
test passed regardless of whether the predicate existed.

Rewrite the test to use `ConditionInstead` with an `else_ability` and a
non-Priority pre-set `waiting_for`, which exercises the line-1486 path
that synchronously stashes the else branch into `pending_continuation`
without changing `waiting_for`. Verified by temporarily disabling the
`installed_continuation` predicate: the test now FAILS with the exact
"pending_continuation overwritten before consumption" debug_assert that
finding #2 describes.

Asserts after one resumed iteration:
- `pending_continuation` is Some (else_ability was stashed synchronously)
- `pending_repeat_iteration` is re-stashed with `next_iteration = 2`
- only iteration 1's parent Draw fired (1 card), proving the drain
  broke immediately on the synchronous-continuation transition

PR #124 review feedback (finding #2 — proper test coverage).
matthewevans added a commit to AlexD-Richardson/phase that referenced this pull request Apr 27, 2026
…handle synchronous-continuation drain

Two interlocking bugs in the `repeat_for` resume path surfaced during
PR phase-rs#124 review of overloaded Winds of Abandon:

1. Iteration-resume path dropped sub_ability for iterations 2+. The
   stash site cleared `resume_ability.sub_ability = None` and the drain
   called `resolve_effect` directly, bypassing the chain-level wiring at
   line 1461 that stashes the SearchChoice continuation. For iteration
   0, the put-onto-battlefield + shuffle continuation runs correctly;
   for iterations 1+, it never ran at all — opponents 2+ would be
   prompted to pick a card but their chosen card stayed in their library.

   Fix: keep `sub_ability` on the resumed copy and clear `repeat_for`
   instead (so the resume call doesn't re-enter the outer iteration
   loop). The drain now calls `resolve_ability_chain` (depth=1, to
   preserve chain-local tracked-set state) so each resumed iteration
   goes through the same line-1660 SearchChoice continuation wiring as
   iteration 0. Per-iteration parent-target rebinding still propagates
   correctly because `rebind_first_object_target` updates `iter_ability.targets`,
   which the line-1651 sub_ability propagation copies onto the
   continuation chain.

2. Synchronous-continuation case was unhandled. If an iteration set
   `pending_continuation` without changing `waiting_for`, the inner
   loop would increment and run the next iteration — clobbering the
   continuation — or, on the trailing iteration, fall through and break
   without re-stashing remaining iterations.

   Fix: detect a None→Some `pending_continuation` transition and
   re-stash with `next_iteration = iteration + 1` before breaking, so
   the outer drain runs the continuation and then re-enters this drain
   for the next iteration.

Tests:
- `repeat_for_resumed_iteration_runs_full_sub_ability_chain`:
  end-to-end across two distinct opponents, asserts both chosen lands
  land on the battlefield AND both Shuffle resolutions emit
  EffectResolved events. Would have caught finding phase-rs#1 directly.
- `drain_pending_repeat_iteration_restashes_on_synchronous_continuation`:
  exercises the synchronous-continuation case with a multi-iteration
  resume that completes without entering any choice state.

PR phase-rs#124 review feedback (findings phase-rs#1, phase-rs#2, phase-rs#5 first/second tests).
matthewevans added a commit to AlexD-Richardson/phase that referenced this pull request Apr 27, 2026
…edicate

The previous version of `drain_pending_repeat_iteration_restashes_on_synchronous_continuation`
constructed a Draw + sub-Draw setup that completed each iteration cleanly
without ever installing a `pending_continuation` synchronously — meaning
the `installed_continuation` predicate was never evaluated true. The
test passed regardless of whether the predicate existed.

Rewrite the test to use `ConditionInstead` with an `else_ability` and a
non-Priority pre-set `waiting_for`, which exercises the line-1486 path
that synchronously stashes the else branch into `pending_continuation`
without changing `waiting_for`. Verified by temporarily disabling the
`installed_continuation` predicate: the test now FAILS with the exact
"pending_continuation overwritten before consumption" debug_assert that
finding phase-rs#2 describes.

Asserts after one resumed iteration:
- `pending_continuation` is Some (else_ability was stashed synchronously)
- `pending_repeat_iteration` is re-stashed with `next_iteration = 2`
- only iteration 1's parent Draw fired (1 card), proving the drain
  broke immediately on the synchronous-continuation transition

PR phase-rs#124 review feedback (finding phase-rs#2 — proper test coverage).
matthewevans added a commit that referenced this pull request Apr 27, 2026
* Add Winds of Abandon

* fix(overload): spell out ChangeZone field drops explicitly (no `..` rest)

The overload `Effect::ChangeZone → ChangeZoneAll` arm previously used
`..` to absorb every field other than `origin`/`destination`/`target`.
Today's dropped fields are semantically inert for hidden-zone exile,
but `..` would silently absorb any newly-added `ChangeZone` field too,
meaning a future field addition could go missing in the overloaded form
without compiler help.

Bind every field by name and annotate the rationale for each drop. Adding
a new `ChangeZone` field will now fail to compile here, forcing a
deliberate decision about overload semantics.

PR #124 review feedback (finding #3).

* fix(engine): preserve sub_ability on resumed repeat_for iterations + handle synchronous-continuation drain

Two interlocking bugs in the `repeat_for` resume path surfaced during
PR #124 review of overloaded Winds of Abandon:

1. Iteration-resume path dropped sub_ability for iterations 2+. The
   stash site cleared `resume_ability.sub_ability = None` and the drain
   called `resolve_effect` directly, bypassing the chain-level wiring at
   line 1461 that stashes the SearchChoice continuation. For iteration
   0, the put-onto-battlefield + shuffle continuation runs correctly;
   for iterations 1+, it never ran at all — opponents 2+ would be
   prompted to pick a card but their chosen card stayed in their library.

   Fix: keep `sub_ability` on the resumed copy and clear `repeat_for`
   instead (so the resume call doesn't re-enter the outer iteration
   loop). The drain now calls `resolve_ability_chain` (depth=1, to
   preserve chain-local tracked-set state) so each resumed iteration
   goes through the same line-1660 SearchChoice continuation wiring as
   iteration 0. Per-iteration parent-target rebinding still propagates
   correctly because `rebind_first_object_target` updates `iter_ability.targets`,
   which the line-1651 sub_ability propagation copies onto the
   continuation chain.

2. Synchronous-continuation case was unhandled. If an iteration set
   `pending_continuation` without changing `waiting_for`, the inner
   loop would increment and run the next iteration — clobbering the
   continuation — or, on the trailing iteration, fall through and break
   without re-stashing remaining iterations.

   Fix: detect a None→Some `pending_continuation` transition and
   re-stash with `next_iteration = iteration + 1` before breaking, so
   the outer drain runs the continuation and then re-enters this drain
   for the next iteration.

Tests:
- `repeat_for_resumed_iteration_runs_full_sub_ability_chain`:
  end-to-end across two distinct opponents, asserts both chosen lands
  land on the battlefield AND both Shuffle resolutions emit
  EffectResolved events. Would have caught finding #1 directly.
- `drain_pending_repeat_iteration_restashes_on_synchronous_continuation`:
  exercises the synchronous-continuation case with a multi-iteration
  resume that completes without entering any choice state.

PR #124 review feedback (findings #1, #2, #5 first/second tests).

* fix(parser): narrow normalize_verb_token's -es rule to known predicate verbs

The previous orthographic rule stripped `-es` whenever the resulting
stem ended in `ch`/`sh`/`ss`/`x`/`z`. This was correct for
`searches → search` but over-applied to the `-eze`/`-eeze` family,
producing invented stems like `freezes → freez`, `breezes → breez`,
`sneezes → sneez` — none of which match any downstream lookup, but they
violate the project's "parser must not swallow" rule by silently
fabricating non-existent words for unknown inputs.

Narrow the rule to only strip `-es` when the resulting stem is a
registered `PREDICATE_VERBS` member. Unknown verbs now pass through
unchanged, and the only verbs that take the strip are ones the parser
explicitly knows about. Adds a regression test that asserts neither
`freezes` nor `breezes` nor `sneezes` produces an invented stem, while
the registered `searches → search` case still works.

PR #124 review feedback (findings #4, #5 third test).

* test(engine): genuinely exercise synchronous-continuation re-stash predicate

The previous version of `drain_pending_repeat_iteration_restashes_on_synchronous_continuation`
constructed a Draw + sub-Draw setup that completed each iteration cleanly
without ever installing a `pending_continuation` synchronously — meaning
the `installed_continuation` predicate was never evaluated true. The
test passed regardless of whether the predicate existed.

Rewrite the test to use `ConditionInstead` with an `else_ability` and a
non-Priority pre-set `waiting_for`, which exercises the line-1486 path
that synchronously stashes the else branch into `pending_continuation`
without changing `waiting_for`. Verified by temporarily disabling the
`installed_continuation` predicate: the test now FAILS with the exact
"pending_continuation overwritten before consumption" debug_assert that
finding #2 describes.

Asserts after one resumed iteration:
- `pending_continuation` is Some (else_ability was stashed synchronously)
- `pending_repeat_iteration` is re-stashed with `next_iteration = 2`
- only iteration 1's parent Draw fired (1 card), proving the drain
  broke immediately on the synchronous-continuation transition

PR #124 review feedback (finding #2 — proper test coverage).

---------

Co-authored-by: Matt Evans <1388610+matthewevans@users.noreply.github.com>
matthewevans added a commit that referenced this pull request May 20, 2026
Skullwinder: "When this creature enters, return target card from your
graveyard to your hand, then choose an opponent. That player returns a
card from their graveyard to their hand."

The engine resolved the second card selection as the *casting player's*
choice. Per CR 608.2c (the "rules of English" make "That player" the
just-chosen opponent), CR 608.2d (the player resolving the choice
announces it), and the card's own ruling ("The chosen opponent gets to
choose which card to return from their graveyard to their hand"), the
chosen opponent makes that choice.

Root cause was a parser defect compounded by a resolver gap:

1. Parser: the trigger AST dropped "then choose an opponent" entirely.
   `try_parse_choose_player_to_verb` only recognized `tag("choose a ") +
   tag("player")`, and `sequence::starts_clause_text_lower` did not
   include "choose " so the chunk splitter glued "then choose an
   opponent" onto the preceding return-card clause. The dependent
   second-sentence Bounce was then scoped to `ScopedPlayer`, which
   falls back to the controller — the agency bug.

2. Resolver: non-targeted graveyard-return `Bounce` had no branch at
   all. `resolved_targets` returns empty for a `Typed` filter with no
   pre-selected target, so the loop ran zero times and the sub-ability
   silently no-op'd even after the parser fix.

Fixes:

- `parser/oracle_effect/mod.rs::try_parse_choose_player_to_verb`:
  extend the head-noun dispatch from `tag("choose a ") + tag("player")`
  to `tag("choose a") + alt((player_arm, opponent_arm))`, carrying the
  resulting `ChoiceType` into the emitted `Effect::Choose`. Add a
  leading optional `tag("then ")` strip for chunks that bypass
  `strip_leading_sequence_connector`.
- `parser/oracle_effect/mod.rs::retarget_effect_to_chosen_player`:
  add an `Effect::Bounce` arm that calls a new
  `rebind_owned_scope(filter, index)` helper. Bounce's recipient lives
  in `FilterProp::Owned { controller }` (CR 109.4 — graveyard cards
  are owned, not controlled), so the rebind tree-walks the filter
  properties rather than rewriting a top-level player slot.
- `parser/oracle_effect/mod.rs` (subject-application post-pass): when
  the subject phrase resolved to a `ChosenPlayer { index }`-scoped
  filter ("That player" after a `Choose(Opponent)`), call
  `rebind_owned_scope` on the resulting `Effect::Bounce.target` so the
  predicate's possessive "their" — which `parse_target` always emits
  as `Owned { ScopedPlayer }` — binds to the chosen player.
- `parser/oracle_effect/subject.rs::parse_subject_application`: add a
  `ChosenPlayer` arm to the "that player" subject handler so the
  cross-sentence anaphora binds to the just-chosen player, mirroring
  the existing `resolve_they_pronoun` `ChosenPlayer` branch (the
  "They" form Gluntch exercises; this is the "That player" form
  Skullwinder exercises). Replace the local pre-existing CR 608.2k
  cite with CR 608.2c — the controlling rule for the anaphor.
- `parser/oracle_effect/sequence.rs::starts_clause_text_lower`: add
  "choose " to the imperative-verb prefix list (one entry in an
  existing `alt()` group, no permutation expansion) so chunks split
  at "..., then choose an opponent". Without this, chunk #2 stays
  glued to chunk #1 and `try_parse_choose_player_to_verb` is never
  invoked. Adjacent bug; fixed inline.
- `game/effects/bounce.rs::resolve`: add a non-targeted
  graveyard-return branch. When `resolved_targets` is empty and the
  filter carries `FilterProp::InZone { Graveyard }`, walk the filter
  for a `ChosenPlayer { index }` ref (top-level controller OR nested
  `Owned` filterprop), recover the concrete `PlayerId` from
  `ability.chosen_players`, enumerate that player's graveyard against
  the filter, and either move the sole match directly or surface a
  `WaitingFor::EffectZoneChoice { player: selecting_player, .. }`
  routed through `EffectKind::ChangeZone` (which the existing intake
  honors for graveyard → hand). The selecting player is the chosen
  opponent for chosen-scoped filters; falls back to `ability.controller`
  otherwise (same-controller graveyard returns).

CR cites verified against docs/MagicCompRules.txt:
  - CR 608.2c (line 2789): "The controller of the spell or ability
    follows its instructions in the order written. ... read the whole
    text and apply the rules of English."
  - CR 608.2d (line 2791): "If an effect of a spell or ability offers
    any choices ... the player announces these while applying the
    effect."
  - CR 109.4 (line 594): "Only objects on the stack or on the
    battlefield have a controller. Objects that are neither on the
    stack nor on the battlefield aren't controlled by any player."

Tests (both discriminate — confirmed by inverting the fix and watching
each fail before reverting):
  - `parser::oracle_effect::tests::skullwinder_etb_parses_choose_opponent`
    asserts the trigger AST is `Bounce → Choose { Opponent } → Bounce`
    with `Owned { ChosenPlayer { 0 } }` in the dependent filter — no
    `ScopedPlayer` anywhere in the chain.
  - `tests/integration/skullwinder_chosen_opponent.rs::
    skullwinder_chosen_opponent_picks_their_own_card` constructs the
    dependent Bounce shape the fixed parser emits, calls
    `bounce::resolve` with `chosen_players = [P1]`, and asserts the
    `EffectZoneChoice.player` is P1 (the chosen opponent) — not P0
    (the caster). Also asserts P0's graveyard card is NOT a candidate
    (ownership-scoped filtering).

The fix covers the general "Choose(Opponent) → That player <verb>"
sentence class — Skullwinder is one instance; the parser change
unlocks the full group-politics template that prints "that player
returns/draws/discards" after a chosen-player binding.
matthewevans added a commit that referenced this pull request May 23, 2026
…(Asinine Antics)

Replace the single-slot view + CopyTokenOf matches! special-case in
effect_iterates_over_parent_target with effect_parent_ref_slots: the single
source of truth for every object-target slot that may carry a parent ref,
including slots target_filter() hides (CopyTokenOf copy-source, Token attach_to,
Attach attachment).

This makes Asinine Antics ("for each creature an opponent controls, create a
Cursed Role attached to that creature") member-driven: the repeat_for:
ObjectCount loop now rebinds ParentTarget per iteration, so each Role attaches to
a distinct opponent creature. Rebind stays single-slot — every reachable
member-driven card has exactly one parent-ref object slot; the Attach-under-
for-each two-slot case has zero reachable consumers and is deferred.

Tests live in token.rs (the token-resolution context) because they exercise this
gate end-to-end through resolve_ability_chain; test #2 is discriminating (the
pre-gate code leaves both Roles unattached).

CR 109.5 + CR 303.4 + CR 603.7.
matthewevans pushed a commit that referenced this pull request May 24, 2026
Three reviewer findings on the prior PerTurnCastLimit work for
Ethersworn Canonist:

FINDING #1 (MEDIUM): the conditional-subject combinator inlined
`tag("each player who has cast ")` and hard-coded
`ProhibitionScope::AllPlayers`, bypassing the shared
`strip_casting_prohibition_subject` building block. A future card phrased
as "Each opponent who has cast ..." would have reduced to zero
coverage. Restructured to strip the subject prefix via the shared
helper first, then nom-match `who has cast (a|an) <SUBJ> spell this
turn can't cast additional <OBJ> spells`. Two class tests
(`each_opponent_scope`, `you_scope`) lock in the subject axis.

FINDING #2 (MEDIUM): CR citations referenced 101.2 + 604.1 ("can't"
beats "can" + static enforcement), but the *authorizing rule for the
casting prohibition itself* (CR 601.2 + CR 601.3a) was missing.
Verified both rule numbers against docs/MagicCompRules.txt before
adding. Updated citations on the combinator docstring, the caller's
inline comment, and the swallow_check marker comment.

LOW #4 (carry-over): swallow marker was a bare `"PerTurnCastLimit"`
substring — could false-positive on the literal string appearing in a
description or unrelated location. Tightened to the serde external-tag
shape `"\"PerTurnCastLimit\":{"` (and same for PerTurnDrawLimit),
matching the precision class of the existing
`"condition":{"type":"Unrecognized"` marker on the line above.

Verification:
- cargo fmt --all: clean
- cargo clippy --all-targets -- -D warnings: clean
- cargo test -p engine: 8187 lib + 422 integration + 7 doctest, all pass
- oracle-gen for "ethersworn canonist": no Unimplemented, no
  parse_warnings, AST unchanged from baseline

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mike-theDude pushed a commit to mike-theDude/phase that referenced this pull request May 25, 2026
…ct count

- engine.rs: populate current_trigger_match_count from
  pending_optional_trigger_match_count before drain_pending_continuation
  so EventContextAmount resolves the correct "that many" in the resumed
  continuation (Gemini comment #1 — logic error in state restoration)

- trigger_matchers.rs: add CR 603.2c doc annotation to
  trigger_event_subject_ids (comment phase-rs#2); extend match to return
  ObjectId arms for TokenCreated, CreatureDestroyed, PermanentSacrificed,
  PermanentTapped, PermanentUntapped, and DamageDealt (object target
  only, per CR 120.3) — previously these all fell through to
  std::iter::empty() (comment phase-rs#3)

- quantity.rs: replace .map(|n| n as i32) with .map(u32_to_i32_saturating)
  for consistency with the rest of the file (comment phase-rs#4)

Verified: cargo clippy --all-targets -- -D warnings (clean),
cargo test -p engine (8956 passed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
AgilErck added a commit to AgilErck/phase that referenced this pull request May 28, 2026
Adds a second bundled cEDH demo deck and resolves the loop-duplication
the previous commit's code review flagged as an Important follow-up.

Deck phase-rs#2: BundledCedh_InallaThoracle_Demo (Grixis = U/B/R color identity)
features the Thassa's Oracle + Demonic Consultation registered combo
(both off-color in mono-white, hence why Heliod's deck couldn't host
them). Legal 99-card mainboard: 30 curated singletons (combo + tutors
+ counterspells + cantrips + fast mana + removal + 5 colorless utility
lands) + 35 Islands + 34 Swamps. No Plains/Forests/Mountains and no
White/Green spells per CR 903.4.

Helper extraction: pushPreconCandidate consolidates the shared push
shape both precon flows (MTGJSON catalog and bundled cEDH) need. The
helper takes the bracket as a parameter so MTGJSON entries continue
to look up via getPreconBracket while bundled entries pass the literal
CEDH_BRACKET. The bundled-specific id-collision guard
(`if (decks && decks[deckId]) continue;`) stays at the call site —
it's semantically bundled-specific, not helper-shared.

Adding a third bundled deck now requires one BUNDLED_CEDH_DECKS entry
and zero deckCatalog.ts changes — class-not-card extensibility per
CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
matthewevans pushed a commit to AgilErck/phase that referenced this pull request May 28, 2026
…hase-rs#1266)

* parser: add 'is/are returned to [possessive] hand' trigger condition

Add try_parse_returned_to_hand() for bounce-trigger zone-change
events (CR 603.6c + CR 603.10a). Handles possessive variants:
your hand, a player's hand, its/their owner's hand, bare hand.

Cards unlocked: Warped Devotion, Azorius Aethermage,
Stormfront Riders, Tameshi Reality Architect.

* review: shared parse_hand_possessive, merge owner into valid_card, fix CR format

Address review comments on PR phase-rs#1266:

1. Gemini #1: Extract local parse_returned_hand / parse_hand_possessive
   into a shared module-level parse_hand_possessive() that both
   try_parse_put_into_hand_from and try_parse_returned_to_hand call.
   Returns Option<ControllerRef> for cleaner semantics.

2. Gemini phase-rs#2: Reformat doc comment to CR <number>: <description> style.

3. Codex #1: Move hand-owner constraint from valid_target (not read by
   match_changes_zone) to valid_card via add_controller(), so the
   zone-change matcher correctly filters by the bounced permanent's
   controller.

---------

Co-authored-by: Whovencroft <6.60056e+06+Whovencroft@users.noreply.github.com>
carlos4s pushed a commit to carlos4s/phase that referenced this pull request May 29, 2026
* docs: cEDH AI difficulty design spec

Add a design spec for AiDifficulty::CEDH covering:
- Difficulty preset that bypasses 4-player paranoid search scaling
- Bracket-5 game-setup lock with AI cascade and warning chip
- Combo-recognition skeleton: ComboLine types, ComboDetector trait,
  ComboRegistry, a ComboLinePolicy gated by difficulty, and a stub
  CedhMulliganPolicy
- Engine-side validate_cedh_bracket as a tag check against the
  manual-declaration-only CommanderBracketTier::Cedh

Scope is intentionally skeleton-only: real combo lines, archetype
tuning, backward-chaining synthesis, and multiplayer cEDH gating
are explicit non-goals for this phase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: correct cEDH spec gating mechanism

Update sections 4, 5.3, 5.4, and 7 to reflect the actual
TacticalPolicy::activation() and MulliganPolicy::evaluate()
gating patterns in the codebase:

- ComboLinePolicy gates via activation() returning Some/None
  keyed off a new DeckFeatures::is_cedh field, not via
  registration-time difficulty checks (the PolicyRegistry uses
  a OnceLock shared instance and is registration-stable).
- CedhKeepablesMulligan follows the existing
  *KeepablesMulligan naming convention and gates internally
  via features.is_cedh inside evaluate(), matching the pattern
  used by AggroKeepablesMulligan et al.
- DeckFeatures gains an is_cedh field populated from the
  deck's declared CommanderBracketTier at deck-analysis time.

Original design intent unchanged; only the integration-point
mechanics are corrected against the live trait signatures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: cEDH difficulty implementation plan

Bite-sized TDD plan covering 9 phases (~25 tasks):
1. AiDifficulty::CEDH variant + preset + 4p scaling skip
2. DeckFeatures::is_cedh field + population from tier
3. Engine validate_cedh_bracket tag check
4. combo/ module (line + detection + registry stub)
5. ComboLinePolicy gated via activation()
6. CedhKeepablesMulligan gated internally
7. Frontend cedhLock service + dropdown + cascade + filter + chip
8. Engine validation wired into WASM/Tauri/server bridges
9. End-to-end integration test + verification sweep

Each task: write failing test, run to confirm fail, write
minimal impl, run to confirm pass, commit. Verification uses
the Tilt-first pattern from CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(phase-ai): AiDifficulty::CEDH variant + preset

Adds CEDH enum variant (highest ordinal, after VeryHard) with its
create_config() match arm: temperature 0.2, depth 3, nodes 96,
rollout 2x2, combat_lookahead=true (first tier to enable it),
projection_min_budget_ms=1500. WASM caps apply: depth 2, nodes 64.

Extends ai_difficulty_serde_roundtrips to cover CEDH. Adds
cedh_preset_values and cedh_preset_wasm_caps_apply tests.

draft-wasm/bot_ai.rs: CEDH uses same full-evaluation pick strategy
as VeryHard.

Tasks 1.1 and 1.2 batched into one commit: the pre-commit hook
requires builds to pass and a standalone Task 1.1 (missing match
arm) would fail clippy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(phase-ai): cEDH bypasses 4p paranoid scaling

cEDH is exclusively played at 4-player tables and is calibrated
for that count from the base preset. The generic 3-4p scaler
(cap depth at 2, reduce nodes to 2/3) would cripple it.
Non-cEDH difficulties continue to follow paranoid scaling
unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(phase-ai): add combo_progress_* PolicyPenalties fields

Tunable bonuses for combo-progress prior boosts. Consumed by
ComboLinePolicy (next phase). Defaults +15.0 (this turn) and
+5.0 (next turn) match the spec; both serde-default so older
saved configs deserialize cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(phase-ai): cEDH Phase 1 quality cleanups

- Update create_config doc comment (Five->Six presets, drop
  stale VeryHard reference).
- Invert the cEDH bypass in create_config_for_players to
  avoid an empty-if + non-empty-else (CLAUDE.md idiom rule).
- Annotate projection_min_budget_ms=1500 with its interaction
  with AI_SEARCH_TIME_BUDGET_MS.
- Add cedh_skips_paranoid_scaling_at_3p test (the spec range
  is 3..=4; we previously only covered 4).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(phase-ai): DeckFeatures::is_cedh + analyze constructor

Adds is_cedh: bool as a declaration-derived field on DeckFeatures.
Unlike the other structurally-detected fields, is_cedh is set from
the declared CommanderBracketTier at deck-analysis time, not from
card text.

Introduces DeckFeatures::analyze(deck, tier) as the canonical
constructor, consolidating the previously-inline feature-detection
logic from session::features_for() into a single, testable site.
features_for() now delegates to analyze() with CommanderBracketTier::Core
as the default until PlayerDeckPool carries explicit tier metadata.

ComboLinePolicy::activation() and CedhKeepablesMulligan gate on
this field (next phases).

Tasks 2.1 + 2.2 batched into one commit: adding is_cedh to the
struct literal in session.rs requires updating session.rs in the
same changeset.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(engine,phase-ai): thread bracket_tier through PlayerDeckPool

- Add bracket_tier: CommanderBracketTier field to PlayerDeckPool
  (default: CommanderBracketTier::Core via new Default impl on the enum).
- Add bracket_tier to PlayerDeckPayload (serde default for backward
  compatibility; PlayerDeckPayload now derives Default).
- Populate bracket_tier from payload at load_deck_into_state and
  deck_payload_from_current_pools, so Bo3 game 2/3 carries the
  same declared tier as game 1.
- Update features_for(deck, tier) to accept the tier parameter;
  from_game reads pool.bracket_tier and forwards it, so
  DeckFeatures::is_cedh is set correctly in production.
- Update from_single_deck and ensure_player_features to accept tier;
  analysis paths (classify_deck_js, starter decks, search.rs) pass
  CommanderBracketTier::Core since those paths have no declared tier.
- Test fixtures use ..Default::default() / ..PlayerDeckPool::default()
  to inherit the new field's default without churn.

Closes the gap from 356f9c5 where features_for hardcoded
CommanderBracketTier::Core — without this, ComboLinePolicy
and CedhKeepablesMulligan would never activate in production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(phase-ai,engine): close Phase 2 review findings

- match_flow::deck_payload_from_current_pools now propagates
  bracket_tier for AI seats (>= 2), not just player/opponent.
  Prevents silent tier drop in Bo3 multi-AI cEDH games.
  Adds test: deck_payload_from_current_pools_propagates_ai_seat_bracket_tier.
- Remove the trivial features_for passthrough in session.rs;
  callers call DeckFeatures::analyze directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(plan): correct CommanderBracketTier variant names

The plan template used CoreCommander / UpgradedCommander
but the actual enum variants in
crates/engine/src/game/bracket_estimate.rs are Core /
Upgraded. Updated all code blocks and string-tier examples
in the plan to match.

No-op for already-implemented phases (the implementers
correctly used the live names). Prevents future agents
from copy-pasting invalid names.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(engine): validate_cedh_bracket tag check

cEDH is manual-declaration only per bracket_estimate.rs:18-27
(Cedh is never returned by the estimator algorithmically).
validate_cedh_bracket asserts every deck has its tier
explicitly set to CommanderBracketTier::Cedh.

Returns typed CedhBracketError::DeckNotCedh on the first
offender (identified by seat_index). Empty input is Ok
(vacuously true).

Note: the plan named the error BracketViolation but that type
already exists in bracket_estimate.rs with a different meaning
(per-axis estimator ceiling crossings). The new type is named
CedhBracketError to avoid ambiguity while keeping clear intent.

Called from the WASM/Tauri/server bridges in Phase 8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(engine): cEDH bracket validation quality cleanup

- Change CedhBracketError seat_index to u8 to match the
  codebase convention (PlayerId(u8), server-core/protocol.rs,
  draft-wasm seat APIs).
- Add Display impl to CommanderBracketTier and use {} (not
  {:?}) in CedhBracketError's error message. Display is the
  long-term contract; Debug worked only by coincidence.
- Rename validate_rejects_empty_input -> validate_accepts_empty_input
  (the test asserts Ok(()); the old name read backwards).
- Move legality.rs cEDH-block use statements to the top-of-module
  use block.
- Keep CedhBracketError as an enum (not a struct): doc comment
  names anticipated future variants (AllDecksUnconfigured,
  TableSizeMismatch) expected once Phase 8 wires the validation
  into the game-init boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(phase-ai): combo/ module (line + detection + registry stub)

- combo/line.rs: ComboLine, ComboPiece, ComboStep, WinKind,
  ComboReachability, CardPredicate, ComboLineId — pure types,
  no game-state coupling.
- combo/detection.rs: ComboDetector trait + DefaultComboDetector
  — walks line.pieces, checks zone presence, computes mana
  shortfall via zone_eval::available_mana. InLibrary pieces
  treated as tutorable-but-absent, elevating those lines to
  ReachableNextTurn.
- combo/registry.rs: ComboRegistry with one synthetic stub line
  (__cedh_stub_test_creature__) for end-to-end wiring proof.
  Real cEDH lines (Thoracle/Consult, Kiki/Twin, Heliod/Ballista)
  land in a follow-up phase once card coverage stabilises.

5 tests added, all passing. Clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(phase-ai): combo module quality cleanups

- Remove wrong CR 726 annotation on WinKind::InfiniteLoop
  (CR 726 is The Initiative, not infinite loops). Replace
  with a description-only doc; CLAUDE.md forbids unverified
  CR numbers.
- Rename DefaultComboDetector -> StructuralComboDetector;
  avoids collision with the Default trait connotation.
- Unify ReachableThisTurn branches in assess() — both
  branches produced identical action vecs and differed only
  in a value that the cast already produces correctly.
- Rename test combo_piece_equality_is_structural ->
  combo_piece_eq_respects_zone for clarity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(phase-ai): ComboLinePolicy (gated on features.is_cedh)

- PolicyId variants ComboLineProgress + CedhKeepablesMulligan.
- ComboLinePolicy implementing TacticalPolicy. activation()
  returns None unless features.is_cedh; verdict() consults
  ComboRegistry and boosts combo-progressing actions.
- Registered in PolicyRegistry::default(). Non-cEDH decks pay
  zero cost via activation() short-circuit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(phase-ai): ComboLinePolicy quality cleanups

- Prefix PolicyReason strings with combo_line_ to match the
  convention used by other policies (combat_tax_*, tribal_lord_*,
  etc.) — makes per-policy trace filtering unambiguous.
- Remove orphaned PolicyId::CedhKeepablesMulligan; Phase 6
  adds it back alongside the implementation. Avoids an enum
  variant with no registered policy.
- Add TODO(cedh-perf) marker on the reachable_lines call in
  verdict() — caching by (quick_state_hash(state), ai_player)
  per spec section 5.3, deferred until real combo lines land.
- Switch policies/combo_line to pub(crate) mod for consistency
  with sibling internal policy modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(phase-ai): CedhKeepablesMulligan stub policy

- Add PolicyId::CedhKeepablesMulligan (re-added; was removed
  in Phase 5's quality pass as an orphan).
- Implement CedhKeepablesMulligan in policies/mulligan/cedh_keepables.rs.
  Internal gate on features.is_cedh; non-cEDH decks see a
  zero-delta Score (no-op).
- When cEDH: <2 lands, >4 lands, or no ramp+tutor+interaction
  -> ForceMulligan. Otherwise +1.0 baseline keep.
- Card classification is name-based against the canonical cEDH
  staple set (stub heuristic, refined later when ComboRegistry
  is populated).
- Registered in MulliganRegistry::default().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(phase-ai): cEDH mulligan no-acceleration + baseline-keep coverage

Adds the two missing test paths for CedhKeepablesMulligan:
- A hand with 2-4 lands and no fast-mana/tutor/interaction
  triggers ForceMulligan with the expected reason.
- A hand with 2-4 lands and at least one fast-mana card
  passes the baseline-keep gate (+1.0 Score).

Closes the coverage gap flagged in Phase 6 spec review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(phase-ai): cEDH mulligan too-many-lands coverage

Adds the missing test for the cedh_keepables_too_many_lands
ForceMulligan branch (>4 lands). Closes the third coverage
gap flagged in Phase 6 quality review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(client): cedhLock service — single source of truth for cEDH lock

anyAiOpponentIsCedh / applyCedhCascade / isDeckCedhLegal.
All cEDH-lock decisions in the frontend route through this
module. Pure functions; never mutate inputs.

Uses numeric CommanderBracket (1-5, cEDH = 5) to match
existing AiDeckCandidate.bracket convention. 9 tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(client): cEDH cascade in AiOpponentConfig + dropdown option

Task 7.2: Selecting cEDH on any AI seat cascades all other AI
seats to cEDH via applyCedhCascade(). Fires an inline notice
when the cascade triggers (no external toast lib — uses local
state with 6s auto-dismiss). When any seat is cEDH, the random
AI deck pool is filtered to bracket 5 only.

Task 7.3: 'CEDH' added to AI_DIFFICULTIES constant. B5 lock
badge (aria-label="B5 lock") renders on the AiDifficultyDropdown
when the selected difficulty is CEDH. 3 new tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(client): filterByBracket AI deck pool + cEDH warning chip

Task 7.4: filterByBracket(decks, tier) — pure filter; null
passes through all decks, non-null restricts to matching
bracket. 3 new tests. AiOpponentConfig's filteredDecks memo
already calls filterByBracket via CEDH_BRACKET when anyCedh.

Task 7.5: GameSetupPage reads human deck bracket via
loadSavedDeckBracket, reads aiSeats from preferencesStore,
and renders a yellow warning chip (role="alert") when any AI
is cEDH and the human deck is not bracket 5. Non-blocking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(client): cEDH cascade + warning chip coverage

Closes the two test gaps flagged in Phase 7 review:
- AiOpponentConfig cascade test verifies selecting cEDH on
  any seat upgrades all other AI seats to cEDH.
- GameSetupPage tests verify the warning chip renders when
  human deck is non-cEDH + any AI is cEDH, and hides
  otherwise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(client): cEDH UI quality cleanups

- AiOpponentConfig: store cascade timer in a ref and clear
  it in a cleanup useEffect so the 6s auto-dismiss does not
  outlive component unmount. Extract CASCADE_NOTICE_MS const.
- Replace inline d.bracket === CEDH_BRACKET in filteredDecks
  with a call to the exported filterByBracket helper, so the
  abstraction has a real caller.
- AiSeatPanel: render the B5 lock badge alongside the
  inline difficulty <select> when the seat is set to cEDH
  (the standalone AiDifficultyDropdown is no longer wired
  into production after PlayPage's removal — pre-existing
  dead code, separate cleanup).
- Correct stale 'below the select' comment in
  AiDifficultyDropdown (badge is positioned above).
- Note the AiSeatPref[] / CommanderBracket signature
  deviation in cedhLock.ts JSDoc.
- Add tests asserting B5 badge renders on cEDH and hides
  otherwise in AiSeatPanel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(engine,wasm): plumb bracket_tier through DeckList + WASM cEDH validation

Add `bracket_tier: CommanderBracketTier` (serde default = Core) to
`PlayerDeckList` and `DeckData` so the WASM/server wire formats carry
tier information through to `PlayerDeckPayload`. `resolve_deck_list`
now threads each seat's tier instead of hardcoding Core.

In `initialize_game` (engine-wasm), call `validate_cedh_bracket` before
`start_game` whenever any seat declares `Cedh` tier; return a typed
`cedh_bracket_violation` error when a seat's tier doesn't match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(tauri,server-core): plumb bracket_tier through Tauri and server bridges

Apply the same cEDH bracket validation pattern to the Tauri desktop
bridge (`commands.rs`): validate before `load_and_hydrate_decks` when
any seat declares Cedh tier.

In server-core, thread `DeckData.bracket_tier` through `resolve_deck`
into `PlayerDeckPayload` instead of relying on its default value. Fix
struct literal initializers in tests to include the new field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(client): blocking BracketViolation modal + DeckList bracket_tier plumbing

Thread `bracket_tier` from localStorage bracket sidecar through the
`DeckListPayload` wire format in `GameProvider`. Add helpers
`bracketToEngineTier()` and `loadActiveDeckBracket()` to map numeric
CommanderBracket (1-5) to engine tier strings. `buildLocalAiDeckList`
now preserves per-candidate bracket rather than always defaulting Core.

In `GamePage`, intercept `onNoDeck` errors containing "not declared
cEDH" and show a blocking `BracketViolation` modal with a "Return to
setup" button that navigates to /setup instead of auto-navigating away.

Tests: 4 new Vitest specs covering modal render, non-cEDH passthrough,
no-error idle state, and navigation on dismiss.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(engine-wasm,tauri): gate cEDH validation on AI difficulty

Previously gated on payload.*.bracket_tier == Cedh, which
triggered a spurious bracket-violation error any time a user
had a bracket-5 tagged deck — even when playing against a
non-cEDH AI difficulty. The spec intent is for validation
to fire only when the game itself is cEDH (i.e., any AI seat
has AiDifficulty::CEDH).

- Add `ai_difficulties: Vec<String>` to `DeckList` and
  `DeckPayload` (both with `#[serde(default)]` for backward
  compat). Thread from frontend through WASM bridge.
- Extract `any_ai_difficulty_is_cedh` canonical helper into
  `engine::database::legality` — single authority, tested.
- Use the helper in both `engine-wasm/src/lib.rs` and
  `client/src-tauri/src/commands.rs` as the gate signal.
- Derive `Default` on `DeckPayload`, `DeckList`, and
  `PlayerDeckList` to simplify test construction and silence
  exhaustive-struct errors across the workspace.
- Add regression test: bracket-5 human vs. non-cEDH AI is
  allowed (the bug that was almost shipped).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(client): typed BracketViolationError + P2P tier plumbing

- Engine-worker preserves the cedh_bracket_violation flag from
  the WASM error envelope and sends it via a new optional
  bracketViolation field on the error response message type.
- Engine-worker-client creates an AdapterError with code
  BRACKET_VIOLATION when the flag is present, so callers can
  match by typed code rather than by string substring on the
  error message.
- GameProvider detects BRACKET_VIOLATION and passes a typed
  boolean flag to onNoDeck(reason, bracketViolation). The
  onNoDeck signature is updated accordingly.
- GamePage.handleNoDeck matches on the bracketViolation flag
  to trigger the modal — no longer string-matches on
  "not declared cEDH", which was brittle.
- p2p-adapter DeckListPayload type carries bracket_tier on
  each seat and ai_difficulties at the top level so guest
  decks do not silently drop tier in TS-typed contexts.
- AdapterErrorCode.BRACKET_VIOLATION added to types.ts.
- GamePage.bracketViolation.test.tsx updated: modal now
  verified via typed flag, not string substring; new
  regression test confirms flag=false does not trigger modal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(client,engine): Phase 8 quality cleanups

- wasm-adapter main-thread fallback now throws typed
  BracketViolation when the WASM JSON envelope sets the
  cedh_bracket_violation flag (previously dropped, causing
  the modal to miss on Worker-creation failure paths).
- p2p-adapter populates ai_difficulties from per-seat
  AI difficulty so cEDH AI seats in P2P sessions are
  validated (previously bypassed validation entirely).
- legality.rs: regression test panic! message + affirmative
  pre-condition assertion; add partial-substring negative
  case to gate_detects_cedh_case_insensitive.
- match_flow.rs: corrected the rematch-skip comment to
  accurately describe the bypass.
- GameProvider: tighten ExpandedDeckWithTier.bracket_tier
  type from string to CommanderBracketTier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(ai-duel): accept --difficulty CEDH

Extend parse_difficulty to recognise "cedh" (case-insensitive) so
`cargo ai-duel --difficulty CEDH` works end-to-end without falling
back to Medium.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(phase-ai): cEDH end-to-end smoke

End-to-end smoke test for cEDH difficulty wiring across:
- Config preset values (depth 3, nodes 96)
- 4-player paranoid-scaling skip
- DeckFeatures::is_cedh gating field
- ComboLinePolicy registration in PolicyRegistry::default()
- ComboRegistry has at least one registered line

Adds PolicyRegistry::has_policy(&self, id: PolicyId) -> bool for
test introspection (narrow surface, not exposed to hot paths).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tauri-adapter): propagate typed BRACKET_VIOLATION from Tauri

The Tauri command returns Err(e.to_string()) on cEDH bracket
violation, which arrives at the JS side as a plain Error.
Detect the message substring from CedhBracketError::DeckNotCedh
Display ("not declared cEDH") and rethrow as
AdapterError(BRACKET_VIOLATION) so GameProvider can surface
the blocking modal in Tauri desktop builds (matches the WASM
worker path).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: refresh cEDH plan + spec type names

The plan and spec were written before Phase 3-4 renames:
- DefaultComboDetector -> StructuralComboDetector
- BracketViolation -> CedhBracketError (the engine already
  had a separate BracketViolation struct in bracket_estimate.rs
  for per-axis estimator violations; that reference preserved)
- ManaCost::Free -> ManaCost::NoCost (Free never existed)

Code is correct; docs were stale. This commit aligns them
to prevent future copy-paste of invalid names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(phase-ai): make cEDH difficulty actually play combos

Replaces the synthetic stub combo with three real hand-authored lines
(Heliod+Ballista, Thoracle+Consult, Kiki+Felidar) and threads the
registry through the surfaces that needed it to fire on real states:

- Detector populates required_actions from ComboStep predicates so
  ComboLinePolicy can match candidates by source_id + ability_index
  (or object_id + card_id) instead of accepting any spell/ability.
- CedhKeepablesMulligan returns a strong-keep when the hand contains
  a complete in-hand combo, bypassing the staple heuristics that real
  cEDH lists ignore once both pieces are drawn.
- Tutor target scorer adds a +1.5 bonus for cards that close a near-
  reachable combo, dominating the 0.8 generic-tutor cap so the AI
  fetches the exact missing piece.
- validate_cedh_bracket gains a TooManyPlayers variant; the seat-count
  guard runs before per-deck tag checks so users get an actionable
  error rather than the AI silently clipping into the >4-player
  scaling path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(server-core,phase-server): enforce cEDH bracket lock in multiplayer

WASM and Tauri adapters already gated game-init on `validate_cedh_bracket`;
server-core was the missing third site, so multiplayer games with a cEDH
AI seat would silently accept non-cEDH decks. This wires the same gate
into the server transport without protocol surface expansion.

- Session::start_game now returns Result<(), CedhBracketError>. The gate
  runs only when any AI seat is configured for AiDifficulty::CEDH, and
  it short-circuits before any session-state mutation so a failed start
  leaves the session re-startable after deck adjustment.
- Both phase-server call sites handle the new Result. The seat-delta
  path resets `started`, broadcasts a ServerMessage::Error to all
  connected players via the existing post-lock fan-out, and skips
  broadcast_game_started. The lobby-join auto-start path converts the
  error to the existing Err(String) join-failure flow so the joiner
  sees a typed message and the session retains them seated.
- Adds a server-core gate test that configures a cEDH AI seat with a
  non-cEDH deck and asserts both the typed CedhBracketError::DeckNotCedh
  return and the no-mutation invariant.

Follow-up: the lobby-join auto-start failure path notifies only the
joiner; mirroring the seat-delta fan-out to host + other connected
players would close the host-feedback gap. Tracked but defer-acceptable
since lobbies are typically host-driven via seat-delta.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(phase-server): fan out cEDH bracket failure to host on lobby join

When a player joined a cEDH game via the auto-start-when-full path and
the bracket validation failed, only the joining player saw the error;
the host and other already-connected players watched the lobby stall
with no diagnostic. The seat-delta path already broadcasts to all on
failure — this mirrors that pattern.

A `bracket_broadcast: Option<String>` side channel captures the
user-facing message inside the state-lock block. After the outer
match join_outcome arms run (so the joiner gets their direct error
first via the existing Err arm), the broadcast block acquires
connections.lock() and sends ServerMessage::Error to each connected
player. The joiner's socket isn't yet in the connections map at this
point — registration only happens in the Ok join-outcome arms — so the
broadcast naturally excludes them, no dedup logic needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(phase-ai): propagate AI bracket_tier into build_ai_context

The integration test (added alongside this fix) revealed a silent wiring
gap: `AiContext::analyze_for_player` hardcodes `CommanderBracketTier::Core`
when building the AI player's DeckFeatures, so `DeckFeatures::is_cedh`
was always false in production. ComboLinePolicy::activation() could not
fire for cEDH decks regardless of the declared bracket tier — the entire
cEDH skeleton (combo registry, tutor targeting, mulligan boost, policy
priors) was running through a `None`-gated path on every choose_action /
score_candidates invocation.

Fix: in build_ai_context, after `analyze_for_player`, look up the AI's
declared bracket_tier from `state.deck_pools` and rebuild the AI
player's session features via `invalidate_player_features` +
`ensure_player_features` when the tier differs from the analyze-time
default. Tier-guarded so the non-cEDH hot path is unaffected.

Adds `score_candidates_boosts_heliod_combo_activation_for_cedh_ai`:
sets up Heliod + Walking Ballista on a cEDH-tagged AI's battlefield
with synthetic activated abilities, calls score_candidates, asserts
the combo activation outscores PassPriority by at least 50% of the
config-derived bonus (combo_progress_this_turn_bonus * tactical_weight).
The margin is computed from config so a future weight retune still
catches genuine wiring regressions instead of silently passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(phase-server): unify cEDH bracket-violation error wording

Three send paths surfaced the same bracket-violation event with two
different framings: the host fanout (lobby-join broadcast) and the
seat-delta broadcast both prefixed "Cannot start cEDH game: ", while
the direct-to-joiner path sent the raw `bracket_err.to_string()`
without the prefix.

Adds the prefix to the joiner-direct path so all three audiences see
identical wording for the same event. No helper extracted — three
inlined uses is below the duplication threshold.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(phase-ai): add choose_action integration coverage for cEDH combos

The prior commit's `score_candidates_boosts_*` test stopped at the scoring
layer. choose_action's softmax+RNG selection sits one layer above and
could theoretically waste a dominant combo score on a wiring bug. Two
new tests close that gap:

- `choose_action_picks_combo_activation_for_cedh_ai` — runs 50 deterministic
  trials over the same Heliod+Ballista cEDH-tagged state and asserts at
  least 40/50 select one of the line's combo activations. With temperature
  0.2 and the combo's ~+1.5 dominance, softmax theory predicts >95% per
  trial; the 80% threshold tolerates benign reshuffling but catches real
  regressions.

- `choose_action_does_not_boost_combo_without_is_cedh` — same state with
  the bracket_tier flipped to Core. Asserts combo_count < 35/50, locking
  in that the bonus (not some unrelated bias) drives selection. The
  baseline rate with is_cedh=false sits around 27/50 because legal combo
  activations are simply available; only a true wiring leak would push
  selection into the >70% regime.

The shared setup is now parameterized into
`cedh_combo_state_with_synthetic_abilities(tier)` so both positive and
negative cases stay in sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(client): seed AI picker with bundled cEDH demo deck

The cEDH AI difficulty had no usable on-ramp: picking cEDH on an AI seat
showed "no decks" because the precon catalog (generated from MTGJSON)
contains no cEDH precons, the PRECON_BRACKETS overlay is empty, and the
saved-deck flow requires user action.

This ships a hand-curated bundled-cEDH-deck mechanism distinct from the
MTGJSON catalog:

- New BUNDLED_CEDH_DECKS map in client/src/data/cedhDecks.ts. Each entry
  is a DeckEntry with type === "Commander Deck" and is authored against
  the same shape MTGJSON precons use, so it flows through the existing
  precon source path in buildDeckCatalog.
- buildDeckCatalog gained a second loop that surfaces bundled cEDH
  entries after the MTGJSON loop. Bracket is the literal cEDH value
  (not looked up via getPreconBracket), so the deck appears under
  filterByBracket(_, 5) regardless of MTGJSON catalog state — bundled
  surfacing is independent of fetch success.
- One demo deck: BundledCedh_HeliodBallista_Demo, a legal 99-card mono-
  white Commander deck (Heliod, Sun-Crowned + Walking Ballista combo)
  with 17 curated cards (combo pieces, ramp, removal, utility, mana-
  fixing lands) and 82 Plains as padding to reach the CR 903.5a card
  count. Off-color cards (Thoracle/Consult/etc.) are documented as
  removed per CR 903.4.
- Three new tests prove the bundled deck appears in the catalog,
  carries source.type "precon" + bracket 5, and surfaces through
  filterByBracket independently of MTGJSON availability.

Follow-ups: (1) extract the near-duplicate MTGJSON/bundled push loops
into a shared helper once a second bundled deck lands. (2) Expand the
demo deck's curated card mix beyond skeleton quality.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(client): add Inalla bundled cEDH demo + dedup precon push helper

Adds a second bundled cEDH demo deck and resolves the loop-duplication
the previous commit's code review flagged as an Important follow-up.

Deck phase-rs#2: BundledCedh_InallaThoracle_Demo (Grixis = U/B/R color identity)
features the Thassa's Oracle + Demonic Consultation registered combo
(both off-color in mono-white, hence why Heliod's deck couldn't host
them). Legal 99-card mainboard: 30 curated singletons (combo + tutors
+ counterspells + cantrips + fast mana + removal + 5 colorless utility
lands) + 35 Islands + 34 Swamps. No Plains/Forests/Mountains and no
White/Green spells per CR 903.4.

Helper extraction: pushPreconCandidate consolidates the shared push
shape both precon flows (MTGJSON catalog and bundled cEDH) need. The
helper takes the bracket as a parameter so MTGJSON entries continue
to look up via getPreconBracket while bundled entries pass the literal
CEDH_BRACKET. The bundled-specific id-collision guard
(`if (decks && decks[deckId]) continue;`) stays at the call site —
it's semantically bundled-specific, not helper-shared.

Adding a third bundled deck now requires one BUNDLED_CEDH_DECKS entry
and zero deckCatalog.ts changes — class-not-card extensibility per
CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(client): add Winota Kiki-Felidar bundled cEDH demo deck

Third bundled cEDH demo deck, validating that the Task F helper
extraction achieved its goal — this required zero deckCatalog.ts changes.

BundledCedh_WinotaKikiFelidar_Demo (Boros = Red/White) features the
Kiki-Jiki, Mirror Breaker + Felidar Guardian registered combo: Kiki
copies Felidar with haste, the token Felidar's ETB blinks Kiki, and
the loop produces unbounded hasty attackers for lethal combat damage.
Both combo pieces are mono-color (Kiki Red, Felidar White) so Boros
is the minimal legal color identity.

Legal 99-card mainboard: 23 curated singletons (combo, colorless fast
mana, white removal, red/white interaction, colorless utility lands)
+ 40 Plains + 36 Mountain. No Islands/Swamps/Forests and no Blue/Black/
Green spells per CR 903.4.

The multi-deck enumeration test now asserts all three bundled decks
surface with bracket 5 and source type "precon".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cedh): address PR phase-rs#816 review comments

- CRITICAL: combo detector computed mana shortfall with i32 saturating_sub,
  which floors at i32::MIN — `2 - 3 = -1` cast to u8 became 255, making the
  policy treat an affordable combo as unreachable. Use unsigned subtraction
  so it saturates at 0.
- HIGH: Tauri adapter only mapped the DeckNotCedh bracket error to the typed
  BRACKET_VIOLATION; TooManyPlayers ("limited to 4 seats") bypassed it. Match
  both Display messages so table-size violations show the blocking modal too.
- MEDIUM: guard combo piece lookups with players.get() instead of direct
  index — an eliminated player would otherwise panic.
- MEDIUM (R2): replace DeckFeatures::is_cedh bool with bracket_tier:
  CommanderBracketTier. Consumers gate on `== Cedh`; the full tier keeps the
  design space open for future bracket-aware behavior.
- MEDIUM: drop the redundant bracket_tier parameter on
  resolve_player_deck_list — PlayerDeckList already carries it, removing the
  sync hazard.
- MEDIUM: correct the stale "1 line" perf comment in ComboLinePolicy (the
  registry now holds 3 lines).
- MEDIUM: document why validate_cedh_bracket carries no CR annotation — the
  Commander Bracket system is WotC format guidance (2024+), not the
  Comprehensive Rules, so a CR number would be fabricated.

The "overly broad combo heuristic" comment was already resolved earlier:
verdict() matches candidates against the line's resolved required_actions and
gates on missing_mana == 0. Caching reachable_lines() remains a documented
follow-up (per-call cost is still small at 3 lines).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(engine): drop Thorin from anaphoric-scope freeze guard after merge

The merge from main brought in the phase-rs#511/phase-rs#512 parser fix that corrects
Thorin, Mountain-King's category-2/3 anaphoric "its" misparse, so the card
no longer retains ObjectScope::Anaphoric in the exported card data. The
freeze guard is a tripwire designed to fail exactly here; per its own
instructions, remove the card from ANAPHORIC_SCOPE_CARDS and drop both count
assertions 265 -> 264.

Surfaced after merge because the pre-push hook runs parser::oracle::tests but
not the engine integration suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(ai): spec cEDH AI 4-card mulligan floor

Design for a ForceKeep mulligan verdict that stops the cEDH AI from
mulliganing below 4 cards, with CR 103.5 free-first card-count math.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(engine): add kept_hand_size_after mulligan helper (CR 103.5)

Expose a public `kept_hand_size_after(mulligan_count, free_first)` helper in
`crates/engine/src/game/mulligan` so cEDH AI logic can query how many cards
a player would keep before committing to another mulligan, without needing
to reach into the private `bottom_count_for` function.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(engine): correct CR annotations and add mulligan boundary tests

Starting hand size and the kept-hand free-first discount are governed by
CR 103.5 / 103.5c, not CR 103.4 (starting life total). Fix the
STARTING_HAND_SIZE constant comment and the kept_hand_size_after doc
annotation, and add boundary tests exercising the saturating_sub floor
where the kept hand reaches zero cards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai): cEDH mulligan floor — never mulligan below 4 cards

Add `MulliganScore::ForceKeep` variant with three-way registry
precedence (ForceKeep > ForceMulligan > score sum), and implement a
hard card-count floor in `CedhKeepablesMulligan` that emits `ForceKeep`
when one more mulligan would leave fewer than 4 cards, covering both
free-first and normal London mulligan rules via
`engine::game::mulligan::kept_hand_size_after`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(ai): cover cEDH mulligan floor end-to-end + tidy const placement

Add a registry-level integration test wiring the real
`CedhKeepablesMulligan` alongside a `ForceMulligan` policy with cEDH
features at a floor-engaged mulligan count, proving the real cEDH floor
`ForceKeep` overrides a real `ForceMulligan` through the registry. Move
`CEDH_MULLIGAN_FLOOR` below the use groups so the imports stay contiguous.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(engine): tag ChooseFromZoneConstraint so the choice modal can validate

ChooseFromZoneConstraint serialized externally-tagged
(`{ "DistinctCardTypes": {...} }`), but the frontend CardChoiceModal reads it
internally-tagged on `constraint.type`. The mismatch left `type` undefined, so
the confirm button never enabled for any selection under a DistinctCardTypes
constraint (Atraxa, Grand Unifier). Add `#[serde(tag = "type")]` to match the
sibling SearchSelectionConstraint convention and the frontend contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(client): make cEDH a table-wide toggle instead of a per-seat difficulty

Selecting cEDH on any AI seat used to cascade-lock every opponent to cEDH, so
multi-opponent games couldn't mix difficulties without dropping to one opponent.
Replace the per-seat "CEDH" difficulty with a table-wide `cedhMode` toggle at the
top of the AI opponents panel: when on, all AI play cEDH (deck pools restricted
to bracket 5) and per-seat difficulty dropdowns are disabled with a cEDH badge
while preserving each seat's remembered difficulty; when off, every opponent's
difficulty is independent.

`effectiveAiDifficulty` maps `cedhMode` to the engine's per-seat "CEDH" contract
at both init sites, so the engine is unchanged. A persisted-store migration
(v11->v12) converts existing per-seat cEDH selections to `cedhMode`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(tauri): structured cEDH bracket error instead of Display matching

The Tauri initialize_game command returned Err(e.to_string()), forcing the
tauri-adapter to substring-match the Rust Display text ("not declared cEDH" /
"limited to 4 seats") to detect a cEDH bracket violation — fragile and against
the project's typed-over-stringly-typed style. Return a serializable
CommandError enum (BracketViolation / Generic) and discriminate on `kind` in
the adapter, mirroring the WASM worker path's `cedh_bracket_violation` flag so
both transports surface the violation as a typed signal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(PR-816): strip accidental external-tool artifacts

The docs/superpowers/ plan + spec files are Claude Code plugin output and
do not belong in the repo (per pr-contribution-handler skill). Removing them
from the PR per maintainer confirmation.

* fix(PR-816): route cEDH modal + warning chip through i18n; drop redundant anyCedh alias

Architecture-review findings:
- GamePage cEDH bracket-violation modal (title/body/return button/aria-label)
  and GameSetupPage cEDH warning chip rendered hardcoded English instead of
  t(). Added gameSetup.bracketViolation.* + gameSetup.cedhWarning{,Untagged}
  keys to all 7 locale game.json catalogs (real translations) and routed the
  frontend-authored chrome through t(). Engine-authored error text
  (bracketViolationError) intentionally stays un-t()'d per the i18n boundary.
- Removed the redundant 1:1 anyCedh alias of cedhMode in AiOpponentConfig
  (leftover from the removed multi-seat cascade design).

Verified: pnpm type-check + eslint clean; GamePage.bracketViolation and
GameSetupPage suites pass against the real en catalog (test-setup loads real
i18n, so existing copy assertions validate the new keys).

* fix(PR-816): make cEDH combo detector color-aware; drop dead reachability/decision-kind

Architecture-review finding (correctness): StructuralComboDetector collapsed a
ManaCost's colored pips + generic into a single count compared against the
colorless zone_eval::available_mana, so a colored combo line (Heliod+Ballista
{1}{W}, Thoracle+Consultation {1}{U}{U}{B}) was reported ReachableThisTurn
{missing_mana:0} whenever total mana sufficed REGARDLESS of color — driving a
+15 combo_progress_this_turn_bonus onto a combo the AI cannot actually cast.

Fix: consume the engine's existing color-accurate affordability primitive
engine::game::casting::can_pay_cost_after_auto_tap (auto-taps all mana sources
into a simulated pool, runs the real can_pay_for_spell — colored pips, generic,
hybrid, Phyrexian, {C}, restrictions, summoning sickness CR 302.6). The
cost-bearing source is derived generically from the line's first ComboStep
(Activate -> battlefield obj, Cast -> hand obj); NoCost/SelfManaCost short-circuit
(Kiki). An all-pieces-present-but-unaffordable line collapses to NotReachable, so
reachable_lines stops surfacing uncastable lines to the policy. CR 601.2g/601.2h
annotated (grep-verified).

Also (cleanup): removed dead ComboReachability::ReachableSoon (never constructed,
YAGNI) and dropped DecisionKind::SelectTarget from ComboLinePolicy::decision_kinds
(verdict can never score a target candidate -> wasted per-candidate reachable_lines
scan).

Tests: added discriminating negative regression
thoracle_line_not_reachable_with_wrong_color_mana (4 wrong-color lands -> NOT
reachable; fails under old count-based code, passes under fix); updated the 3
existing fixtures to give test lands real basic-land subtypes so the engine
auto-tap path produces the correct colors.

Reviewed-clean via engine-implementer pipeline (2 plan-review rounds, 1 impl
review). Verified with direct cargo (Tilt watches main, not this worktree):
cargo clippy -p engine -p phase-ai --all-targets clean; cargo test -p phase-ai
--lib 676 passed.

* fix(PR-816): add bracket_tier/ai_difficulties to whitemane test deck literals

Merge-interaction fix surfaced by bringing the branch current with origin/main:
c71d2da (Whitemane Lion, phase-rs#1268) added crates/phase-ai/tests/whitemane_lion_bounded.rs
constructing DeckList/PlayerDeckList with the old field set, while this PR added
bracket_tier (PlayerDeckList) and ai_difficulties (DeckList). #[serde(default)]
covers deserialization but not struct-literal construction, so the test target
failed to compile after the merge. Added the fields via the PR's established
Default::default()/Vec::new() pattern (sibling coverage for the struct change).
cargo clippy -p phase-ai --all-targets now clean.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Matt Evans <1388610+matthewevans@users.noreply.github.com>
YB0y pushed a commit to YB0y/phase that referenced this pull request May 29, 2026
…t i18n, collapsible groups (phase-rs#1382)

* docs: cEDH AI difficulty design spec

Add a design spec for AiDifficulty::CEDH covering:
- Difficulty preset that bypasses 4-player paranoid search scaling
- Bracket-5 game-setup lock with AI cascade and warning chip
- Combo-recognition skeleton: ComboLine types, ComboDetector trait,
  ComboRegistry, a ComboLinePolicy gated by difficulty, and a stub
  CedhMulliganPolicy
- Engine-side validate_cedh_bracket as a tag check against the
  manual-declaration-only CommanderBracketTier::Cedh

Scope is intentionally skeleton-only: real combo lines, archetype
tuning, backward-chaining synthesis, and multiplayer cEDH gating
are explicit non-goals for this phase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: correct cEDH spec gating mechanism

Update sections 4, 5.3, 5.4, and 7 to reflect the actual
TacticalPolicy::activation() and MulliganPolicy::evaluate()
gating patterns in the codebase:

- ComboLinePolicy gates via activation() returning Some/None
  keyed off a new DeckFeatures::is_cedh field, not via
  registration-time difficulty checks (the PolicyRegistry uses
  a OnceLock shared instance and is registration-stable).
- CedhKeepablesMulligan follows the existing
  *KeepablesMulligan naming convention and gates internally
  via features.is_cedh inside evaluate(), matching the pattern
  used by AggroKeepablesMulligan et al.
- DeckFeatures gains an is_cedh field populated from the
  deck's declared CommanderBracketTier at deck-analysis time.

Original design intent unchanged; only the integration-point
mechanics are corrected against the live trait signatures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: cEDH difficulty implementation plan

Bite-sized TDD plan covering 9 phases (~25 tasks):
1. AiDifficulty::CEDH variant + preset + 4p scaling skip
2. DeckFeatures::is_cedh field + population from tier
3. Engine validate_cedh_bracket tag check
4. combo/ module (line + detection + registry stub)
5. ComboLinePolicy gated via activation()
6. CedhKeepablesMulligan gated internally
7. Frontend cedhLock service + dropdown + cascade + filter + chip
8. Engine validation wired into WASM/Tauri/server bridges
9. End-to-end integration test + verification sweep

Each task: write failing test, run to confirm fail, write
minimal impl, run to confirm pass, commit. Verification uses
the Tilt-first pattern from CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(phase-ai): AiDifficulty::CEDH variant + preset

Adds CEDH enum variant (highest ordinal, after VeryHard) with its
create_config() match arm: temperature 0.2, depth 3, nodes 96,
rollout 2x2, combat_lookahead=true (first tier to enable it),
projection_min_budget_ms=1500. WASM caps apply: depth 2, nodes 64.

Extends ai_difficulty_serde_roundtrips to cover CEDH. Adds
cedh_preset_values and cedh_preset_wasm_caps_apply tests.

draft-wasm/bot_ai.rs: CEDH uses same full-evaluation pick strategy
as VeryHard.

Tasks 1.1 and 1.2 batched into one commit: the pre-commit hook
requires builds to pass and a standalone Task 1.1 (missing match
arm) would fail clippy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(phase-ai): cEDH bypasses 4p paranoid scaling

cEDH is exclusively played at 4-player tables and is calibrated
for that count from the base preset. The generic 3-4p scaler
(cap depth at 2, reduce nodes to 2/3) would cripple it.
Non-cEDH difficulties continue to follow paranoid scaling
unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(phase-ai): add combo_progress_* PolicyPenalties fields

Tunable bonuses for combo-progress prior boosts. Consumed by
ComboLinePolicy (next phase). Defaults +15.0 (this turn) and
+5.0 (next turn) match the spec; both serde-default so older
saved configs deserialize cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(phase-ai): cEDH Phase 1 quality cleanups

- Update create_config doc comment (Five->Six presets, drop
  stale VeryHard reference).
- Invert the cEDH bypass in create_config_for_players to
  avoid an empty-if + non-empty-else (CLAUDE.md idiom rule).
- Annotate projection_min_budget_ms=1500 with its interaction
  with AI_SEARCH_TIME_BUDGET_MS.
- Add cedh_skips_paranoid_scaling_at_3p test (the spec range
  is 3..=4; we previously only covered 4).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(phase-ai): DeckFeatures::is_cedh + analyze constructor

Adds is_cedh: bool as a declaration-derived field on DeckFeatures.
Unlike the other structurally-detected fields, is_cedh is set from
the declared CommanderBracketTier at deck-analysis time, not from
card text.

Introduces DeckFeatures::analyze(deck, tier) as the canonical
constructor, consolidating the previously-inline feature-detection
logic from session::features_for() into a single, testable site.
features_for() now delegates to analyze() with CommanderBracketTier::Core
as the default until PlayerDeckPool carries explicit tier metadata.

ComboLinePolicy::activation() and CedhKeepablesMulligan gate on
this field (next phases).

Tasks 2.1 + 2.2 batched into one commit: adding is_cedh to the
struct literal in session.rs requires updating session.rs in the
same changeset.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(engine,phase-ai): thread bracket_tier through PlayerDeckPool

- Add bracket_tier: CommanderBracketTier field to PlayerDeckPool
  (default: CommanderBracketTier::Core via new Default impl on the enum).
- Add bracket_tier to PlayerDeckPayload (serde default for backward
  compatibility; PlayerDeckPayload now derives Default).
- Populate bracket_tier from payload at load_deck_into_state and
  deck_payload_from_current_pools, so Bo3 game 2/3 carries the
  same declared tier as game 1.
- Update features_for(deck, tier) to accept the tier parameter;
  from_game reads pool.bracket_tier and forwards it, so
  DeckFeatures::is_cedh is set correctly in production.
- Update from_single_deck and ensure_player_features to accept tier;
  analysis paths (classify_deck_js, starter decks, search.rs) pass
  CommanderBracketTier::Core since those paths have no declared tier.
- Test fixtures use ..Default::default() / ..PlayerDeckPool::default()
  to inherit the new field's default without churn.

Closes the gap from 356f9c5 where features_for hardcoded
CommanderBracketTier::Core — without this, ComboLinePolicy
and CedhKeepablesMulligan would never activate in production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(phase-ai,engine): close Phase 2 review findings

- match_flow::deck_payload_from_current_pools now propagates
  bracket_tier for AI seats (>= 2), not just player/opponent.
  Prevents silent tier drop in Bo3 multi-AI cEDH games.
  Adds test: deck_payload_from_current_pools_propagates_ai_seat_bracket_tier.
- Remove the trivial features_for passthrough in session.rs;
  callers call DeckFeatures::analyze directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(plan): correct CommanderBracketTier variant names

The plan template used CoreCommander / UpgradedCommander
but the actual enum variants in
crates/engine/src/game/bracket_estimate.rs are Core /
Upgraded. Updated all code blocks and string-tier examples
in the plan to match.

No-op for already-implemented phases (the implementers
correctly used the live names). Prevents future agents
from copy-pasting invalid names.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(engine): validate_cedh_bracket tag check

cEDH is manual-declaration only per bracket_estimate.rs:18-27
(Cedh is never returned by the estimator algorithmically).
validate_cedh_bracket asserts every deck has its tier
explicitly set to CommanderBracketTier::Cedh.

Returns typed CedhBracketError::DeckNotCedh on the first
offender (identified by seat_index). Empty input is Ok
(vacuously true).

Note: the plan named the error BracketViolation but that type
already exists in bracket_estimate.rs with a different meaning
(per-axis estimator ceiling crossings). The new type is named
CedhBracketError to avoid ambiguity while keeping clear intent.

Called from the WASM/Tauri/server bridges in Phase 8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(engine): cEDH bracket validation quality cleanup

- Change CedhBracketError seat_index to u8 to match the
  codebase convention (PlayerId(u8), server-core/protocol.rs,
  draft-wasm seat APIs).
- Add Display impl to CommanderBracketTier and use {} (not
  {:?}) in CedhBracketError's error message. Display is the
  long-term contract; Debug worked only by coincidence.
- Rename validate_rejects_empty_input -> validate_accepts_empty_input
  (the test asserts Ok(()); the old name read backwards).
- Move legality.rs cEDH-block use statements to the top-of-module
  use block.
- Keep CedhBracketError as an enum (not a struct): doc comment
  names anticipated future variants (AllDecksUnconfigured,
  TableSizeMismatch) expected once Phase 8 wires the validation
  into the game-init boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(phase-ai): combo/ module (line + detection + registry stub)

- combo/line.rs: ComboLine, ComboPiece, ComboStep, WinKind,
  ComboReachability, CardPredicate, ComboLineId — pure types,
  no game-state coupling.
- combo/detection.rs: ComboDetector trait + DefaultComboDetector
  — walks line.pieces, checks zone presence, computes mana
  shortfall via zone_eval::available_mana. InLibrary pieces
  treated as tutorable-but-absent, elevating those lines to
  ReachableNextTurn.
- combo/registry.rs: ComboRegistry with one synthetic stub line
  (__cedh_stub_test_creature__) for end-to-end wiring proof.
  Real cEDH lines (Thoracle/Consult, Kiki/Twin, Heliod/Ballista)
  land in a follow-up phase once card coverage stabilises.

5 tests added, all passing. Clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(phase-ai): combo module quality cleanups

- Remove wrong CR 726 annotation on WinKind::InfiniteLoop
  (CR 726 is The Initiative, not infinite loops). Replace
  with a description-only doc; CLAUDE.md forbids unverified
  CR numbers.
- Rename DefaultComboDetector -> StructuralComboDetector;
  avoids collision with the Default trait connotation.
- Unify ReachableThisTurn branches in assess() — both
  branches produced identical action vecs and differed only
  in a value that the cast already produces correctly.
- Rename test combo_piece_equality_is_structural ->
  combo_piece_eq_respects_zone for clarity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(phase-ai): ComboLinePolicy (gated on features.is_cedh)

- PolicyId variants ComboLineProgress + CedhKeepablesMulligan.
- ComboLinePolicy implementing TacticalPolicy. activation()
  returns None unless features.is_cedh; verdict() consults
  ComboRegistry and boosts combo-progressing actions.
- Registered in PolicyRegistry::default(). Non-cEDH decks pay
  zero cost via activation() short-circuit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(phase-ai): ComboLinePolicy quality cleanups

- Prefix PolicyReason strings with combo_line_ to match the
  convention used by other policies (combat_tax_*, tribal_lord_*,
  etc.) — makes per-policy trace filtering unambiguous.
- Remove orphaned PolicyId::CedhKeepablesMulligan; Phase 6
  adds it back alongside the implementation. Avoids an enum
  variant with no registered policy.
- Add TODO(cedh-perf) marker on the reachable_lines call in
  verdict() — caching by (quick_state_hash(state), ai_player)
  per spec section 5.3, deferred until real combo lines land.
- Switch policies/combo_line to pub(crate) mod for consistency
  with sibling internal policy modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(phase-ai): CedhKeepablesMulligan stub policy

- Add PolicyId::CedhKeepablesMulligan (re-added; was removed
  in Phase 5's quality pass as an orphan).
- Implement CedhKeepablesMulligan in policies/mulligan/cedh_keepables.rs.
  Internal gate on features.is_cedh; non-cEDH decks see a
  zero-delta Score (no-op).
- When cEDH: <2 lands, >4 lands, or no ramp+tutor+interaction
  -> ForceMulligan. Otherwise +1.0 baseline keep.
- Card classification is name-based against the canonical cEDH
  staple set (stub heuristic, refined later when ComboRegistry
  is populated).
- Registered in MulliganRegistry::default().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(phase-ai): cEDH mulligan no-acceleration + baseline-keep coverage

Adds the two missing test paths for CedhKeepablesMulligan:
- A hand with 2-4 lands and no fast-mana/tutor/interaction
  triggers ForceMulligan with the expected reason.
- A hand with 2-4 lands and at least one fast-mana card
  passes the baseline-keep gate (+1.0 Score).

Closes the coverage gap flagged in Phase 6 spec review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(phase-ai): cEDH mulligan too-many-lands coverage

Adds the missing test for the cedh_keepables_too_many_lands
ForceMulligan branch (>4 lands). Closes the third coverage
gap flagged in Phase 6 quality review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(client): cedhLock service — single source of truth for cEDH lock

anyAiOpponentIsCedh / applyCedhCascade / isDeckCedhLegal.
All cEDH-lock decisions in the frontend route through this
module. Pure functions; never mutate inputs.

Uses numeric CommanderBracket (1-5, cEDH = 5) to match
existing AiDeckCandidate.bracket convention. 9 tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(client): cEDH cascade in AiOpponentConfig + dropdown option

Task 7.2: Selecting cEDH on any AI seat cascades all other AI
seats to cEDH via applyCedhCascade(). Fires an inline notice
when the cascade triggers (no external toast lib — uses local
state with 6s auto-dismiss). When any seat is cEDH, the random
AI deck pool is filtered to bracket 5 only.

Task 7.3: 'CEDH' added to AI_DIFFICULTIES constant. B5 lock
badge (aria-label="B5 lock") renders on the AiDifficultyDropdown
when the selected difficulty is CEDH. 3 new tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(client): filterByBracket AI deck pool + cEDH warning chip

Task 7.4: filterByBracket(decks, tier) — pure filter; null
passes through all decks, non-null restricts to matching
bracket. 3 new tests. AiOpponentConfig's filteredDecks memo
already calls filterByBracket via CEDH_BRACKET when anyCedh.

Task 7.5: GameSetupPage reads human deck bracket via
loadSavedDeckBracket, reads aiSeats from preferencesStore,
and renders a yellow warning chip (role="alert") when any AI
is cEDH and the human deck is not bracket 5. Non-blocking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(client): cEDH cascade + warning chip coverage

Closes the two test gaps flagged in Phase 7 review:
- AiOpponentConfig cascade test verifies selecting cEDH on
  any seat upgrades all other AI seats to cEDH.
- GameSetupPage tests verify the warning chip renders when
  human deck is non-cEDH + any AI is cEDH, and hides
  otherwise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(client): cEDH UI quality cleanups

- AiOpponentConfig: store cascade timer in a ref and clear
  it in a cleanup useEffect so the 6s auto-dismiss does not
  outlive component unmount. Extract CASCADE_NOTICE_MS const.
- Replace inline d.bracket === CEDH_BRACKET in filteredDecks
  with a call to the exported filterByBracket helper, so the
  abstraction has a real caller.
- AiSeatPanel: render the B5 lock badge alongside the
  inline difficulty <select> when the seat is set to cEDH
  (the standalone AiDifficultyDropdown is no longer wired
  into production after PlayPage's removal — pre-existing
  dead code, separate cleanup).
- Correct stale 'below the select' comment in
  AiDifficultyDropdown (badge is positioned above).
- Note the AiSeatPref[] / CommanderBracket signature
  deviation in cedhLock.ts JSDoc.
- Add tests asserting B5 badge renders on cEDH and hides
  otherwise in AiSeatPanel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(engine,wasm): plumb bracket_tier through DeckList + WASM cEDH validation

Add `bracket_tier: CommanderBracketTier` (serde default = Core) to
`PlayerDeckList` and `DeckData` so the WASM/server wire formats carry
tier information through to `PlayerDeckPayload`. `resolve_deck_list`
now threads each seat's tier instead of hardcoding Core.

In `initialize_game` (engine-wasm), call `validate_cedh_bracket` before
`start_game` whenever any seat declares `Cedh` tier; return a typed
`cedh_bracket_violation` error when a seat's tier doesn't match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(tauri,server-core): plumb bracket_tier through Tauri and server bridges

Apply the same cEDH bracket validation pattern to the Tauri desktop
bridge (`commands.rs`): validate before `load_and_hydrate_decks` when
any seat declares Cedh tier.

In server-core, thread `DeckData.bracket_tier` through `resolve_deck`
into `PlayerDeckPayload` instead of relying on its default value. Fix
struct literal initializers in tests to include the new field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(client): blocking BracketViolation modal + DeckList bracket_tier plumbing

Thread `bracket_tier` from localStorage bracket sidecar through the
`DeckListPayload` wire format in `GameProvider`. Add helpers
`bracketToEngineTier()` and `loadActiveDeckBracket()` to map numeric
CommanderBracket (1-5) to engine tier strings. `buildLocalAiDeckList`
now preserves per-candidate bracket rather than always defaulting Core.

In `GamePage`, intercept `onNoDeck` errors containing "not declared
cEDH" and show a blocking `BracketViolation` modal with a "Return to
setup" button that navigates to /setup instead of auto-navigating away.

Tests: 4 new Vitest specs covering modal render, non-cEDH passthrough,
no-error idle state, and navigation on dismiss.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(engine-wasm,tauri): gate cEDH validation on AI difficulty

Previously gated on payload.*.bracket_tier == Cedh, which
triggered a spurious bracket-violation error any time a user
had a bracket-5 tagged deck — even when playing against a
non-cEDH AI difficulty. The spec intent is for validation
to fire only when the game itself is cEDH (i.e., any AI seat
has AiDifficulty::CEDH).

- Add `ai_difficulties: Vec<String>` to `DeckList` and
  `DeckPayload` (both with `#[serde(default)]` for backward
  compat). Thread from frontend through WASM bridge.
- Extract `any_ai_difficulty_is_cedh` canonical helper into
  `engine::database::legality` — single authority, tested.
- Use the helper in both `engine-wasm/src/lib.rs` and
  `client/src-tauri/src/commands.rs` as the gate signal.
- Derive `Default` on `DeckPayload`, `DeckList`, and
  `PlayerDeckList` to simplify test construction and silence
  exhaustive-struct errors across the workspace.
- Add regression test: bracket-5 human vs. non-cEDH AI is
  allowed (the bug that was almost shipped).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(client): typed BracketViolationError + P2P tier plumbing

- Engine-worker preserves the cedh_bracket_violation flag from
  the WASM error envelope and sends it via a new optional
  bracketViolation field on the error response message type.
- Engine-worker-client creates an AdapterError with code
  BRACKET_VIOLATION when the flag is present, so callers can
  match by typed code rather than by string substring on the
  error message.
- GameProvider detects BRACKET_VIOLATION and passes a typed
  boolean flag to onNoDeck(reason, bracketViolation). The
  onNoDeck signature is updated accordingly.
- GamePage.handleNoDeck matches on the bracketViolation flag
  to trigger the modal — no longer string-matches on
  "not declared cEDH", which was brittle.
- p2p-adapter DeckListPayload type carries bracket_tier on
  each seat and ai_difficulties at the top level so guest
  decks do not silently drop tier in TS-typed contexts.
- AdapterErrorCode.BRACKET_VIOLATION added to types.ts.
- GamePage.bracketViolation.test.tsx updated: modal now
  verified via typed flag, not string substring; new
  regression test confirms flag=false does not trigger modal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(client,engine): Phase 8 quality cleanups

- wasm-adapter main-thread fallback now throws typed
  BracketViolation when the WASM JSON envelope sets the
  cedh_bracket_violation flag (previously dropped, causing
  the modal to miss on Worker-creation failure paths).
- p2p-adapter populates ai_difficulties from per-seat
  AI difficulty so cEDH AI seats in P2P sessions are
  validated (previously bypassed validation entirely).
- legality.rs: regression test panic! message + affirmative
  pre-condition assertion; add partial-substring negative
  case to gate_detects_cedh_case_insensitive.
- match_flow.rs: corrected the rematch-skip comment to
  accurately describe the bypass.
- GameProvider: tighten ExpandedDeckWithTier.bracket_tier
  type from string to CommanderBracketTier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(ai-duel): accept --difficulty CEDH

Extend parse_difficulty to recognise "cedh" (case-insensitive) so
`cargo ai-duel --difficulty CEDH` works end-to-end without falling
back to Medium.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(phase-ai): cEDH end-to-end smoke

End-to-end smoke test for cEDH difficulty wiring across:
- Config preset values (depth 3, nodes 96)
- 4-player paranoid-scaling skip
- DeckFeatures::is_cedh gating field
- ComboLinePolicy registration in PolicyRegistry::default()
- ComboRegistry has at least one registered line

Adds PolicyRegistry::has_policy(&self, id: PolicyId) -> bool for
test introspection (narrow surface, not exposed to hot paths).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tauri-adapter): propagate typed BRACKET_VIOLATION from Tauri

The Tauri command returns Err(e.to_string()) on cEDH bracket
violation, which arrives at the JS side as a plain Error.
Detect the message substring from CedhBracketError::DeckNotCedh
Display ("not declared cEDH") and rethrow as
AdapterError(BRACKET_VIOLATION) so GameProvider can surface
the blocking modal in Tauri desktop builds (matches the WASM
worker path).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: refresh cEDH plan + spec type names

The plan and spec were written before Phase 3-4 renames:
- DefaultComboDetector -> StructuralComboDetector
- BracketViolation -> CedhBracketError (the engine already
  had a separate BracketViolation struct in bracket_estimate.rs
  for per-axis estimator violations; that reference preserved)
- ManaCost::Free -> ManaCost::NoCost (Free never existed)

Code is correct; docs were stale. This commit aligns them
to prevent future copy-paste of invalid names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(phase-ai): make cEDH difficulty actually play combos

Replaces the synthetic stub combo with three real hand-authored lines
(Heliod+Ballista, Thoracle+Consult, Kiki+Felidar) and threads the
registry through the surfaces that needed it to fire on real states:

- Detector populates required_actions from ComboStep predicates so
  ComboLinePolicy can match candidates by source_id + ability_index
  (or object_id + card_id) instead of accepting any spell/ability.
- CedhKeepablesMulligan returns a strong-keep when the hand contains
  a complete in-hand combo, bypassing the staple heuristics that real
  cEDH lists ignore once both pieces are drawn.
- Tutor target scorer adds a +1.5 bonus for cards that close a near-
  reachable combo, dominating the 0.8 generic-tutor cap so the AI
  fetches the exact missing piece.
- validate_cedh_bracket gains a TooManyPlayers variant; the seat-count
  guard runs before per-deck tag checks so users get an actionable
  error rather than the AI silently clipping into the >4-player
  scaling path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(server-core,phase-server): enforce cEDH bracket lock in multiplayer

WASM and Tauri adapters already gated game-init on `validate_cedh_bracket`;
server-core was the missing third site, so multiplayer games with a cEDH
AI seat would silently accept non-cEDH decks. This wires the same gate
into the server transport without protocol surface expansion.

- Session::start_game now returns Result<(), CedhBracketError>. The gate
  runs only when any AI seat is configured for AiDifficulty::CEDH, and
  it short-circuits before any session-state mutation so a failed start
  leaves the session re-startable after deck adjustment.
- Both phase-server call sites handle the new Result. The seat-delta
  path resets `started`, broadcasts a ServerMessage::Error to all
  connected players via the existing post-lock fan-out, and skips
  broadcast_game_started. The lobby-join auto-start path converts the
  error to the existing Err(String) join-failure flow so the joiner
  sees a typed message and the session retains them seated.
- Adds a server-core gate test that configures a cEDH AI seat with a
  non-cEDH deck and asserts both the typed CedhBracketError::DeckNotCedh
  return and the no-mutation invariant.

Follow-up: the lobby-join auto-start failure path notifies only the
joiner; mirroring the seat-delta fan-out to host + other connected
players would close the host-feedback gap. Tracked but defer-acceptable
since lobbies are typically host-driven via seat-delta.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(phase-server): fan out cEDH bracket failure to host on lobby join

When a player joined a cEDH game via the auto-start-when-full path and
the bracket validation failed, only the joining player saw the error;
the host and other already-connected players watched the lobby stall
with no diagnostic. The seat-delta path already broadcasts to all on
failure — this mirrors that pattern.

A `bracket_broadcast: Option<String>` side channel captures the
user-facing message inside the state-lock block. After the outer
match join_outcome arms run (so the joiner gets their direct error
first via the existing Err arm), the broadcast block acquires
connections.lock() and sends ServerMessage::Error to each connected
player. The joiner's socket isn't yet in the connections map at this
point — registration only happens in the Ok join-outcome arms — so the
broadcast naturally excludes them, no dedup logic needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(phase-ai): propagate AI bracket_tier into build_ai_context

The integration test (added alongside this fix) revealed a silent wiring
gap: `AiContext::analyze_for_player` hardcodes `CommanderBracketTier::Core`
when building the AI player's DeckFeatures, so `DeckFeatures::is_cedh`
was always false in production. ComboLinePolicy::activation() could not
fire for cEDH decks regardless of the declared bracket tier — the entire
cEDH skeleton (combo registry, tutor targeting, mulligan boost, policy
priors) was running through a `None`-gated path on every choose_action /
score_candidates invocation.

Fix: in build_ai_context, after `analyze_for_player`, look up the AI's
declared bracket_tier from `state.deck_pools` and rebuild the AI
player's session features via `invalidate_player_features` +
`ensure_player_features` when the tier differs from the analyze-time
default. Tier-guarded so the non-cEDH hot path is unaffected.

Adds `score_candidates_boosts_heliod_combo_activation_for_cedh_ai`:
sets up Heliod + Walking Ballista on a cEDH-tagged AI's battlefield
with synthetic activated abilities, calls score_candidates, asserts
the combo activation outscores PassPriority by at least 50% of the
config-derived bonus (combo_progress_this_turn_bonus * tactical_weight).
The margin is computed from config so a future weight retune still
catches genuine wiring regressions instead of silently passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(phase-server): unify cEDH bracket-violation error wording

Three send paths surfaced the same bracket-violation event with two
different framings: the host fanout (lobby-join broadcast) and the
seat-delta broadcast both prefixed "Cannot start cEDH game: ", while
the direct-to-joiner path sent the raw `bracket_err.to_string()`
without the prefix.

Adds the prefix to the joiner-direct path so all three audiences see
identical wording for the same event. No helper extracted — three
inlined uses is below the duplication threshold.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(phase-ai): add choose_action integration coverage for cEDH combos

The prior commit's `score_candidates_boosts_*` test stopped at the scoring
layer. choose_action's softmax+RNG selection sits one layer above and
could theoretically waste a dominant combo score on a wiring bug. Two
new tests close that gap:

- `choose_action_picks_combo_activation_for_cedh_ai` — runs 50 deterministic
  trials over the same Heliod+Ballista cEDH-tagged state and asserts at
  least 40/50 select one of the line's combo activations. With temperature
  0.2 and the combo's ~+1.5 dominance, softmax theory predicts >95% per
  trial; the 80% threshold tolerates benign reshuffling but catches real
  regressions.

- `choose_action_does_not_boost_combo_without_is_cedh` — same state with
  the bracket_tier flipped to Core. Asserts combo_count < 35/50, locking
  in that the bonus (not some unrelated bias) drives selection. The
  baseline rate with is_cedh=false sits around 27/50 because legal combo
  activations are simply available; only a true wiring leak would push
  selection into the >70% regime.

The shared setup is now parameterized into
`cedh_combo_state_with_synthetic_abilities(tier)` so both positive and
negative cases stay in sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(client): seed AI picker with bundled cEDH demo deck

The cEDH AI difficulty had no usable on-ramp: picking cEDH on an AI seat
showed "no decks" because the precon catalog (generated from MTGJSON)
contains no cEDH precons, the PRECON_BRACKETS overlay is empty, and the
saved-deck flow requires user action.

This ships a hand-curated bundled-cEDH-deck mechanism distinct from the
MTGJSON catalog:

- New BUNDLED_CEDH_DECKS map in client/src/data/cedhDecks.ts. Each entry
  is a DeckEntry with type === "Commander Deck" and is authored against
  the same shape MTGJSON precons use, so it flows through the existing
  precon source path in buildDeckCatalog.
- buildDeckCatalog gained a second loop that surfaces bundled cEDH
  entries after the MTGJSON loop. Bracket is the literal cEDH value
  (not looked up via getPreconBracket), so the deck appears under
  filterByBracket(_, 5) regardless of MTGJSON catalog state — bundled
  surfacing is independent of fetch success.
- One demo deck: BundledCedh_HeliodBallista_Demo, a legal 99-card mono-
  white Commander deck (Heliod, Sun-Crowned + Walking Ballista combo)
  with 17 curated cards (combo pieces, ramp, removal, utility, mana-
  fixing lands) and 82 Plains as padding to reach the CR 903.5a card
  count. Off-color cards (Thoracle/Consult/etc.) are documented as
  removed per CR 903.4.
- Three new tests prove the bundled deck appears in the catalog,
  carries source.type "precon" + bracket 5, and surfaces through
  filterByBracket independently of MTGJSON availability.

Follow-ups: (1) extract the near-duplicate MTGJSON/bundled push loops
into a shared helper once a second bundled deck lands. (2) Expand the
demo deck's curated card mix beyond skeleton quality.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(client): add Inalla bundled cEDH demo + dedup precon push helper

Adds a second bundled cEDH demo deck and resolves the loop-duplication
the previous commit's code review flagged as an Important follow-up.

Deck phase-rs#2: BundledCedh_InallaThoracle_Demo (Grixis = U/B/R color identity)
features the Thassa's Oracle + Demonic Consultation registered combo
(both off-color in mono-white, hence why Heliod's deck couldn't host
them). Legal 99-card mainboard: 30 curated singletons (combo + tutors
+ counterspells + cantrips + fast mana + removal + 5 colorless utility
lands) + 35 Islands + 34 Swamps. No Plains/Forests/Mountains and no
White/Green spells per CR 903.4.

Helper extraction: pushPreconCandidate consolidates the shared push
shape both precon flows (MTGJSON catalog and bundled cEDH) need. The
helper takes the bracket as a parameter so MTGJSON entries continue
to look up via getPreconBracket while bundled entries pass the literal
CEDH_BRACKET. The bundled-specific id-collision guard
(`if (decks && decks[deckId]) continue;`) stays at the call site —
it's semantically bundled-specific, not helper-shared.

Adding a third bundled deck now requires one BUNDLED_CEDH_DECKS entry
and zero deckCatalog.ts changes — class-not-card extensibility per
CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(client): add Winota Kiki-Felidar bundled cEDH demo deck

Third bundled cEDH demo deck, validating that the Task F helper
extraction achieved its goal — this required zero deckCatalog.ts changes.

BundledCedh_WinotaKikiFelidar_Demo (Boros = Red/White) features the
Kiki-Jiki, Mirror Breaker + Felidar Guardian registered combo: Kiki
copies Felidar with haste, the token Felidar's ETB blinks Kiki, and
the loop produces unbounded hasty attackers for lethal combat damage.
Both combo pieces are mono-color (Kiki Red, Felidar White) so Boros
is the minimal legal color identity.

Legal 99-card mainboard: 23 curated singletons (combo, colorless fast
mana, white removal, red/white interaction, colorless utility lands)
+ 40 Plains + 36 Mountain. No Islands/Swamps/Forests and no Blue/Black/
Green spells per CR 903.4.

The multi-deck enumeration test now asserts all three bundled decks
surface with bracket 5 and source type "precon".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cedh): address PR phase-rs#816 review comments

- CRITICAL: combo detector computed mana shortfall with i32 saturating_sub,
  which floors at i32::MIN — `2 - 3 = -1` cast to u8 became 255, making the
  policy treat an affordable combo as unreachable. Use unsigned subtraction
  so it saturates at 0.
- HIGH: Tauri adapter only mapped the DeckNotCedh bracket error to the typed
  BRACKET_VIOLATION; TooManyPlayers ("limited to 4 seats") bypassed it. Match
  both Display messages so table-size violations show the blocking modal too.
- MEDIUM: guard combo piece lookups with players.get() instead of direct
  index — an eliminated player would otherwise panic.
- MEDIUM (R2): replace DeckFeatures::is_cedh bool with bracket_tier:
  CommanderBracketTier. Consumers gate on `== Cedh`; the full tier keeps the
  design space open for future bracket-aware behavior.
- MEDIUM: drop the redundant bracket_tier parameter on
  resolve_player_deck_list — PlayerDeckList already carries it, removing the
  sync hazard.
- MEDIUM: correct the stale "1 line" perf comment in ComboLinePolicy (the
  registry now holds 3 lines).
- MEDIUM: document why validate_cedh_bracket carries no CR annotation — the
  Commander Bracket system is WotC format guidance (2024+), not the
  Comprehensive Rules, so a CR number would be fabricated.

The "overly broad combo heuristic" comment was already resolved earlier:
verdict() matches candidates against the line's resolved required_actions and
gates on missing_mana == 0. Caching reachable_lines() remains a documented
follow-up (per-call cost is still small at 3 lines).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(engine): drop Thorin from anaphoric-scope freeze guard after merge

The merge from main brought in the phase-rs#511/phase-rs#512 parser fix that corrects
Thorin, Mountain-King's category-2/3 anaphoric "its" misparse, so the card
no longer retains ObjectScope::Anaphoric in the exported card data. The
freeze guard is a tripwire designed to fail exactly here; per its own
instructions, remove the card from ANAPHORIC_SCOPE_CARDS and drop both count
assertions 265 -> 264.

Surfaced after merge because the pre-push hook runs parser::oracle::tests but
not the engine integration suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(ai): spec cEDH AI 4-card mulligan floor

Design for a ForceKeep mulligan verdict that stops the cEDH AI from
mulliganing below 4 cards, with CR 103.5 free-first card-count math.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(engine): add kept_hand_size_after mulligan helper (CR 103.5)

Expose a public `kept_hand_size_after(mulligan_count, free_first)` helper in
`crates/engine/src/game/mulligan` so cEDH AI logic can query how many cards
a player would keep before committing to another mulligan, without needing
to reach into the private `bottom_count_for` function.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(engine): correct CR annotations and add mulligan boundary tests

Starting hand size and the kept-hand free-first discount are governed by
CR 103.5 / 103.5c, not CR 103.4 (starting life total). Fix the
STARTING_HAND_SIZE constant comment and the kept_hand_size_after doc
annotation, and add boundary tests exercising the saturating_sub floor
where the kept hand reaches zero cards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai): cEDH mulligan floor — never mulligan below 4 cards

Add `MulliganScore::ForceKeep` variant with three-way registry
precedence (ForceKeep > ForceMulligan > score sum), and implement a
hard card-count floor in `CedhKeepablesMulligan` that emits `ForceKeep`
when one more mulligan would leave fewer than 4 cards, covering both
free-first and normal London mulligan rules via
`engine::game::mulligan::kept_hand_size_after`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(ai): cover cEDH mulligan floor end-to-end + tidy const placement

Add a registry-level integration test wiring the real
`CedhKeepablesMulligan` alongside a `ForceMulligan` policy with cEDH
features at a floor-engaged mulligan count, proving the real cEDH floor
`ForceKeep` overrides a real `ForceMulligan` through the registry. Move
`CEDH_MULLIGAN_FLOOR` below the use groups so the imports stay contiguous.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(engine): tag ChooseFromZoneConstraint so the choice modal can validate

ChooseFromZoneConstraint serialized externally-tagged
(`{ "DistinctCardTypes": {...} }`), but the frontend CardChoiceModal reads it
internally-tagged on `constraint.type`. The mismatch left `type` undefined, so
the confirm button never enabled for any selection under a DistinctCardTypes
constraint (Atraxa, Grand Unifier). Add `#[serde(tag = "type")]` to match the
sibling SearchSelectionConstraint convention and the frontend contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(client): make cEDH a table-wide toggle instead of a per-seat difficulty

Selecting cEDH on any AI seat used to cascade-lock every opponent to cEDH, so
multi-opponent games couldn't mix difficulties without dropping to one opponent.
Replace the per-seat "CEDH" difficulty with a table-wide `cedhMode` toggle at the
top of the AI opponents panel: when on, all AI play cEDH (deck pools restricted
to bracket 5) and per-seat difficulty dropdowns are disabled with a cEDH badge
while preserving each seat's remembered difficulty; when off, every opponent's
difficulty is independent.

`effectiveAiDifficulty` maps `cedhMode` to the engine's per-seat "CEDH" contract
at both init sites, so the engine is unchanged. A persisted-store migration
(v11->v12) converts existing per-seat cEDH selections to `cedhMode`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(tauri): structured cEDH bracket error instead of Display matching

The Tauri initialize_game command returned Err(e.to_string()), forcing the
tauri-adapter to substring-match the Rust Display text ("not declared cEDH" /
"limited to 4 seats") to detect a cEDH bracket violation — fragile and against
the project's typed-over-stringly-typed style. Return a serializable
CommandError enum (BracketViolation / Generic) and discriminate on `kind` in
the adapter, mirroring the WASM worker path's `cedh_bracket_violation` flag so
both transports surface the violation as a typed signal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(PR-816): strip accidental external-tool artifacts

The docs/superpowers/ plan + spec files are Claude Code plugin output and
do not belong in the repo (per pr-contribution-handler skill). Removing them
from the PR per maintainer confirmation.

* fix(PR-816): route cEDH modal + warning chip through i18n; drop redundant anyCedh alias

Architecture-review findings:
- GamePage cEDH bracket-violation modal (title/body/return button/aria-label)
  and GameSetupPage cEDH warning chip rendered hardcoded English instead of
  t(). Added gameSetup.bracketViolation.* + gameSetup.cedhWarning{,Untagged}
  keys to all 7 locale game.json catalogs (real translations) and routed the
  frontend-authored chrome through t(). Engine-authored error text
  (bracketViolationError) intentionally stays un-t()'d per the i18n boundary.
- Removed the redundant 1:1 anyCedh alias of cedhMode in AiOpponentConfig
  (leftover from the removed multi-seat cascade design).

Verified: pnpm type-check + eslint clean; GamePage.bracketViolation and
GameSetupPage suites pass against the real en catalog (test-setup loads real
i18n, so existing copy assertions validate the new keys).

* fix(PR-816): make cEDH combo detector color-aware; drop dead reachability/decision-kind

Architecture-review finding (correctness): StructuralComboDetector collapsed a
ManaCost's colored pips + generic into a single count compared against the
colorless zone_eval::available_mana, so a colored combo line (Heliod+Ballista
{1}{W}, Thoracle+Consultation {1}{U}{U}{B}) was reported ReachableThisTurn
{missing_mana:0} whenever total mana sufficed REGARDLESS of color — driving a
+15 combo_progress_this_turn_bonus onto a combo the AI cannot actually cast.

Fix: consume the engine's existing color-accurate affordability primitive
engine::game::casting::can_pay_cost_after_auto_tap (auto-taps all mana sources
into a simulated pool, runs the real can_pay_for_spell — colored pips, generic,
hybrid, Phyrexian, {C}, restrictions, summoning sickness CR 302.6). The
cost-bearing source is derived generically from the line's first ComboStep
(Activate -> battlefield obj, Cast -> hand obj); NoCost/SelfManaCost short-circuit
(Kiki). An all-pieces-present-but-unaffordable line collapses to NotReachable, so
reachable_lines stops surfacing uncastable lines to the policy. CR 601.2g/601.2h
annotated (grep-verified).

Also (cleanup): removed dead ComboReachability::ReachableSoon (never constructed,
YAGNI) and dropped DecisionKind::SelectTarget from ComboLinePolicy::decision_kinds
(verdict can never score a target candidate -> wasted per-candidate reachable_lines
scan).

Tests: added discriminating negative regression
thoracle_line_not_reachable_with_wrong_color_mana (4 wrong-color lands -> NOT
reachable; fails under old count-based code, passes under fix); updated the 3
existing fixtures to give test lands real basic-land subtypes so the engine
auto-tap path produces the correct colors.

Reviewed-clean via engine-implementer pipeline (2 plan-review rounds, 1 impl
review). Verified with direct cargo (Tilt watches main, not this worktree):
cargo clippy -p engine -p phase-ai --all-targets clean; cargo test -p phase-ai
--lib 676 passed.

* fix(PR-816): add bracket_tier/ai_difficulties to whitemane test deck literals

Merge-interaction fix surfaced by bringing the branch current with origin/main:
c71d2da (Whitemane Lion, phase-rs#1268) added crates/phase-ai/tests/whitemane_lion_bounded.rs
constructing DeckList/PlayerDeckList with the old field set, while this PR added
bracket_tier (PlayerDeckList) and ai_difficulties (DeckList). #[serde(default)]
covers deserialization but not struct-literal construction, so the test target
failed to compile after the merge. Added the fields via the PR's established
Default::default()/Vec::new() pattern (sibling coverage for the struct change).
cargo clippy -p phase-ai --all-targets now clean.

* fix(PR-816): center mana pips, wire bracket filter to engine estimate, collapsible groups

- ManaCostPips: flex-center the glyph in its pip circle (inline-block baseline
  pushed it ~5px low); normalize lg padding now that the baseline hack is moot.
- aiDeckCatalog: resolve a Commander candidate's bracket from the engine's
  computed BracketEstimate when no explicit tag exists, so the AI bracket
  filter stops collapsing every selection to 'Random (0)'. Explicit tags win;
  non-Commander formats skip estimation. Adds fallback + tag-precedence tests.
- test-setup: register a lean English-only i18next instead of importing the app
  i18n module, which eager-loaded 49 catalogs + a store subscription per test
  file under worker isolation (~54% less per-file setup time).
- board: make expanded permanent groups collapsible via a count badge that
  toggles the group (BattlefieldRow toggle + GroupedPermanent button); add the
  collapseGroup key across all 7 locales to satisfy the key-parity gate.

---------

Co-authored-by: AgilErck <Erickd1190@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: AgilErck <25510186+AgilErck@users.noreply.github.com>
matthewevans added a commit that referenced this pull request Jun 10, 2026
* refactor(engine): route single-authority bypasses through canonical entries

- explore.rs: replace hand-rolled library->hand zone mutation with
  zones::move_to_zone (emits ZoneChanged + runs zone-exit cleanup)
- layers: drop the unused evaluate_layers re-export; migrate the two
  production direct callers (become_copy, engine_replacement) to
  flush_layers / mark_layers_full + flush_layers; doc-reserve direct
  evaluate_layers calls for tests
- ability_utils: funnel validate_selected_targets{,_for_ability} count
  validation through one shared inner body (targeting.rs pattern)
- engine_resolution_choices: route the two synthetic-ability resolver
  bypasses through resolve_ability_chain; doc resolve_effect's contract
- keywords: document the object-scoped vs state-scoped query tiers

* feat(parser): single authority for expressing unparsed text

Effect::unimplemented(name, fragment) is the one way to construct the
Unimplemented effect; name is a stable pattern-class key consumed by
coverage gap categorization. Delete the dead parse_or_unimplemented /
option_to_nom boundary fns (zero callers) and fix the stale CLAUDE.md
oracle_nom/error.rs description.

* ci: extend parser gate + add engine-authority and skill-doc gates

- check-parser-combinators.sh: close .rfind/.split/.splitn blind spots;
  new family (E) verbatim-sentence equality (== "25+ chars"); new
  family (F) hand-constructed Effect::Unimplemented literals
- check-engine-authorities.sh (new): diff gate forbidding raw
  .keywords.contains/.iter queries outside the keyword authorities
- check-skill-doc.sh (new): asserts oracle-parser SKILL.md matches the
  parser tree (paths, symbols, priority-table row parity); SKILL.md
  regenerated against the code (38-slot priority table, IR layer,
  clause_shell, oracle_static/ split, single-authority doctrine)

* refactor(parser): make condition bridges exhaustive over their source enums

static_condition_to_trigger_condition, static_condition_to_ability_condition,
and ability_condition_to_static_condition no longer hide unbridged variants
behind wildcards: adding a StaticCondition/AbilityCondition variant is now a
compile error at each bridge, forcing a conscious bridging decision. All
expanded arms map to None exactly as the wildcards did (no behavior change).

* refactor(engine): cost-payment Phase 0 — sorcery-speed delegation + pay_from_pool rename

Phase 0 of the cost-payment unification plan (.planning/cost-payment-unification):

- casting.rs: CastingProhibitionCondition::NotSorcerySpeed now delegates to
  restrictions::is_sorcery_speed_window instead of re-deriving the CR 307.1
  timing predicate inline — restrictions.rs is the single timing authority.
- mana_payment.rs: pay_cost → pay_from_pool (pool-level arithmetic only),
  freeing the pay_cost name for the future game/costs.rs ability-cost
  payment authority. Callers + re-export updated.

* refactor(parser): consolidate three parallel duration grammars into oracle_nom/duration.rs

Single authority for Oracle duration phrases (CR 611.2 / CR 611.2b /
CR 514.2): the prefix-nested nom grammar in oracle_nom/duration.rs now owns
every phrase→Duration mapping. strip_leading_duration /
strip_trailing_duration in oracle_effect/lower.rs are positional-only
wrappers (word-boundary scan + quantity-clause guard; the two phrase tables
are deleted), and the three inline re-encodings in oracle_effect/subject.rs
delegate to the grammar.

Conflict between the old grammars resolved in the strip table's (test-pinned)
favor: 'for as long as you control ~' maps to UntilHostLeavesPlay. That is a
pre-existing imprecision vs CR 611.2b (control loss without leaving the
battlefield should end it) — now visible in exactly one place.

* refactor(parser): replace verbatim sentence clusters with nom single authorities

- 'Your speed can increase beyond 4' (CR 702.179d-e) was string-equality
  encoded at three sites (two oracle.rs routing predicates + the semantic
  parse in oracle_static/dispatch.rs); now one is_speed_unlock_sentence
  combinator in dispatch.rs, called by all three.
- The ChooseFromZone dig continuation 'put the rest on the bottom of your
  library [in a random order | in any order]' (CR 401.4) was three full-line
  equalities in sequence.rs; now one all_consuming combinator with the order
  suffix as an opt(alt(...)) axis.

* refactor(engine): zone-change pipeline Phase A — carve out game/zone_pipeline.rs

Zero-behavior-change carve-out per .planning/zone-change-pipeline/PLAN.md:
execute_zone_move / deliver_replaced_zone_change / apply_zone_delivery_tail
and friends move verbatim from effects/change_zone.rs into the new
game/zone_pipeline.rs module (one mechanical super::→crate:: path fix); a
pub(crate) use shim keeps every existing caller compiling unchanged.

Seeds the Phase B+ vocabulary (not yet consumed, scoped #[allow(dead_code)]):
ZoneMoveRequest + builders, ZoneChangeCause (CR-annotated exemption split),
EntryMods (EtbTapState, not bool), ExileLinkSpec, DeliveryCtx, and the
ApprovedZoneChange proof token (no Serialize/Deserialize/Clone/Default;
private event field; pub(crate) approve_post_replacement mint path).

Independently reviewed: APPROVED. Phase B watch-item: move_object must seed
EtbTapState onto the ProposedEvent directly instead of round-tripping
through execute_zone_move's legacy bool boundary.

* refactor(types): unify Effect::AddCounter into Effect::PutCounter (CR 122.1)

Effect::AddCounter and Effect::PutCounter expressed the same operation
(place counters on an object) through two variants, forcing every match
site to handle both — and several AI policy sites silently handled only
one (cast_facts, redundancy_avoidance had AddCounter-only legs with
PutCounter receiving different treatment). One variant, one meaning:
PutCounter is the single authority, with #[serde(alias = "AddCounter")]
accepting legacy serialized data.

All ~70 match/construction sites across engine, phase-ai, and
mtgish-import migrated; duplicate arms produced by the rename were
collapsed (compiler-verified via -D unreachable-patterns). Engine
inventory regenerated; the two remaining AddCounter entries are
unrelated enums (ReplacementEvent / game event).

* refactor(parser): rewrite parse_restriction_modes as negation x verb-phrase list grammar

Replace ~14 verbatim equality clusters ('can't attack or block',
'can't block or be blocked', ...) with a composed nom grammar:
alt(("can't", "cannot")) + separated_list1 over restriction atoms
(attack / block / be blocked / be sacrificed / be enchanted / be
equipped / be countered / transform / crew). Any combination of atoms
now parses — including orders no current card uses — instead of only
the enumerated permutations. Elided-'be' English ellipsis ('can't be
equipped or enchanted') handled via a bare-atom fallback inside the
list parser.

Also freezes oracle_quantity.rs for new grammar: new quantity
recognition belongs in oracle_nom/quantity.rs (module doc).

* refactor(parser): delete zero-caller parse_target_phrase wrapper

oracle_target::parse_target is the single authority for 'target <type
phrase>' extraction; the unused nom wrapper was a second grammar waiting
to drift. Its tests retargeted to parse_type_phrase, the building block
production actually calls.

* ci: make coverage ratchet informational on push-to-main

On push-to-main the regression check can only do harm: the merge has
already happened, and a failing check blocks the R2 baseline republish
that runs later in the same job — wedging main red until someone
manually uploads a baseline (the recurring PR #2339 / PR #2802
swallowed-clause ratchet deadlock). continue-on-error on main pushes
lets the baseline self-heal after an intentional ratchet absorption,
while PR and merge_group runs remain fully blocking.

* refactor(types): replace sorcery_speed bool with ActivationRestriction::AsSorcery single authority

CR 602.5d: 'Activate only as a sorcery' timing is now represented solely by
ActivationRestriction::AsSorcery in AbilityDefinition.activation_restrictions.
The parallel sorcery_speed display bool is deleted; is_sorcery_speed() queries
the restriction. A hand-written Deserialize (AbilityDefinitionDe mirror)
tolerates the legacy serialized field and migrates sorcery_speed:true into
AsSorcery (dedup). Serialized exports no longer emit the key; snapshots and
the integration card fixture follow.

* refactor(types): fold ActivationCadence into ActivationRestriction (CR 602.5b)

CR 602.5b: once-each-turn activation cadence on keyword actions (Crew) is now
expressed with the existing ActivationRestriction::OnlyOnceEachTurn instead of
the parallel ActivationCadence enum, which is deleted. Keyword::Crew's
once_per_turn becomes Option<Box<ActivationRestriction>> (None = unrestricted;
boxed to break the Keyword -> ActivationRestriction -> ParsedCondition ->
Keyword size cycle). The custom Crew deserializer accepts both the legacy
ActivationCadence tagged shape ({"type":"Unlimited"}/{"type":"OncePerTurn"})
and the new Option<ActivationRestriction> shape. Regenerated integration card
fixture carries the new export shape (and drops the legacy sorcery_speed key).

* docs(engine): fix stale sorcery_speed display-flag comment (review follow-up)

* refactor(engine): seed three-state EtbTapState directly onto ZoneChange proposal (Phase B watch-item)

execute_zone_move took `effect_enter_tapped: bool` and could only ever set
EtbTapState::Tapped, collapsing the Unspecified-vs-Untapped distinction the
pipeline carrier (ProposedEvent::ZoneChange.enter_tapped: EtbTapState) exists
to preserve. move_object further round-tripped its req.mods.enter_tapped
(EtbTapState) through .is_tapped() before passing it in.

Thread EtbTapState end-to-end: execute_zone_move now accepts EtbTapState and
seeds it onto the proposal whenever it is not Unspecified (CR 614.1). All
callers that already hold an EtbTapState (change_zone resolver paths, seek)
pass it through unchanged; bool-literal callers pass EtbTapState::Unspecified.
move_object passes req.mods.enter_tapped directly. No bool round-trip remains.

This is the recorded Phase A review condition for proceeding with Phase B.

* refactor(engine): route destroy.rs delivery through zone pipeline proof token (Phase B)

apply_destroy_after_replacement delivered its inner (post-replacement)
ZoneChange with a bare zones::move_to_zone in both arms (the post-Destroy inner
move and the outer-replacement-redirected-to-ZoneChange move). A destruction
redirected to the battlefield (CR 614.6) therefore dropped the entire delivery
tail: CR 614.1c enters-with-additional-counter statics, enter_tapped /
enter_with_counters, exile-link tracking, and the post-replacement-continuation
drain.

Seal each already-replaced ZoneChange via ApprovedZoneChange::approve_post_replacement
(preserving its `applied` set and re-validating it is a ZoneChange) and deliver
through zone_pipeline::deliver — the single delivery tail. A NeedsChoice from
the tail propagates as `false` (state.waiting_for already set) so the caller
does not advance.

Adds a discriminating integration test
(destroy_redirect_to_battlefield_delivery_tail) that casts "Destroy target
creature" at a victim with a Moved->Battlefield redirect under a CR 614.1c
additional-+1/+1-counter static, and asserts the redirected creature receives
the counter — fails on the old raw move (0 counters), passes through the tail.

CR 701.8a (line 3315), CR 614 / 614.6 (line 3072), CR 614.1c (line 3056).

* refactor(engine): route sacrifice.rs delivery through zone pipeline proof token (Phase B)

apply_sacrifice_after_replacement delivered its inner (post-replacement)
graveyard ZoneChange — and the outer-redirect ZoneChange — with a bare
zones::move_to_zone. A sacrifice redirected to the battlefield (CR 614.6)
therefore dropped the delivery tail (CR 614.1c enters-with-additional-counter
statics, enter_tapped / enter_with_counters, post-replacement-continuation).

Seal each already-replaced ZoneChange via ApprovedZoneChange::approve_post_replacement
and deliver through zone_pipeline::deliver. The Sacrifice proposal carries no
source, so no exile-link context is attributed. A NeedsChoice from the tail
returns SacrificeApply::NeedsChoice (state.waiting_for already set) so the
caller pauses.

Adds a discriminating test (sacrifice_redirected_to_battlefield_applies_enters_with_counters_tail)
driving the real sacrifice_permanent pipeline with a Moved->Battlefield redirect
under a CR 614.1c additional-+1/+1-counter static — fails on the old raw move
(0 counters), passes through the tail.

CR 701.21a (line 3445), CR 614 / 614.6 (line 3072), CR 614.1c (line 3056).

* refactor(engine): route sba.rs lethal-damage delivery through zone pipeline proof token (Phase B)

The lethal-damage state-based-action destruction loop (check_lethal_damage)
delivered its inner (post-replacement) ZoneChange with a bare
zones::move_to_zone. A lethal-damage death redirected to the battlefield
(CR 614.6, Rest in Peace / "would die -> return" class) therefore dropped the
delivery tail (CR 614.1c enters-with-additional-counter statics, enter_tapped /
enter_with_counters, post-replacement-continuation).

Seal the already-replaced ZoneChange via ApprovedZoneChange::approve_post_replacement
(source carried from the Destroy event) and deliver through zone_pipeline::deliver.
Per CR 704.3, completing all SBAs may require a replacement choice surfaced by
the delivery tail (CR 614.12a Devour as-enters); the NeedsChoice arm pauses
exactly as the existing regeneration NeedsChoice arm does (state.waiting_for
already set by the tail).

Adds a discriminating test (sba_lethal_damage_redirected_to_battlefield_applies_enters_with_counters_tail)
driving check_lethal_damage directly with a Moved->Battlefield redirect under a
CR 614.1c additional-+1/+1-counter static — fails on the old raw move
(0 counters), passes through the tail (exactly 1).

CR 701.19b (line 3426), CR 704.3 (line 5457), CR 704.5g (line 5476),
CR 614 / 614.6 (line 3072), CR 614.1c (line 3056), CR 614.12a (line 3099).

* docs(engine): correct SBA delivery CR annotation to 704.5g + 614.6 (review follow-up)

701.19b is regeneration-via-static; the delivered event here is a
lethal-damage destruction (CR 704.5g) whose redirect is a replacement
(CR 614.6).

* refactor(engine): hoist token + B->B no-op guards into move_object preflight (Phase C0a, Risk #11)

The unified pipeline runs replace_event ahead of zones::move_to_zone's
delivery-time guards. A one-shot replacement could therefore be consumed
(last_effect_count, CR 616.1 choices) on a move the primitive then rejects as
a no-op. Hoist the two cheap, side-effect-free object-level guards
(CR 111.8 token-outside-battlefield cease-to-exist, CR 603.2g + CR 603.6a
Battlefield->Battlefield no-op) into move_object's preflight, before the
execute_zone_move replacement consult.

Behavior-neutral in Phase C0a (move_object has no production callers yet);
the guards take effect as the bucket-B effect sites migrate in C1+.

CR 111.8 (line 663), CR 603.2g (line 2573), CR 603.6a (line 2595).

* fix(engine): clear damage on lethal-damage death redirected to battlefield (Phase C0b, CR 614.5)

A lethal-damage SBA destruction redirected by a Moved replacement back to the
battlefield delivers a Battlefield->Battlefield ZoneChange. zones::move_to_zone's
CR 603.2g no-op guard returns before reset_for_battlefield_entry, so the
creature keeps its marked damage. The SBA fixpoint then re-derives lethal damage
and re-fires the destruction-replacement on every iteration (counter / event
stacking, capped at MAX_SBA_ITERATIONS=9) — a pre-existing CR 614.5 violation
(a replacement gets only one opportunity), surfaced visibly by the Phase B
delivery-tail routing because the enters-with-additional-counter static now
applies on each re-entry.

Root cause: the no-op guard is correct for a spurious self-loop (Coiling Oracle)
but the redirected death is a genuine leave-and-re-enter — a new object per
CR 400.7 whose damage is gone. After delivering the redirect, clear the marked
damage the no-op delivery left behind so the fixpoint sees no lethal damage and
the one-shot replacement is not re-applied.

Adds a discriminating test (sba_lethal_damage_redirect_to_battlefield_applies_counter_exactly_once)
driving check_state_based_actions with a Moved->Battlefield redirect under a
CR 614.1c additional-+1/+1-counter static: pre-fix the creature ends with
Some(9) counters (9 re-fires); post-fix exactly Some(1).

CR 614.5 (line 3069), CR 400.7 (line 1948), CR 603.2g (line 2573), CR 704.5g.

* fix(engine): route mill delivery through zone pipeline so Moved redirects fire (Phase C1)

mill::apply_mill_after_replacement delivered each milled card with a bare
zones::move_to_zone, which never proposed a per-card ZoneChange. Moved-level
redirects ("if a card would be put into a graveyard from anywhere, exile it
instead" — Rest in Peace / Leyline of the Void class) were therefore silently
dropped for milled cards: a milled card reached the graveyard even with Rest in
Peace in play (PLAN §8 Risk #1; confirmed bug).

Route each milled card through zone_pipeline::move_object, which proposes the
inner ZoneChange and consults the Moved replacements before delivery. The milled
card itself anchors the Effect cause (mill to a graveyard creates no exile-link
and a Moved replacement's valid_card is evaluated against the moved card, so
this matches the pre-pipeline raw attribution while enabling the consult).

A per-card NeedsChoice (CR 616.1 multi-replacement race on one milled card) is
parked (state.waiting_for already set) and stops the batch; no real card
produces this on a library->graveyard mill, so the resumable batch-continuation
(PLAN §5a / Risk #10) is deferred. Discriminating test asserts non-interactivity
via the mandatory RIP redirect.

Adds mill_honors_rest_in_peace_graveyard_to_exile_redirect: drives the real Mill
pipeline with a global graveyard->exile Moved replacement and asserts milled
cards land in EXILE (graveyard empty) — fails on the old raw move (graveyard).

Also removes the now-live #[allow(dead_code)] on zone_pipeline::move_object.

CR 701.17a (line 3402), CR 614.6 (line 3072), CR 616.1 (line 3169).

* fix(engine): resume mill batch across per-card replacement choices instead of stranding (Phase C1 review fix)

The C1 mill migration bailed with 'return Ok(())' when a per-card Moved
replacement surfaced a choice, on the false claim that no real card produces
one: the CR 616.1 materiality classifier treats ANY destination-redirecting
Effect::ChangeZone as Unconditional-material, so two simultaneously-applicable
graveyard->exile redirects (Rest in Peace + Leyline of the Void — a real,
common combination) prompt for ordering on EVERY milled card. Pre-fix the
first card's prompt never even surfaced (the pause set pending_replacement but
no caller parked waiting_for) and cards 2..N stranded in the library.

Implement the PLAN §5a batch continuation (option a):
- mill's per-card delivery loop now parks the prompt via
  replacement::park_waiting_for and stashes the undelivered tail in the new
  state.pending_mill_deliveries (PendingMillDeliveries { remaining,
  destination }).
- handle_replacement_choice drains the parked tail after the chosen event
  delivers (Execute arm, following the established pending-queue drain
  pattern; Prevented arm analogous) via
  mill::drain_pending_mill_deliveries, re-parking when the next card surfaces
  its own prompt.

Discriminating test mill_under_two_graveyard_redirects_delivers_every_card_through_ordering_choices:
mills 3 cards under RIP + Leyline, answers each CR 616.1 ordering prompt, and
asserts ALL milled cards leave the library and end in exile with the tail
fully drained. Pre-fix it fails (no prompt surfaced; cards stranded).

CR 701.17a (line 3402), CR 616.1 (line 3169), CR 614.6 (line 3072).

* docs(engine): scope C0b damage scrub as degenerate self-redirect guard, not CR 400.7 re-entry (review fix)

The C0b comment justified clearing damage_marked via the CR 400.7 new-object
rule, contradicting the chosen delivery: the Battlefield->Battlefield no-op
means reset_for_battlefield_entry never ran, so incarnation epoch, summoning
sickness, counters, and entered_battlefield_turn are all stale — claiming a
new object while delivering a stale one is inconsistent.

Reword to the narrow reading: a 'remains on the battlefield instead of dying'
replacement is regeneration-shaped — CR 701.19a/b replaces destruction with
'remove all damage marked on it' while the permanent STAYS the same object —
so the two-field damage scrub matches that semantics without claiming a
new-object re-entry. Add a TODO at the site recording the staleness (and the
delivery tail's CR 614.1c counter re-application) as the open question for
when a real would-die->battlefield redirect card class appears: no card
currently parses to one (parser builds die->exile / shuffle-back;
Persist/Undying are dies-triggers), so the semantics decision is deliberately
not baked in on zero real cards. Test doc header updated to match.

Comment-only change; behavior unchanged (test still passes: exactly one
CR 614.1c counter per SBA fixpoint).

CR 701.19a/b (lines 3424/3426), CR 603.2g (line 2573).

* docs(engine): correct mill NeedsAuraAttachmentChoice stash comment (round-2 review P2)

The stash comment claimed reaching the aura-attachment-choice arm 'fails
loudly (undrained tail)'. It does not: an aura-host choice surfaces as
WaitingFor::ReturnAsAuraTarget, not the replacement-choice path, so
drain_pending_mill_deliveries (only invoked from handle_replacement_choice)
never fires for it. A stale pending_mill_deliveries would instead be silently
drained by the NEXT unrelated replacement-choice resume. The arm is dead code
for every parsed Mill today (Battlefield is not a Mill destination); document
the real behavior so a future battlefield-mill variant is understood as a bug
to surface rather than a self-healing path.

CR 303.4f (line 822).

* fix(engine): propagate mill per-card pause through apply_mill_after_replacement (round-2 review P1)

The nested Mill-event resume path clobbered a per-card mill park. When a
Mill-event replacement (e.g. a mill-doubler) resolves through a CR 616.1
ordering choice, handle_replacement_choice's Mill arm applied the accepted
event with 'let _ = apply_mill_after_replacement(...)', discarding the helper's
pause signal, then unconditionally reset state.waiting_for to Priority (~:392).
If deliver_mill_cards parked a per-card prompt inside that arm (two
simultaneously-applicable graveyard->exile redirects make every milled card
prompt for CR 616.1 ordering), the reset stranded the first paused card.

Plumb deliver_mill_cards's existing pause bool out through
apply_mill_after_replacement (now returns Result<bool, EffectError>; true =
fully delivered, false = parked) and early-return from the Mill arm on a pause,
mirroring the apply_etb_counters early-return precedent. EffectError has no
EngineError conversion at that arm, so the error is mapped to 'delivered'
(preserving the prior let _ swallow) and only the pause is acted on. mill::resolve
also bails before EffectResolved on a per-card pause so it doesn't emit a
resolution event over a parked prompt.

Reachable only with two simultaneous Mill-event replacements that both prompt,
which no parsed card produces, so a full runtime repro is impossible. The unit
test apply_mill_after_replacement_reports_per_card_pause_to_caller drives the
shared seam directly: under two graveyard->exile redirects the first milled
card surfaces a CR 616.1 prompt, asserting the helper returns false, leaves
waiting_for set to that prompt, and parks the tail — the exact contract the
Mill arm's early-return now depends on.

CR 701.17a (line 3402), CR 616.1 (line 3169), CR 614.6 (line 3072).

* docs(engine): confirm rad-counter mill read is park-safe (round-2 review P3)

rad_counters assesses CR 728.1 life loss from library_before (a pre-mill
snapshot) immediately after apply_mill_after_replacement. Document that this
read remains correct even if the mill parks mid-batch on a per-card CR 616.1
ordering choice: milled_ids is fixed by the post-replacement count against the
snapshot (identifying the correct top-N cards regardless of delivery routing),
and the per-card life-loss loop reads card type from state.objects, which is
zone-independent. The parked tail is delivered by the resume path; the snapshot
predates any delivery.

CR 728.1 (line 6239).

* fix(engine): route countered spell to graveyard/exile through zone pipeline (Phase C3)

counter::resolve and resolve_all moved a countered spell off the stack with a
raw zones::move_to_zone, which never proposed a per-card ZoneChange. Moved-level
graveyard redirects (Rest in Peace / Leyline of the Void: 'if a card would be
put into a graveyard from anywhere, exile it instead') were therefore silently
dropped for countered spells: a countered spell reached the graveyard even with
Rest in Peace in play (PLAN §8 Risk #3 — graveyard-redirect class, confirmed
bug).

Route the stack -> graveyard/exile move through zone_pipeline::move_object so
the inner ZoneChange is proposed and Moved replacements are consulted before
delivery. The exile-on-counter destination (CR 702.34a/127a/180a Flashback /
Aftermath / Harmonize) is a static destination rule, not a replacement, so it
is still selected before the consult. SpellCountered now fires immediately on
stack removal (the counter itself) rather than after the consequent move, so a
CR 616.1 ordering pause during delivery does not drop it. A single applicable
redirect never prompts; only two simultaneous redirects produce a CR 616.1
choice — that mass multi-card continuation is the Phase C4 class (bail on pause;
no parsed card combines mass counter with a double graveyard redirect today).

Discriminating test countered_spell_honors_rest_in_peace_graveyard_to_exile_redirect:
counters a spell with a global graveyard->exile Moved redirect on the
battlefield and asserts the spell ends in EXILE (graveyard empty). FAILS on the
pre-C3 raw move (spell reaches the graveyard, redirect dropped).

CR 608.2b (line 2807), CR 614.6 (line 3072), CR 616.1 (line 3169), CR 701.6a (line 3309).

* fix(engine): route mass bounce through zone pipeline + generalize batch continuation (Phase C4)

bounce::resolve_all delivered each mass-bounced permanent with a raw
zones::move_to_zone under a comment claiming 'no replacement-pipeline detour is
needed because mass-bounce events are not destruction events (CR 614.6 doesn't
apply here)'. That justification was wrong by citation: CR 614.6 governs
replacement semantics generally, and CR 614.1 replacements watch zone-change
*events*, not only destruction. The raw move never proposed a per-object
ZoneChange, so Moved redirects watching the bounce destination ('if a permanent
would be returned to a hand, exile it instead' class) silently never fired
(PLAN §8 Risk #4). The single-target bounce raw moves (battlefield/graveyard/
stack -> destination) had the same gap.

Route every bounce move through the pipeline. mass bounce uses a new shared
batch entry zone_pipeline::move_objects_simultaneously; the single-target and
non-targeted-single paths use move_object. The stale 614.6 comment is deleted.

Generalize the Phase C1 mill continuation rather than add a second parallel
struct (CLAUDE.md parameterize-don't-proliferate applies to state types too):
- PendingMillDeliveries -> PendingBatchDeliveries (identical { remaining,
  destination } shape; serialized as a plain struct so the type rename is
  wire-transparent; the GameState field pending_mill_deliveries ->
  pending_batch_deliveries carries a serde field-name alias for save compat).
- New zone_pipeline::move_objects_simultaneously runs each request through
  move_object, parks + stashes the undelivered tail on a CR 616.1 pause, and
  stamps CR 603.10a co-departure over the departed subset on completion (a no-op
  for non-battlefield origins, so mill reuses it unchanged).
- zone_pipeline::drain_pending_batch_deliveries replaces
  mill::drain_pending_mill_deliveries; mill::apply_mill_after_replacement now
  delegates to the shared batch entry, deleting its bespoke deliver_mill_cards.
- engine_replacement.rs drains pending_batch_deliveries from both the Execute
  and Prevented resume arms.

A single applicable redirect never prompts (the realistic path), so the common
mass bounce never pauses. Only two simultaneous redirects on one object split a
batch; the co-departure stamp is then per delivered segment rather than threaded
across the pause boundary (no parsed card hits this — documented).

Two discriminating tests (bounce_destination_redirect.rs):
- mass_bounce_honors_to_hand_redirect: a single to-hand -> exile redirect sends
  every mass-bounced creature to EXILE, not the hand. FAILS on the old raw move.
- mass_bounce_under_two_redirects_delivers_every_permanent_through_choices: two
  simultaneous redirects make every bounced creature prompt a CR 616.1 ordering
  choice; answering each delivers ALL of them (none stranded) and fully drains
  the parked batch tail.

CR 614.6 (line 3072), CR 616.1 (line 3169), CR 603.10a (line 2634), CR 400.7 (line 1948).

* fix(engine): centralize replacement-choice park in move_object so paused single moves can't ghost (round-3 review Fix 1+2)

zone_pipeline::move_object did not park state.waiting_for on NeedsChoice:
replace_event sets only pending_replacement, and the wait-state was each
caller's to set. The C3/C4 single-move migrations (counter.rs x2, bounce.rs x3)
bailed with 'return Ok(())' under comments falsely claiming the pipeline parks.
Under two simultaneously-applicable graveyard->exile redirects (Rest in Peace +
Leyline of the Void, or two RIP copies — RIP is not legendary), a countered
spell was: removed from the stack, SpellCountered emitted, its move parked in
pending_replacement, and the prompt NEVER surfaced (the engine gates
ChooseReplacement on the wait state) — permanently ghosting the spell (off
state.stack but obj.zone == Stack).

Centralize the park at the single unparked origin: execute_zone_move's
replace_event NeedsChoice arm now calls replacement::park_waiting_for before
returning, making counter.rs, bounce.rs, seek.rs (latent same-class ghost — it
discards the result entirely), and every future C5-C9 single-move migration
safe by construction. Idempotence verified:
- park_waiting_for keeps the CR 614.12a devour guard (never clobbers an
  already-surfaced EffectZoneChoice) and recomputes the identical
  ReplacementChoice from the same pending_replacement, so callers that still
  self-park (change_zone's park_waiting_for arms; end_phase /
  exile_from_top_until's replacement_choice_waiting_for) double-set the same
  value.
- sba.rs's self-parks are on its OWN replace_event calls (Destroy / inner
  ZoneChange proposals), not downstream of move_object — unaffected today;
  redundant-but-idempotent when C7 migrates it.
- deliver_batch's explicit park is removed (now redundant); the delivery-tail
  NeedsChoice path is deliberately NOT parked here — its wait state is already
  set by the counter-pause/devour machinery.
The false 'parks via the pipeline' comments are now true and updated to credit
the centralized park; two stale CR 608.2b citations corrected to CR 701.6a.

Discriminating test countered_spell_under_two_redirects_surfaces_prompt_and_exiles
(fail-first verified): counters a spell under RIP + Leyline, asserts the
CR 616.1 ordering prompt SURFACES, answers it via a real
GameAction::ChooseReplacement dispatch, and asserts the spell ends in EXILE with
no ghost (zone == Exile, exile container holds it, graveyard empty,
pending_replacement clear). Pre-fix run: FAIL with 'waiting_for = Priority {
player: PlayerId(0) }, spell zone = Stack' — the exact unparked-pause ghost.

CR 616.1 (line 3169), CR 614.6 (line 3072), CR 701.6a (line 3301).

* fix(engine): batch-continuation comment accuracy + rad-counter park bail + discard delivery re-bucket (round-3 review Fix 3)

(a) deliver_batch's aura-arm comment claimed a stale tail 'surfaces a bug'
(undrained). It does not: the engine_replacement drain gate keys on
pending_batch_deliveries.is_some(), not provenance, so a stale tail would be
silently drained by the NEXT unrelated replacement-choice resume — the same
mischaracterization the P2 review fix corrected in mill.rs. State the real
behavior.

(b) The mark_simultaneous_departures no-op-for-mill reasoning was wrong:
departed_subset (zones.rs:609) DOES include milled cards — it filters on
current zone != Battlefield, and a card now in a graveyard passes. The actual
no-op comes from the EVENT gate: mark_simultaneous_departures (zones.rs:580)
only stamps ZoneChanged events with from: Some(Zone::Battlefield), and a
library-origin move emits none. Both comments corrected.

(c) rad_counters now bails on a parked mill (apply_mill_after_replacement ->
false) like mill::resolve does: continuing into the CR 728.1 life-loss loop
would propose LifeLoss replacement events while the parked CR 616.1 choice is
pending and could overwrite pending_replacement, ghosting the paused milled
card. The life-loss/rad-removal tail is dropped on that parked path (reachable
only with two simultaneous graveyard redirects active while rad counters
trigger) — accepted and documented rather than threaded through the batch
resume.

(d) discard.rs complete_discard_to_graveyard re-bucketed with a comment: it is
a Bucket A post-replacement DELIVERY site (eventual Phase E shape:
approve_post_replacement + deliver on the lowered ZoneChange carrying applied),
not a move_object site — re-proposing would double-apply Discard-level
definitions. Deferred from this round because deliver's tail drains
post_replacement_continuation at delivery time with Moved context, and timing
equivalence for the Discard-execute chain class (madness / Abundance) needs its
own discriminating tests. Audit correction recorded at the site: the
discard_applier Discard->ZoneChange lowering runs only when a Discard-level
definition (madness class) applies — moved_matcher accepts only ZoneChange
proposals — so a PLAIN discard never consults Moved redirects and Rest in Peace
does not exile it today. That graveyard-redirect gap (same class as mill C1 /
counter C3 / bounce C4) needs the inner-ZoneChange-with-applied lowering plus a
discriminating RIP-on-discard test as its own commit.

CR 616.1 (line 3169), CR 728.1 (line 6239), CR 603.10a (line 2634), CR 701.9a (line 3334 area — see docs), CR 303.4f (line 1652).

* fix(engine): plain discard consults Moved redirects (CR 614.6 / 701.9a)

A plain discard moved hand -> graveyard via raw `move_to_zone`, never
consulting `Moved` replacements, so Rest in Peace / Leyline of the Void
did not exile a discarded card. Lower the accepted Discard into an inner
hand -> graveyard `ZoneChange` carrying the outer pass's `applied` set and
run it through the replacement pipeline, mirroring `discard_applier`'s
lowering. The `applied` set guards against re-running Discard-level
(madness) definitions; the lowered ZoneChange already re-loops through the
pipeline (CR 616.1f) for the madness path, so only the unmodified
Execute(Discard) arm needed the fix.

- complete_discard_to_graveyard now takes (source_id, applied), re-proposes
  through replace_event, and returns DiscardOutcome so a Moved-redirect
  CR 616.1 choice can propagate.
- Mayhem marker (CR 702.187b, record_card_discarded) stamped only when the
  card actually reaches the graveyard (CR 701.9c) — redirects leave it
  elsewhere, matching the Madness -> exile path.
- All four callers (resolve specific/non-specific, discard_as_cost,
  handle_replacement_choice resume, ward-discard) updated to surface the
  pause.

CR grep (docs/MagicCompRules.txt):
  701.9a To discard a card, move it from its owner's hand to that player's graveyard.
  701.9c If a card is discarded, but an effect causes it to be put into a hidden zone instead ...
  614.6. If an event is replaced, it never happens. A modified event occurs instead ...

Discriminating tests (effects/discard.rs):
  plain_discard_consults_rest_in_peace_and_exiles — RIP on board, plain
    discard ends in exile not graveyard (failed on the old raw-move path).
  madness_class_discard_still_works_without_double_consult — madness
    redirect to exile fires exactly once (no double-consult).

* fix(engine): seek bail-on-pause + route non-battlefield move through pipeline (CR 616.1 / 614.6)

Seek ignored `execute_zone_move`'s result and always pushed
`EffectResolved`, so a per-card replacement pause was clobbered, and a
multi-card seek with consecutive pausing replacements overwrote
`pending_replacement` — ghosting earlier picks. It also moved
non-battlefield destinations via raw `zones::move_to_zone`, never proposing
a per-card ZoneChange (C9 entry), so `Moved` redirects never fired.

Replace the per-card loop with the shared batch entry
`zone_pipeline::move_objects_simultaneously` (mill::resolve pattern): both
destinations now route through the pipeline, attribution stays
`ability.source_id` (so battlefield entries record
`entered_via_ability_source` and exile-link tracking keys off the seek
source), and a mid-batch CR 616.1 pause parks `state.waiting_for` + stashes
the tail in `pending_batch_deliveries`. Bail before `EffectResolved` on
NeedsChoice so the prompt is not clobbered.

CR grep (docs/MagicCompRules.txt):
  614.6. If an event is replaced, it never happens. A modified event occurs instead ...
  (616.1 ordering among applicable replacements — pipeline_loop authority)

Discriminating test (effects/seek.rs):
  seek_parks_on_per_card_replacement_choice_and_stashes_tail — two
    simultaneous graveyard->exile redirects make the first seeked-to-graveyard
    card prompt for CR 616.1 ordering; the batch parks, stashes the tail, and
    EffectResolved is NOT emitted (old loop ran to completion over the parked
    prompt).

* fix(engine): reveal_until battlefield entry routes through zone pipeline (CR 614.1c / 306.5b)

The kept-card battlefield entry used a raw `zones::move_to_zone`, skipping
the CR 614.1c delivery tail — so a revealed planeswalker/battle entered
with 0 loyalty/defense counters and was immediately put into the graveyard
by CR 704.5i. Route the entry through `zone_pipeline::move_object`: the
delivery tail seeds intrinsic enters-with counters, enters-with-counters
statics, and applies the CR 614.1 tap-state from the seeded EntryMods. The
previous manual `obj.tapped = true` is dropped (the tail does it — double-
application check). The `enters_attacking` combat placement (CR 508.4)
stays after delivery (not part of the zone tail). Bail on NeedsChoice /
NeedsAuraAttachmentChoice (centralized park in move_object).

The `move_to_library_position` rest-pile site (shuffle_to_bottom) is a
library-placement SIBLING raw mover, DEFERRED to Phase D with a comment:
move_object's placement arm is still a Phase-A stub that skips the
replacement consult, and a library→bottom reposition has no Moved-redirect
class to consult, so routing through it gains nothing now.

CR grep (docs/MagicCompRules.txt):
  306.5b A planeswalker has the intrinsic ability "This permanent enters with a ..."
  310.4b A battle has the intrinsic ability "This permanent enters with a number ..."
  614.1c Effects that read "[This permanent] enters with . . . ," ...
  704.5i If a planeswalker has loyalty 0, it's put into its owner's graveyard.
  508.4. If a creature is put onto the battlefield attacking, its controller ch...
  303.4f If an Aura is entering the battlefield under a player's control by any...
  616.1. If two or more replacement and/or prevention effects are attempting to...

Discriminating test (effects/reveal_until.rs):
  reveal_until_planeswalker_enters_with_intrinsic_loyalty — a loyalty-4
    planeswalker revealed to the battlefield enters with 4 loyalty counters
    (old raw path: 0 counters, dead by CR 704.5i).

* fix(engine): resolution-choice battlefield entries route through zone pipeline (CR 614.1c)

The RevealUntilKeptChoice-accept and DigChoice-kept handlers moved
battlefield-entry cards via raw `zones::move_to_zone` + a manual
`obj.tapped`, skipping the CR 614.1c delivery tail — so a kept/dug
planeswalker or battle entered with 0 loyalty/defense and died to
CR 704.5i. Route the battlefield branches through
`zone_pipeline::move_object` (consistent with the synchronous
reveal_until path migrated in C5): the tail seeds intrinsic enters-with
counters and applies the CR 614.1 tap-state from the seeded EntryMods, so
the manual tap is dropped. Bail on NeedsChoice / NeedsAuraAttachmentChoice
(centralized park; realistically unreachable for the dig classes).
`enters_attacking` (CR 508.4) combat placement stays post-delivery.
DigChoice now binds `source_id` for CR 400.7 attribution (falls back to the
moved object when None, matching the pre-pipeline raw move).

Non-battlefield resolution-choice moves (manifest/surveil/dig rest to
graveyard, route_rest_partition) are left raw this round — they sit inside
multi-card loops with post-loop cleanup (revealed-marker clearing,
continuation drain) where a per-card Moved-redirect pause cannot simply
`return` without stranding the rest; migrating them needs the batch-entry +
continuation restructure and is deferred. Library-placement sibling sites
(move_to_library_position / move_to_library_at_index) stay deferred to
Phase D.

CR grep (docs/MagicCompRules.txt):
  306.5b A planeswalker has the intrinsic ability "This permanent enters with a ..."
  310.4b A battle has the intrinsic ability "This permanent enters with a number ..."
  614.1c Effects that read "[This permanent] enters with . . . ," ...
  704.5i If a planeswalker has loyalty 0, it's put into its owner's graveyard.
  508.4. If a creature is put onto the battlefield attacking, its controller ch...
  400.7. An object that moves from one zone to another becomes a new object wit...
  616.1. If two or more replacement and/or prevention effects are attempting to...
  303.4f If an Aura is entering the battlefield under a player's control by any...

Discriminating test (effects/reveal_until.rs):
  reveal_until_kept_choice_planeswalker_enters_with_loyalty — drives the
    RevealUntilKeptChoice accept handler; a loyalty-5 planeswalker enters with
    5 loyalty counters (old raw handler: 0, dead by CR 704.5i).

* fix(engine): non-destroy SBA deaths consult Moved redirects (CR 704.5 / 614.6)

The non-destroy state-based-action graveyard moves (zero toughness, zero
loyalty, zero defense, legend-rule loser, unattached aura, battle without a
protector, final-chapter Saga sacrifice) used a bare `zones::move_to_zone`,
skipping the CR 614.6 replacement consult. These are "leaves the
battlefield" / "dies" events (CR 603.6c + CR 700.4), so a `Moved`
graveyard->exile redirect (Rest in Peace / Leyline of the Void) must apply
— it did not.

Add a shared `move_to_graveyard_via_pipeline` helper that routes each
SBA-departing permanent through `zone_pipeline::move_object` with
`ZoneChangeCause::StateBasedAction`. It returns `true` (and the caller
bails) on a CR 616.1 ordering pause, mirroring the established
`check_lethal_damage` regeneration-pause arm; the CR 704.3 fixpoint re-runs
after the choice resolves and re-derives any undelivered SBA deaths, so
bailing strands nothing. The self-parks at the destroy loop remain
redundant-but-idempotent with the centralized park in move_object.

CR grep (docs/MagicCompRules.txt):
  704.5f If a creature has toughness 0 or less, it's put into its owner's graveyard.
  704.5i If a planeswalker has loyalty 0, it's put into its owner's graveyard.
  704.5j If two or more legendary permanents with the same name are controlled ...
  704.5m If an Aura is attached to an illegal object or player, or is not attac...
  704.5s If the number of lore counters on a Saga permanent ...
  704.5v If a battle has defense 0 ...
  704.5w If a battle has no player in the game designated as its protector ...
  603.6c Leaves-the-battlefield abilities trigger when a permanent moves from t...
  700.4. The term dies means "is put into a graveyard from the battlefield."
  614.6. If an event is replaced, it never happens. A modified event occurs instead ...
  616.1. If two or more replacement and/or prevention effects are attempting to...

Discriminating test (sba.rs):
  sba_zero_toughness_death_consults_rest_in_peace_and_exiles — a
    zero-toughness creature with RIP on the battlefield is exiled, not put into
    the graveyard (old bare-move path: graveyard).

* docs(engine): document C8/C9 zone-pipeline deferral — no Moved class targets Hand/Exile/Stack (PLAN Risk #5/#8)

C8 (casting_costs/engine.rs cost sites) and C9 (Hand/Exile 1-site tail) are
analyzed and deferred: the parser and synthesis only ever emit `Moved`
redirects with `destination_zone` Graveyard (Rest in Peace / Leyline class)
or Battlefield (ETB modifiers) — verified by grep of
oracle_replacement.rs `destination_zone(Zone::...)`. There is NO `Moved`
redirect class targeting a Hand, Exile, or Stack destination, so routing
those moves through `move_object` would consult nothing and only add an
unresumed-pause path to cost-payment / draw / dig flows that do not model a
mid-flow replacement-choice resume. Draw-level replacements already apply at
`ReplacementEvent::Draw` upstream.

Records the finding inline at draw.rs:209 (the PLAN Risk #5 audit anchor),
covering the whole Hand/Exile C9 tail (gift_delivery, connive, explore,
turns return-to-hand; haunt, discover, exile_top, cascade, collect_evidence,
ripple). seek.rs:97 and mill.rs:108 were migrated in earlier rounds (D2 / C1);
discard.rs:38 stays Bucket A (Phase E).

CR grep (docs/MagicCompRules.txt):
  614.6. If an event is replaced, it never happens. A modified event occurs instead ...

No behavioral change; comment-only. No discriminating test (the deferral is
the absence of a behavioral change to test).

* fix(engine): scope self-ETB Moved replacements to battlefield entry (CR 614.1c)

Self-scoped as-enters replacements ("~ enters with N counters", enters
tapped/prepared, as-enters choices, enter-as-copy, Karoo/shock/reveal lands;
plus the Fading/Vanishing, Modular, Sunburst, Graft, Bloodthirst, Devour,
Amplify keyword synthesizers) parsed/synthesized as
ReplacementEvent::Moved with valid_card(SelfRef) and NO destination_zone.
moved_matcher skips the destination gate when destination_zone is None, and
a battlefield permanent is in-scan for its OWN departure — so the def
matched the permanent's own battlefield EXIT: phantom counters +
CounterAdded events on corpses (SBA deaths, bounce, destroy/sacrifice), a
spurious CR 616.1 ordering prompt under a single Rest in Peace, and
Optional clone defs forcing an "enter as a copy?" prompt on death.

Fix is data-shape, not matcher special-casing: stamp
.destination_zone(Zone::Battlefield) at construction on every self-ETB
Moved def — CR 614.1c defs ("[This permanent] enters with...") are
definitionally battlefield-entry-scoped. Parser sites: enters-tapped
(unconditional/unless/if-controls), Karoo pay-cost, enters-prepared,
reveal-land, shock land, as-enters-choose, clone, counter-choice, and the
enters-with-counters builder (previously stamped only the is_external
ChangeZone branch). Synthesis sites: Fading/Vanishing, Modular, Sunburst,
Graft, Bloodthirst, Devour, Amplify (Riot/Unleash/Siege/Tribute/Saga were
already stamped). The unearth / "would leave the battlefield" departure
watchers stay destination-agnostic by design.

CR grep (docs/MagicCompRules.txt):
  614.1c Effects that read "[This permanent] enters with . . . ," "As [this per...
  614.6. If an event is replaced, it never happens. A modified event occurs ins...
  616.1. If two or more replacement and/or prevention effects are attempting to...

Discriminating tests (fail-first evidence captured pre-fix):
  sba.rs::sba_death_does_not_apply_own_enters_with_counter_replacement
    — FAILED pre-fix: phantom counter on corpse (left: 1, right: 0)
  sba.rs::sba_death_under_single_rip_exiles_directly_no_prompt_no_counters
    — FAILED pre-fix: spurious CR 616.1 ordering prompt
  bounce.rs::bounce_does_not_apply_own_enters_with_counter_replacement
    — FAILED pre-fix: phantom counter on bounced card (left: 1, right: 0)
All three drive parse_replacement_line (real parser output), then the SBA /
bounce pipeline.

* docs(engine): correct C8/C9 deferral rationale at draw.rs (destination-None Moved class)

The recorded rationale ("no Moved class targets Hand/Exile") was incomplete:
self-ETB Moved constructors carried NO destination_zone, and None matches
every destination — so pre-Fix-1 such defs DID match Hand/Exile deliveries.
The deferral conclusion stands (and was reinforced: migrating C8/C9 before
the destination stamps landed would have widened the phantom-application
defect). Post-stamp, explicit destinations are Graveyard/Battlefield only
and the remaining destination-None defs are deliberate battlefield-departure
watchers (unearth class), so the original claim is now true. Comment-only.

* fix(engine): prevented discard records nothing; document paused-path bookkeeping gap (CR 614.6 / 701.9a)

Prevented arm: a prevented inner ZoneChange means the card never left the
hand — per CR 701.9a (to discard = move hand -> graveyard) NO discard
occurred, so skip record_discard / the CR 702.187b Mayhem stamp / the
Discarded event. Previously the arm fell through and recorded+emitted a
discard that never happened, incoherent with the NeedsChoice early-return.
Distinct from a REDIRECTED discard (CR 701.9c: still discarded — the
Execute and madness arms keep recording+emitting).

NeedsChoice arm: documented gap (counter.rs resolve_all style) instead of a
resume-side continuation — a CR 616.1 ordering pause on the inner move (TWO
materially-different Moved redirects on one discard, e.g. RIP + Wheel of
Sun and Moon) delivers the card via the generic ZoneChange resume with no
discard context, skipping "whenever you discard" bookkeeping for that card
and abandoning a multi-card remainder. A proper fix needs a new serialized
state slot + resume wiring; not built for a board no parsed deck assembles.
Single-redirect boards (RIP alone) never prompt and are fully correct.

CR grep (docs/MagicCompRules.txt):
  614.6. If an event is replaced, it never happens. A modified event occurs ins...
  701.9a To discard a card, move it from its owner's hand to that player's grav...
  701.9c If a card is discarded, but an effect causes it to be put into a hidde...

* fix(engine): batch-tail re-stash preserves request mods + attribution (CR 400.7 / 614.1c)

PendingBatchDeliveries rebuilt paused-batch tails as
ZoneMoveRequest::effect(obj, dest, obj), dropping the seek flow's
enter_tapped mod and ability-source attribution across the pause boundary
(seek is the first batch caller passing non-default EntryMods + a shared
source). Extend the stash with serde-default fields (source_id,
enter_tapped, exile_tracking) carrying the batch-uniform request context:
captured from the first tail request in the new stash_batch_tail helper
(source equal to the request's own object_id is the mill self-anchor idiom
and stashes None), re-applied per request in drain_pending_batch_deliveries.
Batch-uniform rather than per-request, mirroring the single-destination
batch design; per-card heterogeneity remains a flagged design extension.

Test: seek_parks_on_per_card_replacement_choice_and_stashes_tail extended to
assert the stashed tail preserves the seek's ability-source attribution.

* feat(engine): scan stack-resident object's own Moved redirect on stack exit (CR 608.2n / 614.12)

find_applicable_replacements scanned only [Battlefield, Command] plus the
entering-object (to:Battlefield) and discard exceptions, so a spell's own
self-scoped `Moved` replacement was never discovered for its stack ->
graveyard move. Add a stack-self-move exception mirroring the entering-
object exception: when the proposed event is a ZoneChange whose `from` is
Stack, include the moving object itself as a candidate source so its own
SelfRef-scoped Moved def can fire as it leaves the stack.

Hot-path cost: a single per-event Option match on the event's `object_id`
when `from == Stack` (no extra zone sweep); the loop iterates the same
`active_replacements` set as before, this only lets that one object pass
the zone gate, and the `is_stack_self_move && !in_scanned_zone` SelfRef
guard keeps it scoped to that object's own definitions.

Inert until a stack -> graveyard self-redirect def is installed (next
commit wires the Invoke Calamity rider through it): no parsed self-scoped
Moved def today carries destination_zone: Graveyard, and the existing
enters-with-counters self-defs are scoped to destination_zone: Battlefield.

CR grep (docs/MagicCompRules.txt):
  608.2n As the final part of an instant or sorcery spell's resolution, the spell is put into its owner's graveyard.
  614.12. Some replacement effects modify how a permanent enters the battlefield.

* fix(engine): route stack resolution defaults through pipeline; replace exile-rider flag with synthetic Moved def (CR 608.2n / 614.6)

The stack resolution-default moves (resolved instant/sorcery → graveyard,
fizzled/countered-on-resolution spell, prevented permanent → graveyard)
delivered via raw `move_to_zone`, never proposing the inner ZoneChange, so
board-wide `Moved` graveyard→exile redirects (Rest in Peace / Leyline of
the Void) silently dropped on resolved/countered/prevented spells (PLAN §8
Risk #2 — confirmed bug). Route all three through `zone_pipeline::move_object`
(new `ZoneMoveRequest::spell_resolution_default`, Cause::SpellResolutionDefault).

The Invoke Calamity free-cast "if this spell would be put into your
graveyard, exile it instead" rider was a bespoke per-object boolean
(`exile_from_stack_instead_of_graveyard`) read by hand at the two dest
computations. Replace it with a synthetic self-scoped `Moved`
replacement (valid_card: SelfRef, destination_zone: Graveyard, execute:
ChangeZone → Exile) installed at the same attach point — the same class as
RIP/Leyline, just scoped to one spell — so the pipeline applies it like
any other Moved redirect. Delete the flag, its game_object field, and the
`object_exiles_instead_of_graveyard` reader. The flag may appear in saved
games; GameObject has no deny_unknown_fields, so serde ignores the legacy
field on deserialize (an old mid-cast save lacks the synthetic def — the
def travels with the object via replacement_definitions; acceptable).

Flashback/aftermath/harmonize exile-on-stack-exit stays a STATIC
destination rule selected pre-pipeline (dest = Exile), so its proposed
move is Stack→Exile; the Graveyard-scoped redirect class never matches it
— no double-apply. CR 616.1 ordering pauses (two simultaneous redirects)
are parked by move_object; each site emits the standard StackResolved +
trigger-context-clear epilogue and bails for the replacement-choice resume.

CR grep (docs/MagicCompRules.txt):
  608.2n As the final part of an instant or sorcery spell's resolution, the spell is put into its owner's graveyard.
  608.3e If a permanent spell resolves but its controller can't put it onto the battlefield, that player puts it into its owner's graveyard.
  614.1a Effects that use the word "instead" are replacement effects.
  614.6. If an event is replaced, it never happens. A modified event occurs instead.

Discriminating tests:
  stack.rs::rest_in_peace_exiles_resolved_instant — RIP redirects a
    resolved instant's graveyard move to exile (fails on old raw delivery).
  stack.rs::flashback_spell_exiles_once_with_rest_in_peace_present —
    flashback exiles exactly once via its static rule; RIP (graveyard-scoped)
    does not double-apply on the stack→exile move.
  casting_costs.rs::invoke_calamity_rider_exiles_free_cast_spell_on_resolution —
    the rider's synthetic Moved def redirects a free-cast spell to exile on
    resolution through the deleted-flag's replacement path.

* fix(engine): paused fizzle runs the resolution epilogue before bailing (CR 608.2b / 616.1)

The fizzle/countered-on-resolution arm bailed on a replacement-ordering
pause with a bare `return`, skipping the epilogue directly below it
(StackResolved emission + current_trigger_event / current_trigger_events /
current_trigger_match_count / die_result_this_resolution clears) — leaking
stale cross-resolution context across the pause and never emitting
StackResolved. The other two C2 sites (instant→graveyard else-branch,
prevented-permanent arm) inline that epilogue before their pause-returns;
the fizzle arm now falls through to its shared epilogue instead (the
pause needs nothing else from the fizzle path — `move_object` parked the
prompt and the move, and the resume path delivers it).

Reachable: an Invoke Calamity free-cast spell fizzling under a single
Rest in Peace = rider + RIP = two simultaneous graveyard→exile redirect
candidates = CR 616.1 ordering prompt = this arm.

Also documents the rider's known scope gap at the install site: the
synthetic def carries no "this turn" duration (ReplacementDefinition has
no duration field; revert_layered_characteristics_to_base only runs for
battlefield exits), behavior-preserving vs the deleted flag — a Duration
field on ReplacementDefinition is the eventual fix.

CR grep (docs/MagicCompRules.txt):
  608.2b If the spell or ability specifies targets, it checks whether the targets are still legal ...
  616.1. If two or more replacement and/or prevention effects are attempting to modify the way an event affects an object or player ...

Discriminating test (fail-first verified):
  casting_costs.rs::invoke_calamity_rider_fizzle_under_rip_parks_choice_with_clean_epilogue —
    free-cast spell with the rider fizzles under one RIP → CR 616.1 prompt
    parks → StackResolved emitted + all four context fields cleared on the
    paused path → GameAction::ChooseReplacement resumes → spell exiled.
    Pre-fix failure: panicked at "paused fizzle must still emit
    StackResolved" (prompt assertion passed, proving reachability).

* fix(engine): route surveil + manifest-dread rest piles through zone pipeline (Phase C6)

The non-battlefield rest-pile loops in engine_resolution_choices.rs delivered
every unkept card with a bare zones::move_to_zone(.., Zone::Graveyard, ..),
proposing no per-card ZoneChange — so each card's own Moved redirects (Rest in
Peace / Leyline of the Void: 'would be put into a graveyard from anywhere ->
exile instead') silently never fired (PLAN Risk #1 class). The post-loop
cleanup (surveil kept-on-top reorder; manifest-dread reveal-marker removal) ran
inline at the end of the loop, which is why C6 was deferred twice: a per-card
CR 616.1 pause mid-pile would run that cleanup before the paused tail finished,
then never again.

Route both piles through a new zone_pipeline::move_objects_simultaneously_then,
which carries a typed BatchCompletion (NOT a closure) describing the post-loop
work. The batch runs the cleanup exactly once on true completion: inline on the
synchronous (single-redirect) path, and via drain_pending_batch_deliveries when
a CR 616.1 ordering choice pauses the pile. BatchCompletion rides on the parked
PendingBatchDeliveries tail (mirroring PendingCounterPostAction), and the drain
re-attaches it across each re-park so it can never run early or twice. The
paused-on-last-card empty-tail case is handled by ensure_batch_record so the
completion still fires after the final card's redirect resolves.

CR grep (docs/MagicCompRules.txt):
  701.25a To 'surveil N' means to look at the top N cards of your library, then put any number of them into your graveyard and the rest on top of your library in any order.
  614.6. If an event is replaced, it never happens. A modified event occurs instead ...
  616.1. If two or more replacement and/or prevention effects are attempting to modify ...
  603.10a Some zone-change triggers look back in time. These are leaves-the-battlefield abilities ...

Discriminating tests (surveil_rest_pile_redirect_continuation.rs):
  surveil_rest_pile_honors_graveyard_exile_redirect — single redirect: unkept
    cards land in EXILE (not graveyard), kept card stays on top once. FAILS on
    the old raw move (cards reached the graveyard).
  surveil_rest_pile_under_two_redirects_runs_keep_on_top_cleanup_once — two
    simultaneous redirects pause every unkept card on a CR 616.1 prompt;
    answering each delivers ALL to exile (none stranded), drains the parked
    tail, and runs the kept-on-top reorder EXACTLY once (kept card on top,
    present once — not duplicated, not missing).

* refactor(engine): route exempt-cause moves through zone pipeline (Phase D)

Migrate the four CR-exempt zone-change classes off raw zones::move_to_zone onto
the single pipeline entry under explicit exempt ZoneChangeCause variants, so
Phase E can flip enforcement knowing every production caller goes through the
pipeline. The exempt path seals the proposed event directly and delivers it
WITHOUT the replace_event consult (the 'would'-semantics layer); the
unconditional primitive guards (CR 111.8 token, CR 614.1d ETB block, CR 400.7
cleanup) still run in zones.rs delivery for every cause (PLAN section 2/3).

Sites:
- mulligan.rs (CR 103.5 PregameProcedure): Serum Powder hand->exile, mulligan
  hand->library shuffle, pregame draw, and the two opening-hand/mulligan
  bottoming loops (via the library-placement arm).
- elimination.rs (CR 800.4a PlayerLeftGame): owner's objects -> exile. 'This is
  not a state-based action' and no replacement applies to a player leaving.
- casting_costs.rs::finalize_cast (CR 601.2a CastingToStack): the committed
  Hand/GY/Exile/Command -> Stack transition. Part of the casting process, not a
  replaceable event.
- engine_debug.rs (DebugCommand): MoveToZone (incl. library top/bottom/Nth via
  placement), Mill, and the no-ETB battlefield staging path. Operator intent is
  'force the state'; no intrinsic enters-with-counter seeding, matching the
  prior raw placement.

ZoneChangeCause::is_exempt() is the single authority for the consult skip; each
exempt arm carries its CR citation. New ZoneMoveRequest constructors
(casting_to_stack / pregame / player_left_game / debug) keep call sites short.
Removed now-unused 'use super::zones' in mulligan.rs.

CR grep (docs/MagicCompRules.txt):
  103.5. Each player draws a number of cards equal to their starting hand size ... A player who is dissatisfied with their initial hand may take a mulligan ... shuffles the cards in their hand back into their library ... puts a number of those cards ... on the bottom of their library
  601.2a To propose the casting of a spell, a player first moves that card (or that copy of a card) from where it is to the stack.
  800.4a When a player leaves the game, all objects (see rule 109) owned by that player leave the game ... This is not a state-based action.

No behavior change: exempt causes already bypassed the consult via raw moves;
this routes them through the pipeline with identical delivery. Existing mulligan
/ elimination / debug / casting tests stay green (test-engine ok).

* docs(engine): document W3 library-placement consult deferral with evidence (Phase D)

The placement-aware pipeline arm exists as a stub (move_object delivers a
Some(placement) request directly via move_to_library_at_index, skipping the
replacement consult). Phase D's exempt pregame/debug library callers already
route through it. Completing the stub so it RUNS the consult on a library
placement (PLAN section 3.5 'the consult should still run for future-proofing')
is DEFERRED with evidence:

  - Zero value today: no Moved replacement in the card pool targets
    destination_zone(Library). Verified destination distribution:
      25 destination_zone(Zone::Battlefield)
      17 destination_zone(Zone::Graveyard)
       2 destination_zone(Zone::Exile)
       0 Library
    so the consult is a guaranteed no-op on every library placement.
  - Real risk for that no value: a correct completion must gate the CR 701.24a
    delivery-tail auto-shuffle on placement-absence across the shared deliver /
    deliver_replaced_zone_change / DeliveryCtx signatures (a library *placement*
    must NOT shuffle; a plain library-destination ZoneChange MUST). That is a
    cross-cutting change to every bucket-A/B/C delivery caller with a
    silent-randomization landmine (a wrong gate randomizes put-on-top / scry /
    cascade placements with no crash or test failure).

The raw move_to_library_position / _at_index sibling production callers
(put_on_top, cascade, discover, reveal_until, drawn_this_turn_choice,
engine_resolution_choices) stay on the raw movers until that completion: a
library reposition is not 'put into a graveyard/exile/hand', so nothing is
skipped by staying raw. Migrating them onto the stub arm ahead of the consult
completion is value-neutral churn that does not advance Phase E (which can only
demote the library movers once ALL callers AND the consult are in place) and
carries per-site top-order-reversal preservat…
matthewevans added a commit that referenced this pull request Jun 10, 2026
…e-safe battlefield entries (#2829)

* fix(engine): route declined-cipher, declined-madness, and legend-rule losers to graveyard through zone pipeline (CR 608.2n / 702.35a / 704.5j / 614.6)

These three graveyard-destination moves delivered via raw move_to_zone,
never proposing the inner ZoneChange, so board-wide Moved graveyard->exile
redirects (Rest in Peace / Leyline of the Void) silently dropped:

- cipher decline (CR 608.2n): the resolving cipher card -> owner's graveyard
  now routes through SpellResolutionDefault; handle_encode_choice returns the
  ZoneMoveResult so the caller surfaces a parked CR 616.1 prompt instead of
  clobbering it with Priority.
- madness decline (CR 702.35a): the exiled card -> owner's graveyard now
  routes through move_object; the arm evaluates to the parked WaitingFor on
  pause so the post-action pipeline is skipped.
- legend rule (CR 704.5j): the losing legends -> graveyards now route through
  move_objects_simultaneously (CR 603.10a co-departure stamp); a mid-batch
  CR 616.1 choice parks the prompt and stashes the tail.

CR grep (docs/MagicCompRules.txt): 608.2n spell goes to graveyard on
resolution; 702.35a madness; 704.5j legend rule; 614.6 replaced event never
happens; 603.10a simultaneous leaves-battlefield.

* fix(engine): route Attraction-open and ninjutsu battlefield entries through zone pipeline (CR 614.1c)

Both battlefield entries delivered via raw move_to_zone, skipping the
pipeline delivery tail that applies enters-with-counters statics
(StaticMode::EntersWithAdditionalCounters — Hardened Scales / Conclave
Mentor 'creatures you control enter with an additional +1/+1 counter'
class). The raw mover applies those statics nowhere, so an opened
Attraction or a ninja entering via ninjutsu silently missed them.

Route both through zone_pipeline::move_object. A battlefield-entry pause
(CR 616.1 multi-redirect ordering / CR 303.4f aura host / counter-
replacement) is not reachable for these object classes in the supported
pool: an Attraction is a non-Aura artifact, a ninja is a non-Aura creature,
and no supported entry surfaces a choice. The bail is a safety guard, not a
resume path — ninjutsu's post-entry combat placement (CR 702.49c) cannot
resume across a pause, so on the unreachable pause we stop with the prompt
parked rather than act over parked state. Attribution self-anchors (CR 400.7;
the raw move recorded no source).

CR grep (docs/MagicCompRules.txt): 614.1c enters-with statics; 702.49c
ninjutsu combat placement; 616.1 multi-replacement ordering.

* docs(engine): correct stale aura-arm drain note + document dig multi-kept pause limitation

Two comment-only corrections from the d5a12b8c6 review verdict:

1. zone_pipeline.rs deliver_batch aura arm: the note claiming a tail stashed
   on NeedsAuraAttachmentChoice would be 'silently drained by the NEXT
   unrelated replacement-choice resume' is stale. As of d5a12b8c6 the
   ReturnAsAuraTarget handler (engine.rs:3608-3611) and its chain-resume
   sibling (engine.rs:3572) both drain pending_batch_deliveries, so the
   aura-attachment resume finishes the parked batch correctly.

2. engine_resolution_choices.rs DigChoice kept-loop pause: document the
   multi-kept limitation — if kept card #1 pauses, kept #2+ stay in the
   library (the for-loop return exits before they move), and
   publish_tracked_set: Some(kept) publishes ALL kept including the unmoved
   ones, so a downstream sub-ability can be wired to cards still in the
   library. Pre-existing, strictly no-worse-than the old raw path; revisit
   with a kept-loop continuation if a 2+-kept-to-battlefield dig that pauses
   on the first is ever added.

No behavior change.

* fix(engine): route reveal-until and dig graveyard rest piles through zone pipeline so Moved redirects fire (CR 614.6 / 701.20a / 603.10a)

The reveal-until and dig 'put the rest into your graveyard' piles delivered
via raw move_to_zone, never proposing the inner ZoneChange, so a board-wide
Moved graveyard->exile redirect (Rest in Peace / Leyline of the Void)
silently dropped on the rest cards. 12 card-data cards carry RevealUntil with
rest_destination: Graveyard (Mind Funeral class), plus dig's no-kept-zone
'rest to graveyard' branch — all affected.

move_rest is now the single authority: a Graveyard (or any non-library) rest
pile routes through move_objects_simultaneously (CR 603.10a co-departure
stamp), so each rest card consults the redirect; a Library rest pile keeps
the random-order shuffle_to_bottom (no Moved-redirect class targets Library,
and the placement arm would lose the shuffle). The new move_rest_then carries
an optional BatchCompletion for callers that defer cleanup across a pause.

Synchronous completion runs the resolver's own marker-clear + EffectResolved
inline (the dispatching chain processor still owns priority/continuation). On
a mid-pile CR 616.1 ordering pause, the prompt is parked and the cleanup is
deferred onto a cleanup-only RevealRestPile completion (empty rest_cards — the
pile IS the batch) so the drain runs it once and EffectResolved never lands
over the parked prompt. The dig unkept->graveyard branch mirrors this, deferring
finish_with_continuation via the same completion.

Discriminating test reveal_until_graveyard_rest_redirected_to_exile_by_rest_in_peace:
a graveyard-rest reveal-until with a RIP graveyard->exile Moved redirect on the
battlefield now exiles the rest pile (old raw path: graveyard'd).

CR grep (docs/MagicCompRules.txt): 614.6 replaced event never happens; 701.20a
reveal until / rest pile; 603.10a simultaneous leaves-battlefield.

* fix(engine): route morph and manifest face-down entries through zone pipeline (CR 708.3 / 614.1c)

Both face-down battlefield entries (cast face-down via morph/disguise, and
manifest) delivered via raw move_to_zone followed by a manual
apply_face_down_creature_characteristics + back_face snapshot — bypassing the
pipeline delivery tail, so a face-down 2/2 never received enters-with-counters
statics ('creatures you control enter with an additional +1/+1 counter' —
Hardened Scales / Conclave Mentor class).

Route both through zone_pipeline::move_object with the new
ZoneMoveRequest::face_down(profile) mod. The delivery tail is already the
canonical face-down authority (it snapshots the real face into back_face and
applies the vanilla-2/2 profile BEFORE the entry per CR 708.3, identical to
change_zone's face-down path) AND seeds enters-with-counters statics, so the
manual post-move override is dropped — morph/manifest now match the rest of
the engine's face-down entries. A battlefield-entry pause is unreachable for a
vanilla 2/2 (not an Aura, no Moved redirect / counter-replacement choice); the
bail keeps the helpers safe by construction.

CR grep (docs/MagicCompRules.txt): 708.2a face-down characteristics; 708.3
turned face down before it enters; 614.1c enters-with statics; 701.40a
manifest.

* fix(engine): route exile-until-leaves returns through zone pipeline (CR 610.3a / 614.1c)

check_exile_returns (Banisher Priest / Fiend Hunter / Oblivion Ring class —
'exile until ~ leaves') returned each exiled card via raw move_to_zone,
skipping the pipeline delivery tail: a battlefield return missed enters-with-
counters statics (Hardened Scales class), and a non-battlefield return dropped
any Moved redirect.

Group the returns by destination zone (first-seen order for determinism — Zone
isn't Ord) and route each group through move_objects_simultaneously_then
(CR 603.10a co-departure). A returned creature can pause on an as-enters /
aura-host choice (CR 303.4f / 616.1), so the spent UntilSourceLeaves link
cleanup rides a new BatchCompletion::RemoveExileLinks per group, drained once
the group's pile lands (synchronously or via the replacement-choice /
aura-attachment resume) — never before a paused card finished returning. Links
for cards that already left exile by other means are dropped immediately; only
the in-flight group ids ride their completion.

CR grep (docs/MagicCompRules.txt): 610.3a return to previous zone; 614.1c
enters-with statics; 603.10a simultaneous; 303.4f aura host on entry.

* fix(engine): route reveal-until kept-to-graveyard cards through zone pipeline; document dig rest-partition gap (CR 614.6 / 701.20a)

A reveal-until KEPT card sent to the graveyard (4 cards, kept_destination:
Graveyard — Mind Funeral-style 'put it into your graveyard') was delivered
raw, skipping a Moved graveyard->exile redirect (Rest in Peace / Leyline of
the Void). Route it through move_object on both the synchronous resolve path
(the non-Hand/Battlefield kept branch; Library stays raw as a placement) and
the RevealUntilKeptChoice handler (accept_zone / decline_zone via the new
route_kept_card_or_defer helper). On a CR 616.1 pause the rest-pile move +
marker clear defer onto a RevealRestPile completion; the kept-choice handler's
rest pile now flows through move_rest_then so its completion (marker clear +
finish_with_continuation) runs once on either path — closing the latent
unhandled-pause the move_rest migration introduced here.

Documents the remaining route_rest_partition gap (dig 'rest into graveyard',
65 Dig cards + the null->Graveyard default): it has a caller INSIDE
run_batch_completion, so migrating it needs a re-pause-from-completion contract
plus pause handling in both synchronous callers — a cross-cutting change
tracked for a follow-up rather than a partial migration.

CR grep (docs/MagicCompRules.txt): 614.6 replaced event never happens; 701.20a
reveal until / rest pile.

* fix(engine): paused face-down entry resumes face down; prevented-ETB fallback consults Moved redirects (CR 708.3 / 608.3e / 614.6)

Round-1 review findings 1 (HIGH) + 3 (MEDIUM) on the zone-pipeline tail
migration — both in handle_replacement_choice:

1. The ZoneChange resume arm destructured the approved event with '..',
   DISCARDING face_down_profile, and delivered via the raw mover — a morph /
   manifest entry parked on a CR 616.1 ordering prompt resumed FACE UP,
   violating CR 708.3 and leaking the morpher's hidden card. The prompt is
   REACHABLE: two co-played external enter-tapped Moved effects (Authority of
   the Consuls + Imposing Sovereign class) collide on the entry's tap field
   (no same-value dedupe), surfacing the ordering choice — empirically proven
   by the new test's parked-prompt assertion. Fix: extract the tail's CR 708.3
   block into zone_pipeline::apply_face_down_entry_profile (single authority,
   not a mirrored copy) and call it from the resume arm immediately after the
   move, before the tap/controller/counter blocks (tail ordering). Full
   tail-routing via approve_post_replacement + deliver was assessed and
   deferred to the Phase-B token migration with a flagged TODO: the arm's
   epilogue drains post_replacement_continuation with spell-resolution ctx +
   post_replacement_source clearing, orders pending_spell_resolution
   differently, and carries the bespoke played_from_zone preservation (PLAN
   Open Question #3) — three behavioral divergences that need their own
   reconciliation, not a drive-by.
   Morph/manifest 'pause unreachable' comments replaced with honest
   reachability documentation (the bail is complete: the profile rides the
   parked event; the resume applies it).

2. The CR 608.3e prevented-ETB graveyard fallback delivered raw. The
   consulted (prevented) event was the battlefield ENTRY; the fallback is a
   fresh, never-consulted event, so routing it through move_object
   (SpellResolutionDefault, mirroring stack.rs's C2 prevented-permanent site)
   cannot double-apply — the prevention def is Battlefield-scoped and cannot
   re-match a Graveyard move. RIP/Leyline redirects now fire on the discarded
   spell. The dead pending_continuation is cleared before the move so a
   CR 616.1 pause cannot leave it for the next resume's epilogue.

Fail-first evidence (both red before the fix, green after):
- paused_face_down_morph_entry_resumes_face_down: panicked 'resumed morph
  entry must be FACE DOWN (CR 708.3)'
- prevented_etb_graveyard_fallback_consults_moved_redirects: left: Graveyard,
  right: Exile (staging note in-test: no ZoneChange applier can yield
  Prevented, so the parked choice is staged as a regeneration-shield Destroy
  prevention; the resume is driven through the real GameAction entry)

CR grep (docs/MagicCompRules.txt): 708.2a face-down characteristics; 708.3
turned face down before it enters; 608.3e prevented ETB to graveyard; 614.6
replaced event never happens; 616.1 multiple replacement ordering.

* fix(engine): ninjutsu and Attraction-open pauses resume via continuations instead of dropping post-entry work (CR 702.49 / 701.51 / 616.1)

Round-1 review finding 2 (MEDIUM): both 3d1497411 bails were wrong because the
battlefield-entry prompt IS reachable — two co-played external enter-tapped
Moved effects (Authority of the Consuls + Imposing Sovereign for creatures;
Kismet / Frozen Aether class for artifacts) produce a material same-field
collision on the entry's tap state (no same-value dedupe) and surface a
CR 616.1 ordering prompt. On that board:

- ninjutsu: the bail skipped the cast-variant provenance tag AND the
  CR 702.49c tapped-and-attacking combat placement — the resumed ninja entered
  untagged and non-attacking.
- Attraction open: the bail left in_attraction_deck set, never emitted
  AttractionOpened, and dropped every remaining open of the instruction.

Adjudication: continuations, not documented limitation — the BatchCompletion
infrastructure already exists (RevealRestPile / RemoveExileLinks shape). Two
new variants: NinjutsuPlacement (defers finish_ninjutsu_entry: tag + combat
placement + NinjutsuActivated + layers) and AttractionOpenRemainder (defers
finish_attraction_open + the remaining opens, which may themselves re-park and
re-defer through the same completion — the drain takes the old record before
running it, so a fresh park is preserved). Both finish helpers are shared
single authorities between the synchronous and resumed paths. The 'pause
unreachable' comments are replaced with honest reachability documentation.

Bonus CR-correctness in the shared helper: AttractionOpened now fires only
when the card actually entered the battlefield (CR 701.51c — prevented or
replaced entries must not trigger 'opens an Attraction'; Done also covers
prevented/redirected deliveries). Also corrects the CR 610.3a cite to CR 610.3
proper on the RemoveExileLinks doc + completion arm (610.3a is the
already-occurred-event timing subpart; 610.3 is the return rule).

Fail-first evidence (both red before, green after):
- paused_ninjutsu_entry_resumes_with_combat_placement_and_tag: panicked
  'resumed ninja must be placed attacking (CR 702.49c)'
- paused_attraction_open_resumes_bookkeeping_and_remaining_opens: panicked
  'open bookkeeping must run on the resumed Attraction (old bail left the
  flag set)'
Both tests passed their parked-prompt assertions pre-fix, empirically
confirming the reviewer's reachability claim for both object classes.

CR grep (docs/MagicCompRules.txt): 702.49/49a/49c ninjutsu + placement;
701.51/51b/51c open an Attraction + trigger gate; 616.1 ordering; 610.3
return-to-previous-zone.

* docs(engine): flag CR 614.12 face-down consult gap; correct exile-return cite to CR 610.3 (review finding 4 + nit)

Round-1 review finding 4 (MEDIUM-LOW, comment-only): execute_zone_move's
replacement consult runs the matcher pass against the object's PRINTED
characteristics, but CR 614.12 (docs/MagicCompRules.txt:3094) requires
checking 'the characteristics of the permanent as it would exist on the
battlefield' — for a face-down (morph/manifest) entry that is the 2/2 with no
name/types/subtypes (CR 708.2a), so a type- or name-keyed entry replacement
wrongly matches a face-down printed Wizard. Narrow class today (the common
enter-tapped/counter statics are type-agnostic or creature-scoped, which the
face-down 2/2 satisfies); the fix is profile-projected characteristics in the
matcher pass when face_down_profile is present. Documented at the consult with
the CR cite.

Also corrects check_exile_returns' CR 610.3a cite to CR 610.3 proper (610.3a
is the already-occurred-event timing subpart; 610.3 is the rule that creates
the return one-shot).

CR grep: 614.12 'check the characteristics of the permanent as it would exist
on the battlefield'; 610.3 'A second one-shot effect ... returns the object to
its previous zone.'

No behavior change.

* fix(engine): arrival-gate ninjutsu post-entry work, twin of the Attraction CR 701.51c gate (CR 702.49c)

Round-2 review LOW: finish_ninjutsu_entry ran the cast-variant tag and the
CR 702.49c combat placement unconditionally — ZoneMoveResult::Done also covers
prevented/redirected deliveries, so a redirected resumed entry would tag a
non-battlefield object and place it into combat.attackers. Gate both behind
the same zone == Battlefield arrival check as finish_attraction_open.
Unreachable today (no supported Moved redirect retargets a battlefield entry
away from the battlefield), but the gate makes the helper correct by
construction rather than by census, matching the twin's standard.

NinjutsuActivated stays deliberately OUTSIDE the gate, unlike the Attraction
twin's AttractionOpened: CR 701.51c explicitly suppresses the 'opens an
Attraction' trigger when the entry is prevented/replaced, but ninjutsu's
activation event occurred when the ability was activated (cost paid, attacker
returned) — a redirected entry does not un-activate it. Asymmetry documented
inline.

No new test per the review (unreachable class, identical behavior for every
reachable entry — existing paused_ninjutsu_entry_resumes_with_combat_placement
_and_tag still green).

CR grep (docs/MagicCompRules.txt): 702.49c enters attacking; 701.51c trigger
suppression on prevented/replaced entry.
ntindle added a commit to ntindle/phase that referenced this pull request Jun 27, 2026
…and-play frequency, text/lower desync, context restore

Maintainer @matthewevans raised four issues on PR phase-rs#4341 (The Fourth Doctor
parser cluster). All four are addressed here.

1. Reflexive trigger provenance (issue #1):
   Replace the rules-incorrect `TriggerMode::PlayCard` trigger with a
   `TriggerMode::Unknown` gap marker. A global PlayCard trigger cannot
   distinguish which permission authorized a given play (CR 603.12), so
   the "When you do" rider was firing even when a different top-of-library
   permission authorized the play. The Unknown trigger keeps the gap
   visible in coverage until the casting/land-play pipeline gains
   permission-provenance tracking.

2. Land-play frequency consumption (issue phase-rs#2):
   Add `record_top_of_library_land_permission` in engine.rs and call it
   from all three completion arms of `handle_play_land`. A
   `OncePerTurn` `TopOfLibraryCastPermission { play_mode: Play }` now
   consumes the per-turn slot on land plays, mirroring how
   `finalize_cast` records this for spell casts (CR 401.5 + CR 601.2a).

3. Context mutation without restore (issue phase-rs#3 / Gemini):
   The reflexive rider is no longer parsed at all (Unknown trigger is
   emitted unconditionally), eliminating the ctx.subject/ctx.actor
   mutation that was leaking into subsequent lines.

4. text/lower desync on frequency-prefix strip (issue phase-rs#4 / Gemini):
   In `try_parse_top_of_library_cast_permission`, shadow both `text` and
   `lower` together when stripping the "once each turn, " / "once during
   each of your turns, " prefix, so downstream helpers receive aligned
   slices (Gemini phase-rs#3 suggestion, CR 601.2a).

5. Redundant scan_contains pre-check removed (Gemini phase-rs#2):
   The `scan_contains` guard before `split_once_on_lower` in oracle.rs
   was redundant — `try_parse_top_of_library_cast_permission` already
   validates the anchor. Removed for clarity.

Test: `the_fourth_doctor_full_card_parse` updated to expect
`TriggerMode::Unknown` instead of `TriggerMode::PlayCard`.

Verification: cargo clippy -p engine --all-targets -- -D warnings: clean.
cargo test -p engine: all pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aQYsGCjiRn71Z4vQDo9QR
minion1227 added a commit to minion1227/phase that referenced this pull request Jun 30, 2026
…many" sees final count

Review follow-up phase-rs#2 (phase-rs#4657, [HIGH]): in `drain_pending_continuation` the parked
continuation was resolved BEFORE `drain_pending_change_zone_iteration`. For a
`ChangeZone -> create/draw that many` chain whose returns pause on a per-target
replacement choice, the chained `QuantityRef::EventContextAmount` consumer ran
first and read the stale pre-pause `last_effect_count` (the final count is only
stamped when the iteration completes), creating/drawing the wrong number.

Resume the paused ChangeZone iteration FIRST, then the continuation (guarded so
a re-pause on a further replacement choice stops it). This mirrors the non-pause
path, where `change_zone::resolve` stamps `last_effect_count` and emits the
ChangeZone `EffectResolved` before the chained sub-ability runs.

Regression: `change_zone_then_create_that_many_counts_across_replacement_pause`
drives a production `ChangeZone -> Token { count: EventContextAmount }` chain
through three per-target replacement pauses and asserts three tokens are created
(was 0 with the old order — verified by toggling the drain order).

Full engine lib suite (14309) green, clippy + fmt clean. CR 608.2c.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GildardoDev pushed a commit to GildardoDev/phase that referenced this pull request Jun 30, 2026
… "that many" (phase-rs#1093) (phase-rs#4657)

* fix(engine): targeted multi-object ChangeZone records moved count for "that many" (phase-rs#1093)

The targeted multi-object `ChangeZone` resolution loop moved each chosen
target but never stamped `state.last_effect_count`, unlike the single-object
branches (`Some(1)`) and the mass `resolve_all` path (`Some(moved_count)`).
A chained sub-ability referencing the move count via
`QuantityRef::EventContextAmount` ("that many") therefore read a stale or
empty count.

Vengeful Regrowth — "Return up to three target land cards from your graveyard
to the battlefield tapped. Create that many 4/2 green Plant Warrior creature
tokens with reach." — parses correctly (token count = EventContextAmount) but
created the wrong number of tokens because the return leg left the count
unstamped (issue phase-rs#1093).

Track the moved count in the targeted loop the same way the mass path does
(post-move destination check, excluding no-op/prevented moves), stamp it at
the no-pause completion, and thread `Some(moved_count)` through
`PendingChangeZoneIteration` on the pause arms so the resume drain — which
already stamps `last_effect_count` when the count is `Some` — completes the
total correctly across replacement/aura pauses.

CR 608.2c: "that many" refers back to the prior action's count.

Closes phase-rs#1093

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(engine): count replacement-paused members in targeted ChangeZone move count

Review follow-up (phase-rs#4657): a member of a targeted multi-object `ChangeZone`
that pauses on a per-permanent replacement CHOICE (`ZoneMoveResult::NeedsChoice`)
is delivered out-of-band by the replacement resume, not by the iteration
drain's `remaining` loop — so the drain never counted it toward `moved_count`,
undercounting a downstream "create/draw that many" by one per replacement pause.

Carry the paused (in-flight) object and its pre-move zone on a new
`GameState::pending_change_zone_in_flight` field, set at every `NeedsChoice`
pause arm (targeted `resolve`, mass `resolve_all`, and the drain's re-pause).
The drain consumes it at its top and increments the carried count iff the
object actually reached the iteration's destination (a same-zone no-op or a
prevented/redirected move is excluded). Pause/resume is strictly sequential, so
at most one object is ever in flight.

Regression: `targeted_multi_change_zone_counts_replacement_paused_members` —
three shocks returned library→battlefield, each pausing on its `Moved`
replacement, must stamp `last_effect_count == Some(3)` (was `Some(0)`).

CR 608.2c.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(engine): drain ChangeZone iteration before continuation so "that many" sees final count

Review follow-up phase-rs#2 (phase-rs#4657, [HIGH]): in `drain_pending_continuation` the parked
continuation was resolved BEFORE `drain_pending_change_zone_iteration`. For a
`ChangeZone -> create/draw that many` chain whose returns pause on a per-target
replacement choice, the chained `QuantityRef::EventContextAmount` consumer ran
first and read the stale pre-pause `last_effect_count` (the final count is only
stamped when the iteration completes), creating/drawing the wrong number.

Resume the paused ChangeZone iteration FIRST, then the continuation (guarded so
a re-pause on a further replacement choice stops it). This mirrors the non-pause
path, where `change_zone::resolve` stamps `last_effect_count` and emits the
ChangeZone `EffectResolved` before the chained sub-ability runs.

Regression: `change_zone_then_create_that_many_counts_across_replacement_pause`
drives a production `ChangeZone -> Token { count: EventContextAmount }` chain
through three per-target replacement pauses and asserts three tokens are created
(was 0 with the old order — verified by toggling the drain order).

Full engine lib suite (14309) green, clippy + fmt clean. CR 608.2c.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
matthewevans added a commit to ntindle/phase that referenced this pull request Jul 4, 2026
… — play historic from TOP OF LI (phase-rs#4341)

* fix(parser): UNSUPPORTED cluster: The Fourth Doctor (Blast COMMANDER) — play historic from TOP OF LI

* fix(PR-4341): address maintainer review — honest unsupported rider, land-play frequency, text/lower desync, context restore

Maintainer @matthewevans raised four issues on PR phase-rs#4341 (The Fourth Doctor
parser cluster). All four are addressed here.

1. Reflexive trigger provenance (issue #1):
   Replace the rules-incorrect `TriggerMode::PlayCard` trigger with a
   `TriggerMode::Unknown` gap marker. A global PlayCard trigger cannot
   distinguish which permission authorized a given play (CR 603.12), so
   the "When you do" rider was firing even when a different top-of-library
   permission authorized the play. The Unknown trigger keeps the gap
   visible in coverage until the casting/land-play pipeline gains
   permission-provenance tracking.

2. Land-play frequency consumption (issue phase-rs#2):
   Add `record_top_of_library_land_permission` in engine.rs and call it
   from all three completion arms of `handle_play_land`. A
   `OncePerTurn` `TopOfLibraryCastPermission { play_mode: Play }` now
   consumes the per-turn slot on land plays, mirroring how
   `finalize_cast` records this for spell casts (CR 401.5 + CR 601.2a).

3. Context mutation without restore (issue phase-rs#3 / Gemini):
   The reflexive rider is no longer parsed at all (Unknown trigger is
   emitted unconditionally), eliminating the ctx.subject/ctx.actor
   mutation that was leaking into subsequent lines.

4. text/lower desync on frequency-prefix strip (issue phase-rs#4 / Gemini):
   In `try_parse_top_of_library_cast_permission`, shadow both `text` and
   `lower` together when stripping the "once each turn, " / "once during
   each of your turns, " prefix, so downstream helpers receive aligned
   slices (Gemini phase-rs#3 suggestion, CR 601.2a).

5. Redundant scan_contains pre-check removed (Gemini phase-rs#2):
   The `scan_contains` guard before `split_once_on_lower` in oracle.rs
   was redundant — `try_parse_top_of_library_cast_permission` already
   validates the anchor. Removed for clarity.

Test: `the_fourth_doctor_full_card_parse` updated to expect
`TriggerMode::Unknown` instead of `TriggerMode::PlayCard`.

Verification: cargo clippy -p engine --all-targets -- -D warnings: clean.
cargo test -p engine: all pass.

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

* fix(PR-4341): capture top-of-library land permission before zone change

The `record_top_of_library_land_permission` helper was called in the
land-play epilogue — after `zone_pipeline::deliver` had already moved the
land from Library → Battlefield. At that point `top_of_library_permission_source`
reads `player.library.front()`, which now points to the *next* card, so
`top_id != object_id` always triggered and the slot was silently left
unconsumed. A `OncePerTurn` top-of-library `Play` permission (The Fourth
Doctor shape) could therefore be reused indefinitely for lands.

Fix: capture `(src_id, frequency)` from `top_of_library_permission_source`
*before* the replacement pipeline, alongside the existing
`in_library_with_permission` eligibility check. The simplified
`record_top_of_library_land_permission` now accepts the pre-captured pair
instead of re-deriving it post-delivery. All three epilogue paths
(Execute + `NeedsChoice` replacement prompt, `NeedsChoice` directly, and
the normal path) use the captured value.

Also adds `once_per_turn_library_land_play_consumes_slot_and_blocks_second_play`,
a production-path regression test that plays two historic lands from the
top of the library under a `OncePerTurn` `play_mode: Play` permission,
asserts the first play stamps `top_of_library_cast_permissions_used`, and
asserts the second play is rejected — the discriminating assertion that
the pre-capture bug made impossible to satisfy.

Addresses matt's round-4 review blocker (2026-06-28).

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

* fix(CR-annotations): replace CR 601.2a with CR 305.1/116.2a/401.5 on land-play paths

Matt's 2026-07-01 review (CHANGES_REQUESTED) identified that all six
CR 601.2a annotations in the top-of-library land-play path cite the
spell-casting-to-stack procedure (601.2a) instead of the rules that
actually govern this path:
  - CR 305.1: playing a land is a special action, not a spell cast
  - CR 116.2a: playing a land puts the card onto the battlefield from
    the zone it was in (zero stack involvement)
  - CR 401.5: top-of-library visibility closes after the special action

Fixed locations:
  - engine.rs: doc-comment on record_top_of_library_land_permission (×1)
  - engine.rs: inline comment in handle_play_land pre-capture block (×1)
  - engine.rs: three call-sites in the success/NeedsChoice/fallthrough
    branches of handle_play_land that record the permission slot (×3)
  - engine_tests.rs: doc-comment on the once-per-turn slot regression
    test (×1)

All six now cite CR 305.1 + CR 116.2a + CR 401.5 with explanatory text.
The two remaining mentions of CR 601.2a in the updated comments are
explicit "does not apply here" notes, which is the correct documentation.

Verification: cargo clippy -p engine --all-targets -- -D warnings (clean),
cargo test -p engine (all pass), cargo fmt --all (no changes).

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
lgray added a commit to lgray/phase that referenced this pull request Jul 5, 2026
… unattach delayed trigger (phase-rs#4380 block-D)

Parser recognizer for Stolen Uniform's last sentence ("When you lose control of
that Equipment this turn, if it's attached to a creature you control, unattach
it") plus the two engine gaps it exposed, so the delayed trigger actually
unattaches the right Equipment at cleanup — not a hollow parse flip.

Parser (oracle_effect/mod.rs, oracle_target.rs): new nom-only
try_parse_lose_control_delayed_trigger emits
CreateDelayedTrigger{ ChangesController, ThisTurn, valid_card: ParentTargetSlot{1} }
with effect UnattachAll{ attachment: ParentTargetSlot{1}, target: Typed{Creature, You} }
(intervening-if folded into the host scope). Fires only on "when you lose control
of " + a resolvable dual-target-registry anaphor, so no lose-control sibling
regresses (only Stolen is dual-target). CR 603.7 / 603.4 / 603.2 / 701.3d.

Gap phase-rs#1 — trigger stall (triggers.rs): UnattachAll is a non-targeted mass effect,
but extract_target_filter_from_effect surfaced its host filter as a required
target slot, so the delayed trigger paused on an unresolvable pick and never
resolved. Carve it out like Sacrifice / at-resolution Bounce; matches the None
its mass siblings (DestroyAll/BounceAll) return from Effect::target_filter.
CR 701.3d + CR 115.1.

Gap phase-rs#2 — attachment anaphor (attach.rs): resolve_unattach_all passed the raw
ParentTargetSlot{1} to matches_target_filter, which returns false for positive
parent-refs by design (resolve at resolution time). Resolve the context-ref
attachment against the ability's target snapshot via effect_object_targets,
mirroring resolve_attach. Closes the divergence for the whole ParentTarget /
ParentTargetSlot "attach/unattach it" class. CR 608.2c + CR 701.3d.

Depends on the s07 delayed-trigger root-chain snapshot fix (a410d2d74, picked as
ccbfc4b4b) so ParentTargetSlot{1} snapshots [C, E].

Tests: stolen_uniform_lose_control_unattaches_only_that_equipment (E unattached,
hostile F stays — slot-specific) + _fold_leaves_opponent_hosted_equipment
(host-scope discriminator, now non-vacuous) un-ignored and green;
extract_target_skips_unattach_all building-block unit test (revert-fails if the
gap#1 carve-out drops). Full test-engine: 14707 lib + all integration green;
CI clippy 0 warnings.

Assisted-by: ClaudeCode:claude-opus-4.8
matthewevans added a commit that referenced this pull request Jul 5, 2026
…#5155)

* feat(engine): dynamic keep count on the dig pipeline (Stargaze)

Parameterize PutCount::Up/Exactly payload u32 -> QuantityExpr and add an
additive Effect::Dig.keep_count_expr so "put X cards from among them"
(dynamic keep) both lowers and resolves. Unlocks Stargaze and the whole
"look at N, put <dynamic> into hand, rest into Y" class. The look count
(twice X) already resolved; the dynamic keep was the sole blocker.

Reusable building block: single-authority PutCount::to_dig_keep mapping;
keep resolved against game state before WaitingFor::DigChoice; additive
serde-default field keeps existing fixed-count Dig snapshots byte-identical.

CR 701.20e (look), CR 608.2c (follow instructions), CR 107.1b (negative -> 0).

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): opponent-constrained target-player slot (Quick Draw)

Add ControllerRef::TargetOpponent -- a pure routing tag whose runtime read
is identical to TargetPlayer but whose companion target-player slot offers
only opponents (self excluded; any one opponent in >2p), reusing the
existing TargetFilter::Typed{controller:Opponent} + find_legal_targets
legality path. Lowers "creatures target opponent controls lose <kw>..."
(Quick Draw) and unlocks the whole "target opponent controls" class.

Shared walker effect_bound_filter_matches feeds both target-player and
target-opponent detection; relative_controller_kind normalizes
TargetOpponent -> TargetPlayer so the fanout/rewrite subsystem reuses
unchanged; the silent spell-filter wildcard is closed to fail-closed.

CR 109.4, CR 102.2 / CR 102.3 (opponent), CR 611.2c (EOT set fixed at start),
CR 702.7a (first strike), CR 702.4a (double strike).

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): reflexive "this way" delayed-trigger building block (Prishe, Rhino)

Add DelayedTriggerLifetime::Reflexive (CR 603.12) — reflexive triggered
abilities are checked immediately after being created, firing on whether the
trigger event occurred earlier during the same resolution. Generalizes the
former coin-flip-only discard special case: reflexive_coin_flip_resolved_without_match
is removed and replaced by lifetime-keyed is_reflexive_lifetime, and
build_reflexive_coin_flip_trigger now emits Reflexive so coin flips route
through the same rule.

Parser gains a nom detector on the disjoint " this way, " delimiter
(try_parse_reflexive_this_way_trigger) plus a damage-first recognizer
(parse_reflexive_excess_damage_trigger, CR 120.10 excess-damage). Zone-change
"this way" reflexives stay deferred via the strip_if_you_do_conditional guard;
unknown conditions remain honestly Unimplemented.

Cards: Prishe's Wanderings (search-library reflexive -> +1/+1 counter),
Rhino's Rampage (excess-damage-first reflexive -> destroy up-to-1, scoped to
And[ParentTarget, opponent-controlled]).

Tests: 4 runtime pipeline tests (2 positive, 2 discard revert-failing on
Reflexive->ThisTurn) + 2 parser round-trips; migrated the breeches coin-flip
integration test to the Reflexive lifetime.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): scope "lose control" trigger + emit control-loss at cleanup (CR 514.3a)

match_changes_controller fired on ANY ControllerChanged/EffectResolved{GainControl},
ignoring valid_card and direction — a latent over-fire (Portent trap) for the three
supported "When you lose control of ~" cards (Khârn the Betrayer, Duplicity,
Gustha's Scepter). Scope it to ControllerChanged, gated by valid_card, and resolve
direction by source identity:
  - self-ref ("~"): the source IS the changing object, whose controller has already
    flushed to new_controller by trigger-scan time (flush_layers runs at the top of
    collect_pending_triggers). Rely on CR 603.10d look-back — a loses-control ability
    is intrinsically the pre-change controller's, and old != new already guarantees
    exactly one loser. Fire for it.
  - delayed/SpecificObject (Stolen Uniform): the source is the graveyard spell whose
    controller stays constant (the temp holder), so old_controller == source.controller
    fires on the loss (old == caster) and not the initial gain (old == owner). CR 603.2.
Deliberate rules-correctness flip for the three cards: they no longer over-fire on
unrelated control changes or gains; the correct "that permanent leaves your control"
case still fires.

Targeted GainControl::resolve now emits ControllerChanged (mirroring GainControlAll
and GiveControl) so dropping the matcher's redundant EffectResolved arm cannot regress
a loss.

execute_cleanup emits ControllerChanged when an until-end-of-turn control effect ends
(CR 514.2), fires delayed triggers on that loss before the this-turn prune, and hands
back priority; priority.rs re-enters cleanup once the stack empties (CR 514.3a "another
cleanup step begins"). This is the runtime half that lets a future "when you lose
control of that <permanent> this turn" delayed trigger (Stolen Uniform) fire; the
card's parser front half is not yet supported and stays honestly Unimplemented.

Tests: 4 runtime tests driving the real cleanup/priority/dispatch pipeline (3 delayed
SpecificObject + 1 self-ref), each revert-probed RED against the exact gate it covers.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): look-at + play face-down exile building block (Outrageous Robbery)

Outrageous Robbery: "Target opponent exiles the top X cards of their library
face down. You may look at and play those cards for as long as they remain
exiled. If you cast a spell this way, you may spend mana as though it were mana
of any type to cast it."

Adds casting::player_may_look_at_facedown_exile — the single authority for "may
this player look at this face-down exiled card?", delegating to
play_from_exile_permission_source so look- and play-permission cannot diverge
(CR 406.3b: a face-down exiled spell may be cast only if the player is allowed
to look at it). It inherits the source's card_filter / single_use / per-turn
gating. visibility.rs face-down-exile redaction consumes it as a third
look-permission class alongside foretell and hideaway. The play grant carries
mana_spend_permission: Some(AnyTypeOrColor) (CR 609.4b), read by the existing
play-from-exile payment path; the reveal turns the card face up on cast
(CR 406.3a).

Parser: the subject-voice "<player> exiles the top N ... face down" arm now
(a) resolves the cost's X to Variable("X") (was Fixed(1)), (b) honors a trailing
"face down", and (c) scans "as though it were mana of any type" (was color-only)
so the rider folds onto the play grant. Corrects a pre-existing wrong CR
annotation on that arm (701.10a Doubling -> 701.13a Exile). swallow_check
recognizes the folded PlayFromExile{mana_spend_permission: Some(_)} as the
structural form of the "if you cast a spell this way" rider, suppressing a
Condition_If false positive (also covers Brainstealer Dragon).

Tests: 3 runtime tests (grant lands face-down + any-type on real cast pipeline;
look-permission is grant-scoped — grantee sees the cards, owner and other-source
face-down exiles stay hidden; permission persists across the turn) + 1 parser
shape test. Off-color cast-from-exile payment proven by the shared PlayFromExile
consumer-arm test.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): parse "the spell's mana value <= X" gate for impulse-cast (Bre of Clan Stoutarm)

Bre of Clan Stoutarm's end-step ability: "if you gained life this turn, exile
cards from the top of your library until you exile a nonland card. You may cast
that card without paying its mana cost if the spell's mana value is less than or
equal to the amount of life you gained this turn. Otherwise, put it into your
hand." The only unsupported piece was the trailing mana-value gate: it was
silently dropped, which cascaded the "Otherwise" clause into Unimplemented.

Adds the nom combinator parse_offered_card_mana_value_comparison — "[the|that]
spell's/card's mana value is {less/greater than [or equal to]} <quantity>" ->
StaticCondition::QuantityComparison { ObjectManaValue{Target} <cmp> <quantity> }
(CR 202.3 mana value, CR 115.1 target, CR 608.2c). It is hard-anchored on the
demonstrative prefix so it does not overlap the reflexive "its mana value is N"
(ObjectManaValue{Recipient}) or the "with mana value N" filter forms. Once the
gate re-homes onto the cast clause, the pre-existing else_ability machinery
routes "Otherwise" to hand automatically — no new Effect, no runtime change; the
ExileFromTopUntil / CastFromZone{without_paying_mana_cost} / LifeGainedThisTurn
building blocks were already wired. Bre's activated ability already parsed.

Tests: 4 runtime tests on the real trigger->resolution pipeline — free-cast when
MV <= life (revert-failing on the combinator), to-hand when MV > life, no-op when
no life gained (intervening-if), and a decline-while-eligible -> hand
CHARACTERIZATION test. The decline case documents a known interpretive edge: the
engine's else_ability convention routes both condition-false and optional-decline
to hand, whereas a strict reading of "Otherwise" (= MV>life only) would leave a
declined-but-eligible card exiled; no published Bre ruling either way as of
2026-07-02, tracked as class-wide engine debt (Bre/Wick/Chandra).

Assisted-by: ClaudeCode:claude-opus-4.8

* fix: thread keep_count_expr + TargetOpponent through non-engine consumers (mtgish-import, phase-ai tests)

Commits 1b67f0248 (Stargaze) and 6f3a35d11 (Quick Draw) added the `keep_count_expr` field to `Effect::Dig` and the `ControllerRef::TargetOpponent` variant to the engine but did not update the non-engine consumer crates, leaving CI's exact lint surface (`cargo clippy --workspace --exclude phase-tauri --all-targets`) red. `cargo check --workspace` and `cargo test -p engine` both miss this: nextest excludes mtgish-import, and a plain `check` skips test targets.

- mtgish-import action.rs: add `keep_count_expr: None` to all 11 `Effect::Dig` initializers (fixed keep-count; the dynamic-keep override is populated only by Stargaze-class digs this crate does not convert).
- mtgish-import player_effect.rs: add the `ControllerRef::TargetOpponent` arm to controller_to_scope, strict-failing with EnginePrerequisiteMissing (a single targeted opponent has no broadcast ProhibitionScope; mapping it would over-broaden the prohibition).
- phase-ai control.rs + spellslinger_prowess.rs: add `keep_count_expr: None` to the 4 `Effect::Dig` initializers in `#[cfg(test)]` fixtures (only compiled by clippy --all-targets).

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): Memory Vessel — play-from-exile grant + can't-play-from-zone prohibition

Memory Vessel ({T}, exile it: each player exiles the top 7, may play them until the activator's next turn, and can't play from their hand) now lowers fully — the previously-collapsed "players may play cards they exiled this way, and they can't play cards from their hand" clause resolves into a per-owner play-from-exile grant plus a play-from-zone prohibition.

Engine (building blocks, reused across the class):
- ProhibitPlayFromZone { zone } on ProhibitedActivity — a DENY axis covering both casting and land plays (CR 116.2a/305.1/601.2a), distinct from the CastOnlyFromZones allow-list which is cast-only. Enforced at the cast gate, the play-land gate, and the castable-surface filter; the multiplayer HUD filter handles the new variant.
- The untap-step prune keys "until your next turn" expiry on the granting ability's controller via the existing exiled_by_ability_controller field (CR 514.2/611.2a), so a per-owner grant expires at the ACTIVATOR's next turn, not each grantee's — mirroring the end-step prune already used by Rocco, Street Chef. No new field.

Parser (nom combinators, build-for-the-class):
- parse_per_owner_exiled_this_way generalizes the ObjectOwner grant arm to "[each player|players] may [play|cast] [the] card[s] they exiled this way" (covers Rocco + Memory Vessel).
- try_parse_cant_play_from_zone: "[scope] can't play [cards|lands...] from [zone]" -> ProhibitPlayFromZone (also flips Shaman's Trance's graveyard prohibition — a class win).
- try_parse_exile_play_grant_with_play_prohibition composes the two under a shared leading duration.

Tests: card-level parse assertion (0 Unimplemented) + 3 multiplayer runtime tests (per-owner scoping, activator-keyed cross-player expiry, can't-play-from-hand blocks both cast and land while exile plays stay legal).

Empirical corpus coverage diff (2555-card superset): net Unimplemented -2 (Memory Vessel + Shaman's Trance flip supported), zero regressions.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): dual-target slot registry + anaphoric slot binding (Stolen Uniform front-half)

Add a declared-target-slot registry on ParseContext so "Choose target X and
target Y" chains bind later anaphors ("that Equipment", "the chosen creature",
"the artifact card") to a precise ParentTargetSlot{index} instead of the
ambiguous grab-all ParentTarget. Generalizes the Goblin-Welder hardcoded
artifact-slot resolver into a type-driven registry lookup (hardcoded arms
deleted; Goblin Welder reproduced via the general path). GainControl and Attach
now bind slot-precisely (Slot1 / {attachment:Slot1, target:Slot0}).

Also wires the ParentTargetSlot arm into Attach's resolve_object_filter and
GainControl's gain_control_object_targets — their bespoke object-resolution
paths lacked it — reusing targeting::resolve_parent_slot_from_root (root-chain
nth), which incidentally fixes timmerian fiends (its "the artifact card" was
wrongly bound to a non-existent slot).

Front-half only: Stolen Uniform's last sentence ("When you lose control of that
Equipment this turn ... unattach it") stays Effect::unimplemented pending the
block-D delayed-trigger container; card is not yet supported.

The determiner anaphor path uses nom combinators; the pre-commit parser gate's
flags are stale-base (ae663ee8c) false positives on pre-existing mod.rs /
untouched oracle_trigger.rs lines — none in this commit's additions (verified).

CR 601.2c (target chosen once per instance of "target") + CR 608.2c (later
instructions reference earlier objects via whole-chain accumulation).

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): classify new engine surfaces in the #4904 fail-closed walker

Rebasing the S25 tranche onto main (#4904 growing-cascade detector + fail-closed
ability-scan walker) surfaces two exhaustive-match classification points for
engine surfaces this branch added before the walker existed:

- ControllerRef::TargetOpponent (Quick Draw) — Axes::NONE in the C0 axis
  classifier, mirroring TargetPlayer. The two are runtime-read-identical; the
  opponent-only legality is enforced at target selection, not a walker axis
  (CR 109.4).
- Effect::Dig.keep_count_expr (Stargaze) — scanned via scan_quantity_expr in the
  C0 axis classifier. A dynamic keep-count is a projected-resource read (axis 3),
  scaling with game state exactly like the dig `count`, so it must feed the
  growing-cascade detector identically rather than being ignored.

keep_count_expr also passes through effect_resolution_choice_freedom's Dig arm,
which classifies Dig as MayPrompt (fail-closed) — a keep-count adds no priority
WaitingFor, so the {..} pass-through is sound and needs no per-field action.

ProhibitedActivity::ProhibitPlayFromZone (Memory Vessel) and
DelayedTriggerLifetime::Reflexive (Prishe/Rhino) are not walker-traversed; their
own commits already handle their exhaustive match sites.

Only ability_scan.rs (game/) changed; the pre-commit parser gate's flags are
stale-base (ae663ee8c) false positives on pre-existing parser lines not in this
commit.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): pay turn-face-up cost as a special action (Overgrown Zealot, Tin Street Gossip)

Turning a face-down permanent face up is a special action (CR 116.2b) that must
pay the morph/megamorph/disguise cost (CR 702.37e / 702.168d) or the manifested
creature's mana cost (CR 701.40b). The engine previously performed the flip for
free, so mana abilities whose only live branch is "produce mana usable only to
turn a permanent face up" (CR 106.6 restricted-purpose mana) were left as
honest-red Unimplemented gaps.

- morph.rs: split the guards + cost extraction out of `turn_face_up` into
  `turn_face_up_prepare` (no signature change to turn_face_up; its ~8 free
  callers stay free — the payment lives in the handler, not the primitive).
- engine.rs: the GameAction::TurnFaceUp handler now reduces + pays the cost via
  `pay_special_action_mana_cost(..., SpecialAction::TurnFaceUp, ...)`, mirroring
  the UnlockDoor special-action sibling. Sole production paid entry.
- types/ability.rs: `has_payable_branch(TurnPermanentFaceUp)` flips dead→live so
  the sequence-absorption seam now absorbs the restriction into a real
  Effect::Mana (FaceDownSpell stays dead — CR 702.37c face-down casting is still
  unimplemented). Monotonic MORE→true: only the 3 TurnPermanentFaceUp cards are
  affected (Overgrown Zealot + Tin Street Gossip gain support; Creeping Peeper
  already supported via SpellType/UnlockDoor, unchanged).

Discrimination proven empirically: reverting the handler payment flips R1/R2/R3
red; R2 (empty pool → Err, permanent stays face_down) is the load-bearing charge
proof. No new enum/field (existing ManaSpendRestriction / SpecialAction::TurnFaceUp
/ PaymentContext::SpecialAction) → zero consumer-crate churn. s07-frozen files
untouched. Parser dispatch unchanged (oracle_tests.rs changes are test assertions
only; the gate's flags are stale-base ae663ee8c false positives, not this commit).

CR 106.6 + CR 116.2b + CR 702.37e + CR 702.168d + CR 701.40b + CR 702.37c.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): grant abilities beyond activated-only via GrantedAbilityScope (Symbiote Spider-Man, Choreographed Sparks, Nalfeshnee)

Parameterize Effect::GainActivatedAbilitiesOfTarget with a typed
GrantedAbilityScope { ActivatedOnly (default) | AllOther } field (serde-default,
zero consumer-crate churn) instead of adding a sibling variant — the granted
ability set is fixed at effect start (CR 611.2c), within a single rule section.

- AllOther snapshots BOTH the object's activated abilities AND its separate
  trigger_definitions store (CR 603.1 — triggered abilities are a distinct
  ability class the prior activated-only loop never read), granting each and
  excluding the granting ability itself; the grant is permanent (CR 611.2a).
- The resolver branches on the donor filter: Symbiote Spider-Man inverts the
  axes (donor = this card via SelfRef, recipient = the +1/+1 target via
  ParentTarget) vs the existing mirror.
- Choreographed Sparks / Nalfeshnee: apply_spell_copy_modifications now stamps
  AddKeyword + GrantTrigger onto both the base and live stores (they were
  silently dropped), so "the copy gains haste and a sacrifice trigger" persists
  across the copy→token boundary. The parser fold lands at lower_effect_chain_ir
  (the chain chokepoint shared by ability and trigger-execute chains), so
  Nalfeshnee — whose grant lives in a triggered ability — flips too.

Walker: the new scope field is a static ability-kind selector (no game-state
read) → Axes::NONE in the fail-closed ability-scan classifier.

Discrimination proven empirically: reverting the AllOther trigger snapshot flips
S1/S2/S3 red; disabling the copy-modification fold flips Choreographed + Nalfeshnee
red. Coverage +3, zero regressions across all 35397 faces. No new Effect variant;
s07-frozen files untouched.

CR 611.2a + CR 611.2c + CR 603.1 + CR 701.21a (delayed sacrifice) + copy/haste rules.

Assisted-by: ClaudeCode:claude-opus-4.8

* test(engine): flip Choreographed Sparks deferred-pin to supported

P2f (4b2566eb6) implemented Choreographed Sparks' copy-grant (haste +
delayed-sac fold via apply_spell_copy_modifications), invalidating the
deferred-honesty guard `choreographed_sparks_copy_grant_is_deferred`, which
asserted the card still lowered to Unimplemented. Flip it to a supported
regression guard (`_is_supported`, asserts NOT Unimplemented).

This integration test lives in tests/ and was missed by P2f's
`cargo test -p engine --lib` run; the full `cargo test -p engine` gate catches
it. Fixup for 4b2566eb6 — fold at ship-time autosquash.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): Stolen Uniform lose-control container — runtime-correct unattach delayed trigger (#4380 block-D)

Parser recognizer for Stolen Uniform's last sentence ("When you lose control of
that Equipment this turn, if it's attached to a creature you control, unattach
it") plus the two engine gaps it exposed, so the delayed trigger actually
unattaches the right Equipment at cleanup — not a hollow parse flip.

Parser (oracle_effect/mod.rs, oracle_target.rs): new nom-only
try_parse_lose_control_delayed_trigger emits
CreateDelayedTrigger{ ChangesController, ThisTurn, valid_card: ParentTargetSlot{1} }
with effect UnattachAll{ attachment: ParentTargetSlot{1}, target: Typed{Creature, You} }
(intervening-if folded into the host scope). Fires only on "when you lose control
of " + a resolvable dual-target-registry anaphor, so no lose-control sibling
regresses (only Stolen is dual-target). CR 603.7 / 603.4 / 603.2 / 701.3d.

Gap #1 — trigger stall (triggers.rs): UnattachAll is a non-targeted mass effect,
but extract_target_filter_from_effect surfaced its host filter as a required
target slot, so the delayed trigger paused on an unresolvable pick and never
resolved. Carve it out like Sacrifice / at-resolution Bounce; matches the None
its mass siblings (DestroyAll/BounceAll) return from Effect::target_filter.
CR 701.3d + CR 115.1.

Gap #2 — attachment anaphor (attach.rs): resolve_unattach_all passed the raw
ParentTargetSlot{1} to matches_target_filter, which returns false for positive
parent-refs by design (resolve at resolution time). Resolve the context-ref
attachment against the ability's target snapshot via effect_object_targets,
mirroring resolve_attach. Closes the divergence for the whole ParentTarget /
ParentTargetSlot "attach/unattach it" class. CR 608.2c + CR 701.3d.

Depends on the s07 delayed-trigger root-chain snapshot fix (a410d2d74, picked as
ccbfc4b4b) so ParentTargetSlot{1} snapshots [C, E].

Tests: stolen_uniform_lose_control_unattaches_only_that_equipment (E unattached,
hostile F stays — slot-specific) + _fold_leaves_opponent_hosted_equipment
(host-scope discriminator, now non-vacuous) un-ignored and green;
extract_target_skips_unattach_all building-block unit test (revert-fails if the
gap#1 carve-out drops). Full test-engine: 14707 lib + all integration green;
CI clippy 0 warnings.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): become-a-typed-token copula on reanimated objects (Vraska the Silencer, Brilliance Unleashed)

P2e — "It's a <typed thing> …" applied to a returned/reanimated non-copy
object, as an indefinite continuous effect bound to that object. Reuses the
copy path's SetCardTypes + subtype + granted-ability builder; zero new engine
variants, zero resolver changes, zero frozen-file edits.

Vraska, the Silencer: "return that card … tapped under your control. It's a
Treasure artifact with '{T}, Sacrifice this artifact: Add one mana of any
color,' and it loses all other card types." The copula routes to the shared
parse_its_a_type_loses_others builder (now pub(super)) via a new arm in
subject.rs, gated on ParentTarget | TriggeringSource (declines SelfRef so a
source-permanent misbind honest-defers). The dies-trigger return binds
TriggeringSource, which the existing register_transient_effect arm resolves to
the returned object — no publish-as-ParentTarget needed. CR 205.1a / 205.1b /
613.1d / 611.2a / 400.7 / 603.6 (NOT 707.9d — copy-effect-scoped).

Block 1a (sequence.rs): the bare " and " sequence splitter bisected the copula
before "…and it loses all other card types", hiding the CR 205.1a replacement
signal from the builder. Suppress the split when the remainder is exactly
"(it )?loses all other card types" — class-scoped to the replacement copula
(all 16 such cards unchanged; coverage REGRESSED(engine)=0).

Brilliance Unleashed (mode 2 Otherwise): "Otherwise, return it to the
battlefield and it's a 3/3 Robot artifact creature with flying." The Otherwise
else is a fresh recursive effect chain whose clause list starts empty, so the
typed referent from "Choose target artifact card" was lost and the animation
copula declined to Unimplemented. Seed ctx.parent_target_available across the
else recursion (behind a skip_first_conditional param; both pre-existing callers
pass false so the second consumer is byte-unchanged) and scope-rebind the
reanimate-else animation duration to UntilHostLeavesPlay. Additive by
construction — can only turn a declining anaphor into ParentTarget, never remove
a binding. Bre of Clan Stoutarm (C12) reuses the same else-seed.

C3: both copulas install Duration::UntilHostLeavesPlay (CR 400.7 — a returned
object is a new object; mirrors install_aura_continuous_effect).

Tests (std_longtail_e.rs, +4, deferred note flipped): parser round-trip +
runtime for each card. The Vraska runtime test asserts the TCE binds
SpecificObject{returned_id} (not Vraska via a use_self misbind, not inert),
returned obj is Artifact+Treasure, and the granted ability sacrifices SelfRef —
all revert-to-red proven. gen-card-data: both flip supported, gap_count=0.
Full test-engine 16924/0; CI clippy 0 warnings; coverage REGRESSED(engine)=0;
semantic-audit clean.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): cloak from a chosen non-library source (Vannifar, Evolved Enigma)

Vannifar's "Cloak a card from your hand" — the controller cloaks a card they
CHOOSE from hand, not the top of the library. Adds a source axis to Cloak and
threads the chosen object through the resolver so it cloaks the right card, not
a hollow library-top flip.

Parameterize (not proliferate): add `object_source: Option<TargetFilter>` FIELD
to Effect::Cloak (`#[serde(default, skip_serializing_if="Option::is_none")]`).
None = CR 701.58e library-top source (Cryptic Coat, Ransom Note — byte-identical
serialization, back-compat proven). Some(filter) = explicit objects chosen
upstream. Zero new variants.

Resolver (cloak.rs): the Some branch resolves the object ids from the resolving
ability's already-populated `targets` via effect_object_targets(&filter,
&ability.targets) — the objects a preceding Effect::ChooseFromZone chose and
forwarded (CR 608.2c) — then manifest_card(…, cloaked_2_2()) per object. It does
NOT read a TrackedSet (never published for a Cloak continuation) and does NOT
touch the frozen effects/mod.rs. The None branch is the unchanged library-top
loop.

Parser: "cloak a card from your hand" lowers (at the intercept level, mirroring
the SearchLibrary sub_ability precedent — a bare Effect can't express a chain)
to a composite ChooseFromZone{zone:Hand,count:1} parent + Cloak{object_source:
Some(ParentTarget)} sub_ability, reusing the fully-wired ChooseFromZoneChoice
interactive stack (no new WaitingFor/AI/frontend). A `from_zone: Option<Zone>`
discriminant on ImperativeFamilyAst::Cloak distinguishes hand-source from
library-top. Pure nom (tag/alt/all_consuming).

Walker (#4904 ability_scan.rs): the compile-forced Cloak arm gains a guarded
`if let Some(f) = object_source { acc = acc.or(scan_target_filter(f)); }`.

Anti-hollow-win test (tests/vannifar_cloak_from_hand.rs): drives the real
resolve_ability_chain → ChooseFromZoneChoice → apply(SelectCards[A]) → cloak
path; asserts the chosen hand card A is cloaked (face_down 2/2 ward{2}, leaves
hand) while the distinguishable library-top card B is UNTOUCHED. Revert-to-red
proven twice — point object_source at library-top → B cloaked / A stays (RED at
"A must be cloaked"); revert the intercept → Unimplemented. Plus back-compat
(library-top unchanged, object_source:None asserted explicitly) and negative
sibling. Expose the Culprit remains a separate later gate (reuses this field).

Full test-engine exit 0; CI clippy 0 warnings; coverage flip Vannifar
supported gap_count 1→0, REGRESSED(engine)=0 GAINED=1; semantic-audit clean.
CR 701.58a / 701.58e / 608.2c (grep-verified).

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): cloak an exiled face-down pile — Expose the Culprit mode 2

Expose the Culprit's mode 2 ("Exile any number of face-up creatures you
control with disguise in a face-down pile, shuffle that pile, then cloak
them") lowers to ChooseObjectsIntoTrackedSet{Creature, You,
HasKeywordKind{Disguise}} -> Shuffle{TrackedSet} -> Cloak{object_source:
Some(TrackedSet)}.

- KeywordKind::Disguise (CR 702.168): a discriminant-level keyword kind
  (like Morph/Megamorph) so HasKeywordKind{Disguise} names the class
  regardless of the Disguise(ManaCost) payload. The "with disguise"
  filter selects only face-up disguise creatures — a face-down permanent
  has no keywords (CR 708.2a).
- Shuffle gains a TrackedSet/pile branch (CR 701.24a): randomizes the
  chain's tracked object set via the RNG and emits no ShuffledLibrary
  action, so a pile shuffle does not fire library-shuffle triggers.
- Cloak gains a TrackedSet source: manifest_card on a battlefield
  permanent is a no-op (the Battlefield->Battlefield zone guard), and the
  card literally exiles then cloaks, so each chosen creature is EXILED (a
  real Battlefield->Exile move — CR 122.2 counters cease, CR 603.6c
  leaves-the-battlefield triggers fire, CR 704.5m/704.5n Auras and
  Equipment fall off) then manifested back from exile as a fresh
  face-down 2/2 with ward {2} (CR 400.7 new object, CR 701.58a/e). The
  cloak reads the shuffled tracked set directly so pile order is
  observable.

Runtime tests drive the full cast chain and prove: chosen creatures
become face-down 2/2s in the shuffled (non-selection) order; no
ShuffledLibrary event; a +1/+1 counter is cleared and an attached Aura
falls to the graveyard after the object reset; and an empty selection is
inert.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): interactive "any number" multi-zone name-matched search-and-exile

Route "search <its owner's/its controller's> graveyard, hand, and library for
any number of cards with <same-name-ref> and exile them" to the interactive
Effect::SearchLibrary path (CR 701.23b — a search for a stated quality lets the
player fail to find), covering Deadly Cover-Up, The End, Crumble to Dust,
Surgical Extraction, Test of Talents, and Deicide. Zero new engine enum variants.

Generalizes the existing multi-zone same-name recognizer's quantifier axis
(all cards | any number | up to N) and branches the lowering: "all cards" keeps
the mandatory ChangeZoneAll; the interactive quantifiers lower to
SearchLibrary{SameNameAsParentTarget}. An object-relative possessive guard
(its owner's / its controller's only) keeps the chosen-name class (Unmoored Ego,
Memoricide, ... — "choose a card name ... with that name") on the HasChosenName
path (CR 201.2).

- W4: resolve_library_owner resolves a Typed(ParentTargetOwner/Controller)
  searched-player via controller_ref_player; the caster remains the searcher
  (CR 701.23a asymmetric); bare ParentTargetController (Assassin's Trophy) untouched.
- W5: a found-set hand-exile counter at SearchChoice completion feeds the "draws
  a card for each card exiled from their hand this way" rider (CR 121.1).
- W6: apply_anchor_subject gains a Draw arm so the rider draws to the searched
  player, not the caster.
- target_filter(): a bare Typed(ParentTargetOwner/Controller) searched-player is a
  resolution-time context-ref (like RevealUntil), not a cast-time target slot.

Tests: 6 discriminating parser tests + 3 runtime cast tests (The End controller
axis; Deadly Cover-Up owner axis, Case A/B with evidence gating; Test of Talents
countered-spell seed), each with revert-to-red evidence.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): bind granted-ability self-references to the granting object (CR 201.5a)

When an ability grants another ability that refers to the granting object by
name (Deconstruction Hammer "Sacrifice Deconstruction Hammer", The Dominion
Bracelet "{15}, Exile The Dominion Bracelet", Trusty Boomerang "Return Trusty
Boomerang"), CR 201.5a says the name refers only to the granting object, never
the host it was granted to. Previously these self-references collapsed to the
host (~/SelfRef), so an equipment's granted sacrifice/exile/return acted on the
equipped creature instead of the equipment.

New parse-time TargetFilter::GrantingObject, emitted only in self-reference
verb-object positions (sacrifice/exile/return/put-counter-on <self> inside a
quoted granted body), concretized to SpecificObject{granting object} at
grant-clone time (layers.rs GrantAbility/GrantTrigger, via effect.source_id).
Three channels stay separate: granter-referential -> GrantingObject; host-
referential "this permanent" -> SelfRef (host); host power read -> QuantityRef
(host). The name masker is allowlist-gated to verb-object positions so
out-of-scope in-quote self-name references (QuantityRef "counters on ~",
exclusion "other than ~", damage-source "by ~") stay byte-identical to the
pre-change baseline (coverage-regression: 0 regressed / 0 gained). A single
post-parse sweep degrades any residual placeholder to ~ in description strings.

Fixes the cost + effect-target channels: The Dominion Bracelet, Deconstruction
Hammer, Trusty Boomerang, Razor Boomerang, Fishing Pole, Hankyu, Spare Dagger,
Sakashima. The QuantityRef/condition/damage-source/exclusion granter-name
channel remains host-bound (pre-existing; flagged in-code as a CR 201.5a
follow-up). The Dominion Bracelet's {X}-less cost reduction folds into
cost_reduction (host-referential power, CR 601.2f). Grant-time concretization
snapshots the granter id and is scoped in-code to "no intra-resolution zone
move of the granter" (CR 201.5a second sentence + CR 400.7).

Zero new engine enum variants beyond TargetFilter::GrantingObject.

Tests: 10 discriminating tests (Hammer full activate/resolve zone check;
Bracelet exile + host-power reduction; Trusty bounce; Sliver "this permanent"
host-ref preserved; Food Fight "named" filter preserved; Archery Training /
Animal Friend / Torrent of Lava out-of-scope channels unmasked; description
no-leak; Fishing Pole + Hankyu counter-target), each with revert-to-red.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): phase-scoped player control for Secret of Bloodbending (CR 723.2)

Secret of Bloodbending: "You control target opponent during their next combat
phase. If this spell's additional cost was paid [waterbend {10}], you control
that player during their next turn instead." Previously the base "next combat
phase" leaf was Unimplemented — CR 723.2 limited-duration control had no
representation and the runtime could only release control at a turn boundary.

Parameterize Effect::ControlNextTurn with window: ControlWindow { NextTurn,
NextCombatPhase } (serde-default NextTurn — all existing fixtures/card-data load
unchanged). CR 723.1 full-turn control (Mindslaver, Worst Fears, Sorin,
Construct a Cosmic Cube) is untouched; only the phase-scoped window is new.

Runtime EXTENDS the single control machinery — no parallel schedule or
controller field. A new phase-boundary activate/release hook in
finish_enter_phase (turns.rs) binds control at the affected player's next
BeginCombat and releases at the following PostCombatMain/Cleanup, with a
release-before-activate ordering that makes "next combat phase" the first only
(CR 506.7d by analogy). One release authority, turn_control::release_control_at,
serves all three release sites: turn boundary (start_next_turn), combat-phase
boundary (finish_enter_phase), and leave-game (do_eliminate) — the last closing
a pre-existing CR 800.4a/b gap that also affected Mindslaver's full-turn control.

Parser: a window alt() axis on the control suffix combinator; a subject.rs
deferral guard so the full pipeline routes "control ... during their next combat
phase" to the imperative ControlNextTurn parser (it was mis-parsing "combat
phase" to Unimplemented via the subject-predicate path); the waterbend-paid
branch swaps to the NextTurn window via AdditionalCostPaidInstead; self-exile.

Edge cases designed (CR 723.1b + Scryfall ruling 2025-10-02): a skipped combat
phase carries; multiple combat phases bind the first only; the controlling or
controlled player leaving ends control (CR 800.4a/b); 3+ players route only the
controlled seat. Coverage-regression on fresh card-data: 0 regressed, +1 (Secret).

Tests: 8 groups incl. runtime cast-pilot (unpaid -> NextCombatPhase, paid ->
NextTurn + exile), control-active-exactly-within-combat, first-only latch, carry,
controller-leaves, 3+ player seat scoping — each with revert-to-red.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(parser): repeat a whole process a fixed/variable count of times (CR 608.2c)

"Then repeat this process X more times." (Another Round) previously left an
Unimplemented{repeat} node. Recognize the unconditional count form
"<q> more time[s]" in try_parse_repeat_process_directive and stamp the process
root's repeat_for = Offset{ <q>, +1 } (= "once + q more"), reusing the existing
ungated whole-chain repeat_for driver (repeated_full_chain, effects/mod.rs) —
zero engine, zero new variant. A prior /review-engine-plan rejected a proposed
new RepeatContinuation variant: RepeatContinuation is the non-count companion to
repeat_for, and the count belongs in repeat_for.

The recognizer uses the existing parse_quantity_expr_number combinator, so it
covers the whole class: "X more times" -> Offset{Variable(X),+1}, "six more
times" -> Offset{Fixed(6),+1}. Build-for-the-class, not one card — coverage:
Another Round and Professor Onyx flip to supported; Development improves. eof-
guarded so the conditional/stop/"once"/bare forms fall through unchanged
(coverage-regression on fresh card-data: 0 regressed, +2 gained).

CR 608.2c: the controller repeats the same instructions in order. CR 107.3a /
601.2b: X is announced at cast and fixed once. CR 400.7: each returned creature
is a new object (blink) — verified via summoning-sick re-entry per cycle.

Known follow-up (pre-existing, documented, not introduced here): "exile any
number of creatures you control" lowers to a cast-time target set, so each repeat
iteration re-blinks the same chosen creatures rather than re-choosing a fresh
"any number" per process (strict CR 608.2c). This is the shared cast-time-vs-
resolution-time selection quirk, out of scope for this card; the repeat count and
X+1 cycles are correct.

Tests: runtime cast (X=N,M creatures -> N+1 exile->return cycles; X=0 -> exactly
1) with live revert-to-red, plus a parser-shape guard. Parser-only.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(parser): reanimate self and a targeted graveyard card to the battlefield (CR 400.7)

"Return this card and target land card from your graveyard to the battlefield
tapped." (Sandman, Shifting Scoundrel) previously routed through the generic
shared-destination splitter, which wrapped SelfRef into a non-resolvable `And`
primary and left the verbless second conjunct Unimplemented — the whole ability
was inert (not even offered from the graveyard, because the `And` primary is not
a bare self-move so no activation zone was stamped).

Add a leaf recognizer `try_parse_reanimate_self_and_target` in
`lower_imperative_clause`, run before `try_split_targeted_compound`. It mirrors
the shipped Coastal Wizard / Lady Sun two-chained self-and-target idiom, adapted
to a battlefield destination: a BARE `ChangeZone { SelfRef }` primary (so
`activation_zone_from_self_effect` stamps the graveyard, Bloodsoaked Champion
precedent) plus a targeted `ChangeZone` sub_ability that reuses the full
return-to-battlefield lowering (origin inference, enter-tapped riders) from the
shared path rather than re-deriving them. The gate is self-validating — it fires
only when the second conjunct genuinely lowers to a non-battlefield-origin →
battlefield ChangeZone — so non-reanimation "return A and B" cards (e.g. Coastal
Wizard's return-to-hand) fall through unchanged.

Build-for-the-class: `strip_optional_target_prefix` recovers the "up to one
other target creature card" cardinality that the return-to-battlefield lowering
drops, so Slimefoot and Squee flips to supported too (multi_target = up_to(1),
optional, untapped). Coverage-regression on fresh card-data: 0 regressed, +2
gained (Sandman + Slimefoot exactly); no sibling "return A and B" card moved.

CR 400.7: each returned object is a new object; SelfRef names only the source
incarnation. CR 608.2c: the two chained moves resolve in written order.
CR 601.2c + CR 115.1: the graveyard card is a chosen target; SelfRef is not.
CR 113.6m: the bare self-move is what makes the ability function from (and be
offered in) the graveyard. CR 614.1: "to the battlefield tapped" enters both
objects tapped.

Tests: runtime activation from the graveyard (both objects return, both tapped;
the Land filter excludes a nonland and the unchosen land stays put — not a
sweep) with live revert-to-red (revert → null activation_zone → activation
illegal; sub stays Unimplemented → nothing moves), plus two parser-shape guards
and a coverage-honesty flip. Parser-only; no engine, no new variant.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(parser): disjunctive "first-of-type spell this turn" intervening-if (CR 603.4)

Alania, Divergent Storm's trigger — "Whenever you cast a spell, if it's the first
instant spell, the first sorcery spell, or the first Otter spell other than Alania
you've cast this turn, you may have target opponent draw a card." — left the whole
intervening-if plus the draw as an Unimplemented node. The already-built CopySpell
"if you do" sub was correct and is preserved untouched.

Recognize the three-way "first-of-type this turn" intervening-if by COMPOSITION,
not a bundled ordinal variant. Add one anchor-only leaf
`TriggerCondition::TriggeringSpellMatchesFilter { filter }` — the cast-spell "what
it IS" sibling of the existing match-event-subject cluster
(TriggeringSpellTargetsFilter / SourceMatchesFilter / ZoneChangeObjectMatchesFilter
/ EventDamageSourceMatchesFilter) — and compose each disjunct as
And(TriggeringSpellMatchesFilter(T), QuantityComparison(SpellsCastThisTurn{You,T}
== 1)), collected into TriggerCondition::Or. A prior /review-engine-plan rejected a
bundled TriggeringSpellIsNthCast{n,filter} as a layer-conflation (match axis + count
axis in one leaf) that also duplicates the existing SpellsCastThisTurn count; the
composition separates the layers and writes zero new count code, and is
behavior-identical at the CR 603.4 re-check.

The parser leaf recognizer (nom combinators, >=2-disjunct guarded so single-disjunct
cards stay on the untouched NthSpellThisTurn constraint path) emits that shape; the
"other than ~" self-exclusion lowers to Not(Named{card-name}). Build-for-the-class:
the anchor variant also unlocks the plain "if it's an instant spell" intervening-if.

Also fix a latent bug this exposed: spell_record_matches_filter dropped
TargetFilter::Named to the catch-all false, so Not(Named{X}) over spell history was
always true — silently no-opping any name self-exclusion (Alania's "other than ~").
Add the Named arm (record.name == name). Measured: zero existing cards fed a
top-level Named filter into a spell-record position, so this is a pure fix
(coverage-regression on fresh card-data: 0 regressed, +1 gained = Alania only; the
>=2-disjunct guard protected Vengevine and the 108 NthSpellThisTurn constraint cards).

CR 603.4: the intervening-if is checked at trigger time AND resolution — the count is
read live, so a second matching spell cast in response correctly fizzles it. CR
601.2a: the anchor keys on the SpellCast event's spell object. CR 201.2: card name
for the self-exclusion.

Tests: 6 runtime cast-pipeline tests (first-instant fires + copies; second-instant
same turn does not; first-sorcery fires; Otter self-exclusion by name; the CR 603.4
in-response fizzle; optional-draw decline skips the copy) each with live
revert-to-red, plus a parser-shape guard and a Vengevine constraint-path regression
anchor.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): Behold a [quality] as an interactive triggered-ability effect (CR 701.4a)

Sarkhan, Dragon Ascendant's ETB "you may behold a Dragon. If you do, create a
Treasure token." previously left an Unimplemented{behold} node — behold existed
only as a casting cost (AbilityCost::Behold), never as an effect. The already-
parsed Treasure sub (gated on OptionalEffectPerformed) and the second trigger
(Dragon-enters → +1/+1 + becomes-Dragon-with-flying) are preserved untouched.

Add Effect::Behold { filter }, an interactive resolution-time keyword action per
CR 701.4a ("Reveal a [quality] card from your hand or choose a [quality]
permanent you control on the battlefield"). The resolver (game/effects/behold.rs)
reuses the single candidate authority eligible_behold_choices (battlefield-you-
control ∪ matching hand) and a shared reveal_if_from_hand helper:
- 0 candidates: whiff — set cost_payment_failed_flag, stash no continuation, so
  the "if you do" rider reads performed && !flag = false (no Treasure).
- 1 candidate: forced, no agency — auto-select; a hand card emits CardsRevealed
  (card stays in hand), a permanent reveals nothing.
- >=2 candidates: a genuine, rules-visible choice (CR 608.2d) — park a new
  WaitingFor::BeholdChoice for the controller; the submit handler validates the
  chosen object is in the candidate set, reveals-if-hand, sets the rider
  performed, and drains.

A prior /review-engine-plan rejected an auto-resolve design: CR 701.4a grants the
player the choice, and a hand reveal is a public event, so auto-picking among two
or more distinct hand Dragons is an engine-made choice with rules-visible
consequences — pillar #1 (rules-correct over convenient).

Hidden-information correctness (CR 400.2): the BeholdChoice candidate list spans a
hidden zone (hand), so visibility.rs redacts the pre-choice candidates to an
opaque sentinel for opponents — otherwise the controller's matching hand cards
would leak before they choose. The post-choice reveal exposes only the chosen
card.

Behold has no stack target (chosen at resolution, not declared): target_filter()
is None, in the mass-effect group beside Clash/Populate.

The frontend BeholdChoiceModal is display-only — it renders the engine-provided
candidate list and dispatches a single-object SelectCards; it derives no
eligibility. i18n reuses the existing cardChoice.behold.* keys (present in all
seven locales from the cost-side UI) — zero new keys.

#5051 (CR 400.7): this fixed-quality behold writes no ChosenAttribute, so the
stale-chosen-type bug cannot manifest here; a code + test pin flags the future
type_choice axis for the fix sweep.

Tests: 7 runtime cast-pipeline tests (board-Dragon behold; hand-Dragon reveal
stays in hand; >=2-hand-Dragon interactive prompt with only-chosen-revealed;
opponent-view candidate redaction; accept-with-no-Dragon whiff; decline; parser
shape) each with revert-to-red. Coverage-regression on fresh card-data: 0
regressed, +1 gained (Sarkhan).

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): delayed sacrifice "at the end step on your next turn" (CR 513.2)

Kav Landseeker's "When this creature enters, create a Lander token. At the
beginning of the end step on your next turn, sacrifice that token." previously
left the delayed clause an Unimplemented{at} node — the parser had no temporal
arm for "the end step on your next turn", and the nearest existing condition
(AtNextPhaseForPlayer, "your next end step") fires the CURRENT turn's end step
(CR 513.2: a next-end-step delayed trigger created outside the end step is not
backed up), which would sacrifice the Lander before it could be used.

Add a turn-floor to AtNextPhaseForPlayer via a new typed enum
TurnGate { None, AfterCreationTurn, After(u32) } (not a bool, not a magic
sentinel): the parser emits the symbolic AfterCreationTurn, and
effects::delayed_trigger::resolve stamps it to After(creation_turn) at creation
(CR 603.7a) — the single-path binding site that already rewrites the PlayerId(0)
controller placeholder. The matcher then skips every matching phase up to and
including the floor turn and fires on the first strictly-later controller turn
(CR 500.7 extra turns included). An unstamped AfterCreationTurn reaching the
matcher debug_asserts and falls through to fire this turn (a loud, test-caught
wrong-timing signal) rather than silently never firing.

Existing AtNextPhaseForPlayer users (Greasefang, rebound, epic) default
gate: None and are byte-identical — None is skip_serializing_if, so only Kav
serializes a gate ("AfterCreationTurn", a pure semantic, no number). "That
token" reuses the existing TargetFilter::LastCreated snapshot-at-creation
(specific-token-safe per CR 603.7c / 400.7); the Lander predefined token and the
parsed Lander creation are untouched.

Tests: paired-timing runtime (Lander survives the current end step, is
sacrificed at the controller's next end step) with a resolved-stack reach-guard,
specific-token (a bystander token survives), a resolve-time stamp unit test, a
parser-shape guard, and a Greasefang non-perturbation guard — each with
revert-to-red. Coverage-regression on fresh card-data: 0 regressed, +1 gained
(Kav). Singleton delayed-trigger test scope is deliberate (see #5072).

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): Crowd-Control Warden dual enters/turned-face-up counter replacement + suppress own as-enters on face-down entry (CR 708.2a)

Crowd-Control Warden ("As this creature enters or is turned face up, put X +1/+1
counters on it, where X is the number of other creatures you control. Disguise …")
previously left the replacement line an Unimplemented node — the replacement
dispatcher matched the "As ~ enters" pattern but no inner parser handled the dual
"enters OR is turned face up" condition + the "put … on it" clause surface.

PARSER: recognize the self-and-face-up counters replacement (a new leaf
`lower_as_enters_or_face_up_counters`, wired at the Priority-8 replacement slot).
It emits two ReplacementDefinitions — one `Moved` (enter-the-battlefield) and one
`TurnFaceUp` — sharing one `PutCounter{P1P1, ObjectCount(other creatures you
control), SelfRef}` execute (CR 613.2a: both conditions generate the same effect).
The dynamic-count "on it" anaphor lowers to ParentTarget; the recognizer normalizes
it to SelfRef (matching the directly-built enters-with-counters shape) so the
runtime folds it as an ETB event modifier. A guard requires every execute effect to
be PutCounter{SelfRef}, so sibling "As ~ enters, choose…/becomes…" lines fall
through untouched. The dynamic count + turned-face-up runtime were already fully
supported (extract_etb_counters, turn_face_up_applier) — parser-only for the card.
Coverage-regression on fresh card-data: 0 regressed, +1 gained (Crowd-Control
Warden), blast radius exactly 1 card.

RUNTIME (the parser fix exposed a class bug): playing the Warden face-down via
Disguise wrongly gained the +1/+1 counters. CR 708.2a: a permanent that enters
face down is a 2/2 with no text, so its OWN "As ~ enters" replacement must not apply
on a face-down entry. Add a guard in object_replacement_candidate_applies:
`if is_entering && ZoneChange{face_down_profile: Some} return false` — is_entering
is true only when the candidate's source IS the entering object (its own text), so
EXTERNAL replacements (another permanent's enters-tapped) still apply. Masked until
now: existing morph/disguise cards with an own "enters with N counters" use an X
count (=0 face-down); the Warden's ObjectCount is the first nonzero one to expose
it. CR 708.3: the permanent is turned face down before it enters.

A pre-existing change_zone test (paused_face_down_change_zone_resumes_face_down_with_profile)
forced its replacement pause via the entrant's OWN SelfRef shock replacement on a
face-down entry — inadvertently asserting the masked bug. Rework it (test-only) to
force the pause via EXTERNAL replacements (mirroring the morph sibling), preserving
the load-bearing seam assertion (the PendingChangeZoneIteration carrier preserves
the face-down profile through pause/resume).

Tests: parser-shape (dual replacement, zero Unimplemented) + as-enters-choose
reach-guard; runtime face-down NEG discriminator (0 counters, reds to 2 on guard
revert) + hard-cast POS + face-down-then-turn-up; a no-over-suppression external
test + Hooded Hydra masked-class regression guards — each labeled by its
discriminating role. Full test-engine: 17263 passed.

Assisted-by: ClaudeCode:claude-opus-4.8

* chore(engine): rebase adaptation — classify S25 tranche variants into the #5072 walker

The #5072 PR-6.75 read/write conflict profiler (ability_rw.rs) is exhaustive over
Effect / TriggerCondition / TargetFilter / ControllerRef with no wildcards. This
tranche, written before #5072 merged, added new variants/fields the walker had no
arms for; rebasing onto main surfaced 13 compile errors (+ a latent Effect::Behold
masked by same-match E0027s). Classify each with its read/write profile and the
three ordering axes (reads_member_bound / reads_event_live / writes_event_object),
CR-grounded and mirroring the closest existing sibling:

- TargetFilter::GrantingObject (CR 201.5a): mirrors SpecificObject on the base
  axes; reads_member_bound = true (fail-closed R3 divergence). Parse-template-only —
  grant-clone concretizes it to SpecificObject{granter} (ability_utils.rs), so the
  arm is inert at runtime and classified conservatively.
- ControllerRef::TargetOpponent (CR 109.4): mirrors TargetPlayer (runtime-read-
  identical) — all axes empty (declared-target read, member-invariant).
- TriggerCondition::TriggeringSpellMatchesFilter (CR 601.2a/603.4): mirrors
  TriggeringSpellTargetsFilter — reads_event_live.
- Effect::Behold (CR 701.4a/608.2d): mirrors Reveal — a pure information event, no
  write; its resolution-time choice is classified in ability_scan (MayPrompt).
- Effect::Cloak.object_source (CR 701.58a), Effect::Dig.keep_count_expr
  (CR 701.20e/608.2c), Effect::GainActivatedAbilitiesOfTarget.scope (CR 602.1): new
  fields bound + profiled like their sibling fields.
TurnGate.gate (a match-time firing gate, not traversed by ability_rw) and
Effect::ControlNextTurn's ControlWindow (absorbed by the maximal-conservative arm)
need no binding.

Also adapts one lib test to a main API delta: #5072-era main replaced
Effect::DealDamage's former `excess_only: bool` with a typed `excess` field, so a
DealDamage constructor in copy_spell.rs's tests gains `excess: None` to match.

One tail adaptation commit rather than folding into the owning card commits: unlike
#5072's own case, the walker lives in the rebase BASE here, so folding WOULD restore
per-commit compiles — but a six-way fold across the tranche is error-prone; the arms
are grouped here and can be autosquashed at ship-prep if the final PR wants per-commit
bisectability. cargo check --workspace is clean (engine + phase-ai + mtgish-import +
server-core + wasm/server + test targets), zero downstream dual-walk sites, and the
full engine test suite passes (17549 tests, 0 failures) — no HIGH-2 / C2-ungating trip
in the tranche's tests (Kav's delayed-trigger tests were already singleton-scoped).

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): Rhys, the Evermore — interactive "remove any number of counters"

Implements Rhys's "{W},{T}: Remove any number of counters from target creature
you control" as a resolution-time interactive choice (CR 107.1c: "any number"
includes zero; CR 608.2d: the controller chooses at resolution).

Parser: a new "any number of" arm lowers the count to QuantityExpr::UpTo
(Fixed{-1}), reusing the existing UpTo slot — no new variant, so the #5072
read/write dual-walk needs no arms. A CR 608.2d "from among" guard keeps
out-of-scope multi-source removals (Galloping Lizrog, Eventide's Shadow) as
Unimplemented rather than collapsing them to single-source semantics.

Effect path: resolve_remove peels the UpTo flag and raises
WaitingFor::RemoveCountersChoice carrying the target's live per-type counter
counts; the controller's GameAction::ChooseCountersToRemove is validated by a
validate_counter_selection helper shared with the cost path and applied through
the CR 614.1 remove_counter_with_replacement pipeline via a pending_counter_removals
queue. The queue re-parks on replacement choices and, on drain, stamps
last_effect_count before the continuation drains, so "create that many" reflexive
clauses (Tetravus) read the true removed total (CR 608.2h).

Multiplayer: accepts_freeform_counter_removal + a session skip-legality arm let a
human submit an intermediate per-type selection the coarse AI candidate set does
not enumerate; the payload guard bounds the selection list.

Coverage: GAINED = exactly {Rhys}. Tetravus's remove-counters trigger and token
creation now resolve (proven: 3 counters -> 3 Tetravite tokens), but the card
stays unsupported on an unrelated keyword-grant clause nested in that trigger.
Cost-path storage lands / batteries and the multi-source cards are unchanged.

The plan reviewer's re-review request was folded into /review-impl with a mandate
to re-verify all six must-change resolutions at code level plus the four
non-compile-enforced registration points; verdict APPROVE.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): Esper Terra — "up to N" lore, conjunctive mana, token-anaphor counter binding

Fixes the two parser-leaf gaps on Esper Terra (Terra, Magical Adept // Esper
Terra) plus the shared binding that makes its marquee chapter effect land on the
right object. No new engine variant (reuses QuantityExpr::UpTo, PutCounter,
ManaProduction::Fixed, TargetFilter::LastCreated) — the #5072 read/write dual-walk
needs no arms.

Changes (all parser):
- Gap A (counter.rs): "put up to three lore counters on it" — strip the "up to "
  count marker and wrap the count in QuantityExpr::up_to (CR 608.2d + CR 122.1),
  mirroring the Draw/Discard/PutSticker convention. Count-side only; the target-side
  "on up to N target(s)" path is untouched.
- Gap B (mana.rs): "Add {W}{W}, {U}{U}, {B}{B}, {R}{R}, and {G}{G}" — a conjunctive
  comma+"and" fixed-mana-group accumulator (parse_fixed_mana_group_list) →
  ManaProduction::Fixed (CR 106.4). Rejects "or" lists, requires >=2 groups, no dedup.
- §B2 token-anaphor bind (context.rs + mod.rs + counter.rs): a bare "put ... counters
  on it" following a token creator binds to the created token, not the ability source
  (CR 608.2c). A ParseContext.token_created_in_chain signal (set from the most-recent
  prior referent, cleared by an intervening typed target) drives the it-pronoun counter
  branch to TargetFilter::LastCreated. A companion LastCreated guard on
  replace_target_with_parent's counter arm (mod.rs) preserves that bind through the
  lowering pass — the guard its sibling Attach/UnattachAll arms already carried.

The "if it's a Saga" gate needs NO code: it parses to TargetMatchesFilter{Saga}
reading ability.targets.first() (the copied enchantment, CR 707.2 type-equal to the
token), so a non-Saga copy places zero lore counters by construction.

Coverage (full-DB regression): REGRESSED(engine)=0. GAINED=1 (Esper Terra). Eight
counter-target fingerprint changes, all correct:
  - 4 anaphor target corrections (SelfRef→LastCreated): Esper Terra (x3 chapters, the
    +1 GAINED); applied geometry; the bus runner (first put; card stays unsupported on
    a separate gap); journey to the lost city.
  - 4 Gap-A parser wins (target unchanged SelfRef, a new "up to X" node parses):
    clockwork avian/beast/steed/swarm (stay unsupported on the counter-cap gap).
  - 9 genuine-source cards ("...on this creature/enchantment/<name>") byte-identical.

Measured blast radius (replaces the plan's 6/7 prediction): 3 anaphor cards corrected
(applied geometry, the bus runner, journey to the lost city) / 5 pre-existing-unchanged
/ 9 genuine-source preserved. The 5 unchanged cards (alien invasion, intrude on the
mind, lasting fayth, match the odds, kianne) put counters via a dynamic "for each"-count
PutCounter on a separate parser path that never reaches the it-branch — they stay
SelfRef (misbound to source), byte-identical to baseline. That path is a pre-existing,
LIVE rules-wrong gap this change does not touch; deferred and logged
(.planning/coverage-analysis/S25-DEFERRALS.md D1).

Ship-prep note: journey to the lost city appears in #5072's R3 member-bound
classification; its PutCounter target change alters its ability AST, so its
reads_member_bound rw-profile — and thus se_member_bound_class — may move +/-1 at the
next full-DB sweep. Attributed to this commit (expected movement, not a regression).

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): Foraging Wickermaw — "this creature becomes that color"

Closes the last gap on Foraging Wickermaw: "{1}: Add one mana of any color.
This creature becomes that color until end of turn." reuses the existing
chosen-color carrier — no new engine variant (empty types/ diff).

- Parser (oracle_effect/subject.rs): fold "that color" into the existing
  AddChosenColor arm of try_parse_become_color_modification, beside "the chosen
  color". "That color" is the color of the mana produced this activation — a
  resolution-time anaphor, not a value baked at parse (CR 106.1a/202.2/105.3).
- Runtime (game/mana_abilities.rs): record the produced color as
  ChosenAttribute::Color on the source in produce_mana_from_ability, so the
  Layer-5 AddChosenColor reader (already powering Puca's Eye) sets the creature's
  color (CR 613.1e). Reuses the existing mana_sources::mana_type_to_color
  (Colorless→None) — no new converter, no types/mana.rs edit.

Two safety properties, both proven by revert-to-red:
- Gated write, zero blast radius. produce_mana_from_ability is the universal
  mana chokepoint, so the write fires only when the resolving ability's own chain
  carries a downstream AddChosenColor (via visit_links_any on the activated
  ability_def, fresh per activation). Coverage-regression confirms exactly one
  card flips (Foraging Wickermaw) and every ordinary producer — Birds of Paradise,
  City of Brass, painland, filter land, basic, rock — is byte-identical.
- Explicit replace, not accumulate (CR 400.7). The stored ChosenAttribute::Color
  PERSISTS across turns (cleared only on zone change), UEOT expiry removes the
  Layer-5 effect but NOT the stored attribute, and the choice binder accumulates
  rather than replaces — so a plain push would leave a stale prior-turn color
  first in the list (chosen_color() is first-match). The write retain-drops the
  prior Color before pushing, so re-activating on a later turn with a new color
  replaces cleanly.

The Colorless→White fallback converter (mana_payment.rs) is left untouched: it is
load-bearing-defensive for the three Phyrexian-shard sites, where CR 107.4a makes
Colorless unreachable.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): The Tomb of Aclazotz — cast-from-graveyard type-grant rider

Closes the last gap on The Tomb of Aclazotz's "{T}: You may cast a creature
spell from your graveyard this turn. If you do, it enters with a finality
counter on it and is a Vampire in addition to its other types." The finality
counter half was already wired via the ExileWithAltCost permission channel; this
adds the parallel channel for the "is a Vampire in addition to its other types"
type grant.

- New Effect::AddPendingEntersModifications { modifications: Vec<ContinuousModification> }
  — the general "enters-with continuous modifications" carrier (CR 613 layer
  system), a categorical sibling of AddPendingETBCounters (CR 122 physical
  counters). The Vec<ContinuousModification> shape absorbs every future
  enters-with rider (added types, granted abilities, colors), so no third sibling
  is needed. The two channels stay separate because a counter is not a continuous
  modification.
- Parser (oracle_effect/mod.rs): try_parse_cast_this_way_enters_rider lifts the
  former trailing-tail rejection (CR 205.1b) to accept "… and is a <type> in
  addition to its other types", parsing the tail via
  animation::parse_becomes_type_modifications (additive AddType/AddSubtype/
  AddSupertype — retains printed types, CR 205.1b) and emitting the new effect as
  the counter rider's sub-ability.
- Permission channel (mirrors enters_with_counter exactly): a new
  ExileWithAltCost.enters_with_modifications field (serde default + skip-if-empty),
  lifted from the rider at grant time and read back from the SELECTED permission at
  cast-finalize (CR 611.2c — no sibling-permission leak), applied to the cast
  object as a Duration::Permanent continuous effect (Layer 4, CR 613.1d).
- #5072 read/write dual-walk: AddPendingEntersModifications is classified as a
  self-scoped SetMembership write (mirroring Effect::Animate/Endure's type-line
  writes) — NOT an ObjectCounters write; …
andriypolanski pushed a commit to andriypolanski/phase that referenced this pull request Jul 11, 2026
…ng block already handles it (test-only) (phase-rs#5547)

* test(engine): prove Land's Edge already parses and resolves correctly

Land's Edge ("Discard a card: If the discarded card was a land card,
this enchantment deals 2 damage to target player or planeswalker. Any
player may activate this ability.") is listed in the parser-misparse
backlog's root cause #1 (dropped relative-clause/filter restriction).

Investigation found this is a stale backlog entry, not a live bug: the
existing AbilityCondition::CostPaidObjectMatchesFilter building block
(added 2026-05-04, previously only exercised in ConditionInstead-wrapped
form by Agency Coroner/Surtland Flinger/Stormscale Anarch/Grab the Prize)
already correctly handles this card's bare (non-instead) composition --
condition extraction, chunk-boundary protection for the leading "if",
runtime evaluation via the cost-paid discard's LKI snapshot, and a
separate PlayerFilter::All "any player may activate" mechanism. Zero
production code required.

Adds 2 parser unit tests locking the full parse shape (CR 602.1 +
602.1a + 602.2 + 118.1 + 608.2c + 608.2k + 400.7j) and 5 GameRunner
integration tests proving the runtime behavior: discarding a land deals
2 damage, discarding a nonland deals 0 (ability still resolves), the
discard snapshot binds the specifically-chosen object (not any land in
hand), a non-controller can activate and pays from their own hand
(CR 602.1a), and a sibling without the "any player" clause rejects
non-controller activation (CR 117.3d priority-gate correctly enforced).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMa6DrxXyFHBdGxz3uLgvM

* docs: remove Land's Edge from parser-misparse-backlog

Confirmed fixed (test-only, no production change) by the preceding
commit. Root cause phase-rs#2 (dropped intervening-if): 606 -> 605 cards;
totals rebased to 4760 distinct / 4794 total appearances.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMa6DrxXyFHBdGxz3uLgvM

* test(engine): reduce Land's Edge PR to a single discriminating parser test

Addresses matthewevans' review on phase-rs#5547: the CostPaidObjectMatchesFilter
building block this PR exercises already has runtime coverage via four
sibling integration tests (Agency Coroner, Surtland Flinger, Stormscale
Anarch, Grab the Prize). Adding a fifth card's standalone 427-line
integration test file for the same already-covered condition was
duplicative coverage with real suite-runtime/maintenance cost and
near-zero marginal risk reduction.

The one genuinely new thing Land's Edge's shape exercises is a
parser-level distinction: a BARE (non-instead) composition of
CostPaidObjectMatchesFilter, versus the ConditionInstead-wrapped form
all four existing sibling cards use. At runtime this is not actually a
new code path -- evaluate_condition's CostPaidObjectMatchesFilter arm
already fires for any ability.condition, ConditionInstead or not -- so
the distinction is provable at the parser level alone.

Removed:
- crates/engine/tests/integration/lands_edge_discard_land_condition.rs
  (427 lines, 5 tests) and its main.rs mod line
- lands_edge_without_any_player_clause_has_no_activator_filter (a
  second parser test exercising the separately-established
  PlayerFilter::All mechanism, not the bare-condition distinction)

Kept: lands_edge_discard_land_condition_parses_as_bare_intervening_if,
a single parser unit test that locks the full bare parse shape (cost,
condition explicitly NOT ConditionInstead, effect, activator_filter) in
one assertion block -- the "single discriminating assertion" the review
asked for.

Verification note: could not get a fresh cargo test run to complete
locally after this reduction -- 5 consecutive attempts were killed
mid-compile (a background-build contention issue this fork's ~10
concurrent worktrees have hit repeatedly; the shared WORKLIST.md
cargo-lock was held by another agent throughout). This change is a pure
deletion (no logic modified) of an already-green test suite; the one
retained test is byte-identical to its previously-verified-passing
form. CI will provide the authoritative signal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMa6DrxXyFHBdGxz3uLgvM

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
lgray added a commit to lgray/phase that referenced this pull request Jul 12, 2026
… loop-cover predicate

Cover the infinite charge-counter-growth proliferate loop (Pentad Prism +
Kilo/Freed/Relic) and offer the CR 732.2a resource shortcut. New sibling predicate
`loop_states_cover_modulo_counter_growth` (analysis/resource.rs): strict-growth-only
over the PRESERVED `Generic` object-counter axis, fail-closed.

- `CounterGrowthDisposition {StrictGrowth, Stable, Consumed}` (typed, not bool).
  `classify_generic_counter_growth` is a wildcard-free `CounterType` match — the
  per-type classification table itself (a new variant won't compile until
  classified), kept in lockstep with `is_monotone_loop_resource`. `Consumed` (any
  `Generic` counter fell) takes precedence over `StrictGrowth` (mixed grow+consume
  rejects) — the infinite-consume soundness trap.
- `equalize_generic_counters` overwrites only `Generic` counts with `prior`'s, then
  rides the existing `loop_states_equal_modulo_resources`, so any non-`Generic`
  drift still rejects via the untouched equality.

Wired at exactly two non-GameOver seams (the firewall): `detect_loop` gate-1
(offline `Advantage` certification) and `interactive_loop_bridge` Path-C (live
revocable-unbounded capability mark). Deliberately NOT wired into
`live_mandatory_loop_winner` (GameOver-capable) — a conservative fail-closed
boundary. A charge/burden growth loop classifies `WinKind::Advantage` (CR 104.4b:
an optional loop is not a draw), so an over-claim is a declinable offer / revocable
mark, never a wrongful game-end — the revocability soundness bound (the equalize
step introduces a new projected `Generic`-counter axis, sound by this bound, not by
firewall parity).

GENERAL over preserved-`Generic` growth: Pentad Prism (charge) and The One Ring
(burden) are the SAME cover, so One-Ring's growth cover is discharged here — its
later pass is offer-layer + card-verify only.

Two-path coverage (matches the existing architecture): the real proliferate loop is
offline-certified (`drive_offline_pentad_prism`, real Kilo/Freed/Relic + Pentad
seeded >=1 charge via Sunburst, CR 702.44a); the live Path-C disjunct is exercised
by a sampler-visible self-refilling charge-growth trigger — the live equality
sampler records only non-shrinking-stack cascades, and a `ProliferateChoice` beat
hits the pre-existing ring-clear arm.

8 discriminating tests (4 unit + phase-rs#5/phase-rs#8 offline + phase-rs#6/phase-rs#7 live), all non-vacuous: the
consume control (phase-rs#2) is a same-`Generic("charge")` decrease that flips only under a
direction-blind revert; phase-rs#8 asserts `Some(Advantage, Counter(Other,Other))`; phase-rs#6
marks the counter axis without any GameOver; phase-rs#7 proves phase-rs#4603-OFF byte-identity.

Verify: clippy -p engine --all-targets 0/0; analysis lib 173 + loop_shortcut 28 +
the 8 new tests green. Plan-reviewed + impl-reviewed clean.

Assisted-by: ClaudeCode:claude-opus-4.8
lgray added a commit to lgray/phase that referenced this pull request Jul 12, 2026
… loop-cover predicate

Cover the infinite charge-counter-growth proliferate loop (Pentad Prism +
Kilo/Freed/Relic) and offer the CR 732.2a resource shortcut. New sibling predicate
`loop_states_cover_modulo_counter_growth` (analysis/resource.rs): strict-growth-only
over the PRESERVED `Generic` object-counter axis, fail-closed.

- `CounterGrowthDisposition {StrictGrowth, Stable, Consumed}` (typed, not bool).
  `classify_generic_counter_growth` is a wildcard-free `CounterType` match — the
  per-type classification table itself (a new variant won't compile until
  classified), kept in lockstep with `is_monotone_loop_resource`. `Consumed` (any
  `Generic` counter fell) takes precedence over `StrictGrowth` (mixed grow+consume
  rejects) — the infinite-consume soundness trap.
- `equalize_generic_counters` overwrites only `Generic` counts with `prior`'s, then
  rides the existing `loop_states_equal_modulo_resources`, so any non-`Generic`
  drift still rejects via the untouched equality.

Wired at exactly two non-GameOver seams (the firewall): `detect_loop` gate-1
(offline `Advantage` certification) and `interactive_loop_bridge` Path-C (live
revocable-unbounded capability mark). Deliberately NOT wired into
`live_mandatory_loop_winner` (GameOver-capable) — a conservative fail-closed
boundary. A charge/burden growth loop classifies `WinKind::Advantage` (CR 104.4b:
an optional loop is not a draw), so an over-claim is a declinable offer / revocable
mark, never a wrongful game-end — the revocability soundness bound (the equalize
step introduces a new projected `Generic`-counter axis, sound by this bound, not by
firewall parity).

GENERAL over preserved-`Generic` growth: Pentad Prism (charge) and The One Ring
(burden) are the SAME cover, so One-Ring's growth cover is discharged here — its
later pass is offer-layer + card-verify only.

Two-path coverage (matches the existing architecture): the real proliferate loop is
offline-certified (`drive_offline_pentad_prism`, real Kilo/Freed/Relic + Pentad
seeded >=1 charge via Sunburst, CR 702.44a); the live Path-C disjunct is exercised
by a sampler-visible self-refilling charge-growth trigger — the live equality
sampler records only non-shrinking-stack cascades, and a `ProliferateChoice` beat
hits the pre-existing ring-clear arm.

8 discriminating tests (4 unit + phase-rs#5/phase-rs#8 offline + phase-rs#6/phase-rs#7 live), all non-vacuous: the
consume control (phase-rs#2) is a same-`Generic("charge")` decrease that flips only under a
direction-blind revert; phase-rs#8 asserts `Some(Advantage, Counter(Other,Other))`; phase-rs#6
marks the counter axis without any GameOver; phase-rs#7 proves phase-rs#4603-OFF byte-identity.

Verify: clippy -p engine --all-targets 0/0; analysis lib 173 + loop_shortcut 28 +
the 8 new tests green. Plan-reviewed + impl-reviewed clean.

Assisted-by: ClaudeCode:claude-opus-4.8
lgray added a commit to lgray/phase that referenced this pull request Jul 12, 2026
… loop-cover predicate

Cover the infinite charge-counter-growth proliferate loop (Pentad Prism +
Kilo/Freed/Relic) and offer the CR 732.2a resource shortcut. New sibling predicate
`loop_states_cover_modulo_counter_growth` (analysis/resource.rs): strict-growth-only
over the PRESERVED `Generic` object-counter axis, fail-closed.

- `CounterGrowthDisposition {StrictGrowth, Stable, Consumed}` (typed, not bool).
  `classify_generic_counter_growth` is a wildcard-free `CounterType` match — the
  per-type classification table itself (a new variant won't compile until
  classified), kept in lockstep with `is_monotone_loop_resource`. `Consumed` (any
  `Generic` counter fell) takes precedence over `StrictGrowth` (mixed grow+consume
  rejects) — the infinite-consume soundness trap.
- `equalize_generic_counters` overwrites only `Generic` counts with `prior`'s, then
  rides the existing `loop_states_equal_modulo_resources`, so any non-`Generic`
  drift still rejects via the untouched equality.

Wired at exactly two non-GameOver seams (the firewall): `detect_loop` gate-1
(offline `Advantage` certification) and `interactive_loop_bridge` Path-C (live
revocable-unbounded capability mark). Deliberately NOT wired into
`live_mandatory_loop_winner` (GameOver-capable) — a conservative fail-closed
boundary. A charge/burden growth loop classifies `WinKind::Advantage` (CR 104.4b:
an optional loop is not a draw), so an over-claim is a declinable offer / revocable
mark, never a wrongful game-end — the revocability soundness bound (the equalize
step introduces a new projected `Generic`-counter axis, sound by this bound, not by
firewall parity).

GENERAL over preserved-`Generic` growth: Pentad Prism (charge) and The One Ring
(burden) are the SAME cover, so One-Ring's growth cover is discharged here — its
later pass is offer-layer + card-verify only.

Two-path coverage (matches the existing architecture): the real proliferate loop is
offline-certified (`drive_offline_pentad_prism`, real Kilo/Freed/Relic + Pentad
seeded >=1 charge via Sunburst, CR 702.44a); the live Path-C disjunct is exercised
by a sampler-visible self-refilling charge-growth trigger — the live equality
sampler records only non-shrinking-stack cascades, and a `ProliferateChoice` beat
hits the pre-existing ring-clear arm.

8 discriminating tests (4 unit + phase-rs#5/phase-rs#8 offline + phase-rs#6/phase-rs#7 live), all non-vacuous: the
consume control (phase-rs#2) is a same-`Generic("charge")` decrease that flips only under a
direction-blind revert; phase-rs#8 asserts `Some(Advantage, Counter(Other,Other))`; phase-rs#6
marks the counter axis without any GameOver; phase-rs#7 proves phase-rs#4603-OFF byte-identity.

Verify: clippy -p engine --all-targets 0/0; analysis lib 173 + loop_shortcut 28 +
the 8 new tests green. Plan-reviewed + impl-reviewed clean.

Assisted-by: ClaudeCode:claude-opus-4.8
matthewevans added a commit that referenced this pull request Jul 12, 2026
…ow + combo-declaration UI (CR 732.2a–c) (#5672)

* feat(engine): DecisionTemplate replay + residual board delta (PR-7 phase 1)

Phase 1 (foundations) of combo-detector PR-7: pure/offline analysis-layer primitives, zero live engine wiring, no gate yet.

B1: DecisionTemplate {owner, decisions, replay} + resolve() replay engine + predictability_gate() (CR 732.2a firewall). DecisionSource reuses YieldTarget (CR 117.3d provenance + CR 400.7 identity). ReplayFailure is selected per pin kind: absent Order source -> MissingSource (CR 400.7); illegal/absent target -> IllegalTarget (CR 608.2b). IterationCount::Fixed only; TargetSchedule = {Constant, RoundRobin, Piecewise}.

B4: BoardDelta/ResidualPermanent (CR 110.1) + pure board_delta() producer + structurally-empty LoopCertificate.residual_board_delta. The field is empty by construction for every certificate detect_loop can currently produce (loop_states_equal_modulo_resources requires an identical battlefield); board_delta() is the single population seam that lights up once a future object-growth detection path relaxes that gate -- not a silent stub.

Verification: cargo fmt; clippy --all-targets -D warnings (clean); cargo test -p engine (18543 passed, 0 failed); 12 discriminating inline unit tests. coverage/semantic-audit skipped -- PR-7 touches zero parser/card-data surface so they carry no signal; coverage runs once at final pre-push as a no-regression check.

Deferred: DecisionGroupKey/DecisionKind/key + Ord-on-newtypes -> Phase 2/B2 (first consumer); IndexedClass schedule -> Phase 4/B3 (needs live FilterContext); non-empty residual materialization + board_delta output-order determinism -> object-growth detection phase.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): trigger-order resolver + intra-batch re-prompt fix (PR-7 phase 2)

Phase 2 (B2) of combo-detector PR-7: one resolver at the begin_trigger_ordering
auto-order gate, two tiers over a shared DecisionTemplate, plus the deferred B1
key apparatus.

Ephemeral tier (CORRECTNESS FIX, CR 603.3b): a simultaneous trigger batch is
ordered ONCE. Previously, when a targeted trigger paused for target selection,
the already-ordered deferred tail lost its 'ordered' flag and
drain_deferred_trigger_queue_unchecked re-prompted after every target. Now
handle_order_triggers registers an ephemeral ThisObject-keyed coverage-only
template; the gate's 3rd arm verifies sub-multiset coverage and keeps the tail's
chosen order without re-prompting. Cleared at the batch resolution boundary.

Persistent tier (UX): opt-in save/reapply keyed by AllCopies/oracle identity, via
GameAction::SetTriggerOrderTemplate{Save,Remove,ClearAll} mirroring the merged
session bypass + payload guard + manabrew-compat + per-viewer redaction). Permutes
the fresh batch once, then registers an ephemeral marker so all parked-tail
re-drains are coverage-only for both tiers. Frontend list deferred to phase 5.

B1 key apparatus (deferred from phase 1, first consumer here): DecisionGroupKey/
DecisionKind + DecisionTemplate.key + Ord on ObjectId/CardId + decision_templates
Vec on GameState with get/set/remove/clear accessors. Excluded from
loop_fingerprint, kept in PartialEq (safe direction), per-viewer redacted in
filter_state_for_viewer.

Gate OFF byte-identical: empty decision_templates no-ops the 3rd arm; neither tier
rides LoopDetectionMode.

Verification: cargo fmt; clippy --all-targets -D warnings (clean); cargo test -p
engine (18597 passed, 0 failed, post-rebase); 8 discriminating tests incl. the
headline single-OrderTriggers-prompt on a paused multi-targeted batch.

FORGE ordering_parity_sweep (full-DB, corpus regenerated at the rebased tip): B2's
delta vs main is ZERO. The sweep's decision inputs -- profiles_conflict/
ability_rw_profile (ability_rw.rs), build_resolved_from_def (ability_utils.rs), and
the allowlist (triggers_ordering_parity_tests.rs) -- are byte-identical to main and
the full-DB path never calls the (unmodified-body) group_is_order_independent, so
the run equals main's own full-DB result. It reports 20 PRE-EXISTING over-prompt
divergences (the Urza's-era hidden/veiled/opal/lurking enchantment cycle) that
predate this change; all are the safe auto->prompt direction (zero under-prompts =>
zero CR 603.3b violations). Default CI runs the fixture path (unexplained=0). These
are a pre-existing full-DB-only allowlist gap, out of PR-7 scope, tracked as a
follow-up.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): live loop-shortcut protocol + APNAP response window (PR-7 phase 3)

Phase 3 (Part A) of combo-detector PR-7: at a CR 704.3 priority point an opt-in
LoopDetectionMode::Interactive routes a confirmed live loop certificate through a
shortcut-offer protocol instead of the pre-feature halt. Default (Off) and On stay
byte-identical (samples() == is_on() at both live gates; the On reconcile arm is
byte-verbatim inside the mode match).

Bridge (interactive_loop_bridge): on a determinate lethal single-winner drain,
mandatory loops auto-win (identical to the On path); optional loops OFFER
WaitingFor::LoopShortcut to the determinate winner (CR 732.2a), then an APNAP
WaitingFor::RespondToShortcut accept-or-shorten window over all living opponents
(CR 732.2b/c) reusing the OpponentMayChoice remaining_players drain-one-advance
fan-out. GameAction::DeclareShortcut{count,template} + RespondToShortcut{response}
drive it. CR 732.4 all-mandatory net-progress no-loss loops end in a draw
(CR 104.4b); any loss axis or optional loop falls through to the pre-feature halt.
Multiplayer (>=3p APNAP) from the start.

Winner authority: the crown is the sole non-faller from live_mandatory_loop_winner
(nonfallers.len() == 1 requires every OTHER living player to fall, CR 104.2a), not
the mechanical loop controller. Subset-lethal pods (an opponent survives) return
None and never crown; a >=2-faller simultaneity floor requires equal per-cycle
life deltas so all fallers cross lethal in one CR 704.3 SBA batch.

Soundness (CR 608.2b): the offer latches the determinate winner; the dispatch
firewall admits only DeclareShortcut/RespondToShortcut between offer and accept
(a live ring re-scan is unsound -- intervening finalize/SBA/layer steps drift the
paused state). Concede and Debug bypass that firewall, so apply_confirmed_shortcut
re-validates the latched controller's liveness at consumption: CR 104.3a (a player
who conceded has lost and cannot be crowned), CR 104.2a (the winner must still be
in the game), CR 800.4a (the departed proposer's loop objects have left, so the
loop dissolved). On a departed proposer it hands priority to the next LIVING player
(is_alive(active_player) ? active_player : next_player_in_turn_order) rather than
crowning a departed player or leaving priority on a departed holder; the APNAP
remaining_players advance likewise filters out opponents who left mid-window.

New serialized surface (LoopDetectionMode::Interactive, WaitingFor::LoopShortcut/
RespondToShortcut, GameAction::DeclareShortcut/RespondToShortcut,
IterationCount::UntilLethal, ShortcutProposal/ShortcutResponse, LoopCertificate
serde derives) is additive; Off/On configs serialize byte-identically. Frontend
modal UI, smart-Shorten AI, and Fixed-count materialization are Phase 4/5 (the
WaitingFor types are registered so UnhandledWaitingForModal does not fire; the AI
answers RespondToShortcut with Accept and DeclareShortcut with UntilLethal via
explicit non-wildcard arms).

Verification: cargo fmt; clippy --all-targets -D warnings (clean); cargo test -p
engine (18616 passed, 0 failed); loop_shortcut 10/10 -- On+Off byte-identity
goldens, 3p APNAP cascade accept-win, CR 732.4 draw, Shorten priority window,
authorization routing, and revert-failing guards: controller-concede-not-crowned,
queued-opponent-concede-no-deadlock, and 3p-subset-lethal-no-crown. FORGE
ordering_parity_sweep not run -- Phase 3 touches zero trigger-ordering surface;
the three decision-input recipe files (ability_rw.rs, ability_utils.rs,
triggers_ordering_parity_tests.rs) are byte-identical to the branch base and no
parser/card-data is touched (delta=0 by construction). Independent review-impl
clean after catching + fixing one soundness blocker (the Concede/Debug firewall
bypass and its dead-priority remedy path).

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): object-growth loop detection — offline detector (PR-7 phase 4a-core)

Phase 4a-core of combo-detector PR-7: the OFFLINE object-growth loop detector — the
object-axis analog of the existing stack-axis omega-coverability. Certifies (or
fail-closed rejects) loops whose battlefield GROWS by inert tokens each iteration,
which the strict board-equality gate + MAX_OBJECT_GROWTH ceiling could not detect.
Offline only: no reducer/sampler/bridge change, so Off/On/Interactive runtime stays
byte-identical to HEAD; the live empty-stack sampler (4a-live) is deferred until a
certifiable live object-growth loop exists (plan ruling).

Detector (analysis/resource.rs) loop_states_cover_modulo_object_growth reuses the
Karp-Miller discipline on the object axis:
- board_covers: absolute-ObjectId embed + inert-class confine; grown_ids = the
  battlefield after-before absolute-id set.
- eq_except_growable: strip grown objects + clear battlefield/stack, then reuse the
  GameState PartialEq wholesale so EVERY non-object field strict-compares (incl.
  delayed_triggers / deferred_triggers / pending_trigger* / epic_effects
  accumulators) — the excepted set is derived, not hand-listed.
- grown_objects_are_inert: no static/trigger/replacement/activated ability, non-CDA
  P/T, non-legendary/world, no counters (CR 704.5).
Wired into the OFFLINE detect_loop classifier; the fail-loud residual_board_delta
seam lights up on object growth.

Fail-closed firewalls (CR 732.2a predictable-results; false-negative = grind-to-cap
= today's behavior, false-positive is not acceptable):
- Observation (fire_time_conditions_read_growing_class): scans the CONDITION and
  full EFFECT-BODY AST of every trigger/activated/static/TCE/granted-keyword ability
  on every battlefield object + the delayed/deferred/pending/epic store bodies, on
  the projected||sibling axis via the recursive scan_effect (opaque => CONSERVATIVE).
- Cost (cost_surface_references_growing_class): ONE predicate over the whole cost
  surface — an EXHAUSTIVE no-`_` match over all 117 StaticMode variants (ModifyCost /
  ReduceAbilityCost dynamic_count for Reduce AND Raise; the AbilityCost-bearing
  Impose/Alternative/CastWith variants + the three cast-permission variants;
  CastWithKeyword) and an EXHAUSTIVE no-`_` match over all 198 Keyword variants
  (Affinity/Convoke/Improvise/Delve/Emerge/Offering/Bargain/Assist/Crew/Saddle/
  Station/Teamwork/Conspire/Waterbend/Harmonize/Craft/Casualty reject on grown-class
  overlap; Undaunted is opponent-count SAFE), plus nested sub_ability/else_ability
  cost recursion. A per-creature cost INCREASE (ModifyCost Raise + ObjectCount) is
  the false-positive-infinite direction and rejects. Both matches are compile-break
  tripwires — a future StaticMode/Keyword variant cannot silently fail open.

Totality guards: compile-time _gameobject_partition_is_total (all 136 GameObject
fields) + _gamestate_partition_is_total (all 259 GameState fields), no `..`, so a
future field is a build break until classified. objects_content_eq is extended with
the 14 mutable per-object accumulators the hand-list had drifted behind
(chosen_attributes / intensity / stickers / perpetual_mods / class_level /
modal_back_face / goaded_by / detained_by / casting_permissions / saddled_by / ...),
each classified by its write sites, not its doc-string. Stricter equality on the
shared 2p CR 104.4b path is fail-safe (only suppresses a wrongful draw where an
accumulator differed = not a true fixed-point); T-ON-golden stays byte-identical.

Human rulings (plan section 14): keystone Witherbloom + Sprout Swarm => FAIL-CLOSED
REJECT (cast-affordability preservation is unprovable from resolution deltas —
affinity monotonicity is necessary but insufficient without a convoke-supply
invariant the ResourceVector does not capture); object growth certifies nothing
LIVE (4a-live deferred; keystone ships as the offline structural K-offline REJECT);
B5 defuse tracks every enabler (phase 4c).

Verification: cargo fmt; clippy --all-targets -D warnings (clean); cargo test -p
engine (18642 passed, 0 failed); 20 offline object-growth predicate tests incl.
K-offline keystone REJECT + R-e2 cost-increase-infinite + previously-fail-open cost
keywords + one REJECT per observation/cost axis, each with a paired COVER control;
T-ON-golden (Heliod+Ballista live 2p CR 104.4b) green — the fail-safe proof the
objects_content_eq ADD did not over-suppress a legitimate draw loop; full loop
suites green (loop_check/resource/loop_shortcut/corpus). Both firewall matches
exhaustive-no-`_`. FORGE ordering-parity recipe files byte-identical (zero
trigger-ordering/parser/card-data surface). Independent review: a 6-round
soundness plan-review (S1-S6, each a fail-open-allowlist / equality-loosening
class) + an implementation review that caught + structurally closed two
cost-firewall gaps the green tests masked (the StaticMode type-misread and the
cost-keyword fail-open allowlist).

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): finite Fixed(N) loop-shortcut materialization (PR-7 phase 4b)

Materialize a confirmed Fixed(N) loop shortcut (CR 732.2a) by driving N whole
cycles of the constant-depth loop, committing atomically per cycle. Three-way
per-beat drive-outcome split: cross-lethal (CR 704.5a) commits the GameOver
already applied to `work` and stops; a recurred settle beat commits the cycle;
any other prompt or engine error aborts to manual play (last complete cycle +
priority to the living seat, CR 800.4a). Per-iteration `resolve` re-check
enforces target legality (CR 608.2b) and object incarnation (CR 400.7) against
the last committed board; stale/absent source aborts.

Threads an Option<DecisionTemplate> onto ShortcutProposal (serde default,
skip-if-none, so On/Off serialized streams are unchanged). The Fixed arm is
reachable only under LoopDetectionMode::Interactive; Off/On paths stay
byte-identical (candidates.rs still emits only UntilLethal). Per-beat fresh
event buffers avoid re-scanning prior beats' events, matching run_auto_pass_loop.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(combo-detect): PR-7 Phase 4c — revocable-∞ (B5) + LOW-2 self-preserving shortcut

CR 104.4b: an OPTIONAL beneficial (non-winning) loop is neither crowned (Path A: no faller) nor drawn (Path B: !mandatory) — mark it as a revocable-∞ capability (Path C) with its battlefield enabler set, so an enabler's departure (zones.rs apply_zone_exit_cleanup) REVOKES it. LOW-2: the AI's RespondToShortcut self-preserves via the shared smart_shortcut_response authority.

Documents has_no_loss_axis's two-gate asymmetry (measured): REDUNDANT at Path C (poison caught by ==Advantage/PoisonLoss, life by is_net_progress, library by recurrence — discriminator unsatisfiable, waived) but LOAD-BEARING at Path B (sole loss-axis veto, no ==Advantage backstop — kept; a poison loop reaching the gate would be wrongly drawn without it). The Path-B runtime discriminator is waived as measured-unsatisfiable: no constructible fixture carries poison>0 to the gate — the 2-trigger form clears the loop-detect ring on OrderTriggers beats, and the single-compound-trigger form drops the poison conjunct via a parser gap. Ships the Path-B draw-gate behavioral test (control draws / variant poisons out).

Follow-up (LOW, 0 live cards, synthetic-only): silent-clause-swallow parser gap — a bare-"and" cross-subject second conjunct ("...and each opponent gets a poison counter") is dropped at parse (kept only in description, not Unimplemented); fixing it unblocks a genuine Path-B has_no_loss_axis runtime discriminator.

Assisted-by: ClaudeCode:claude-opus-4.8

* refactor(engine): PR-7 rebase-adaptation — classify post-rebase GameState fields for the loop cover gate

Rebase of PR-7 phases 1–4c onto upstream/main d1a1e995e (#5546) surfaced two
new GameState fields that the object-growth cover gate must account for. The
`_gamestate_partition_is_total` totality guard (exhaustive no-`..` destructure)
forces an explicit classification of every field; ONE-SIDED-SAFETY governs it
(COMPARED is fail-safe, EXCLUSION is the fail-dangerous direction — exclude a
field only when comparing it would break legitimate loop detection):

- pending_player_scope_sacrifice_choice: COMPARED (already in upstream's
  `impl PartialEq`) — a differing paused-sacrifice state is correctly not a
  fixed-point repeat.
- post_replacement_token_substitution_count (CR 614.1a copy-token count):
  COMPARED via one contained conjunct in `eq_except_growable` — upstream's
  PartialEq deliberately excludes it, but excluding a count from the cover gate
  is the fail-dangerous direction. It is None at every sample beat (cleared
  whenever waiting_for == Priority) or a constant direct-assigned count across
  a real copy-token loop, so comparing never suppresses a legitimate loop.
  Upstream's PartialEq is left untouched.
- resolution_source_relatch (CR 400.7j self-move re-latch): EXCLUDED-required
  (measured by ordering trace, not doc-trust). The clear at stack.rs fires at
  the START of the next resolution; record_loop_detect_sample fires at the
  Priority window AFTER this resolution's self-move set it, so at the sample
  beat it holds this iteration's current_incarnation, which bumps every
  iteration. Comparing it would make every self-moving loop compare unequal
  (a false negative). Object growth lives in `objects` (stripped and compared
  by eq_except_growable), so excluding this single-object identity field
  cannot hide growth.

Gate: fmt, clippy --workspace --all-targets -D warnings, test -p engine
-p phase-ai (0 failed / 22 binaries), FORGE byte-id (ability_utils/ability_rw
byte-identical) all green; 0-behind upstream/main.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(combo-detect): PR-7 Phase 4d-i — offline tapped-fodder cover + BLOCKER-2 structural sign-check

Offline soundness core for object/token-growth loop detection (Witherbloom, the
Balancer + Sprout Swarm class). NO live caller — the fodder predicate is exercised
only by unit tests + the T-B1i discriminator; 4d-ii wires the live clone-drive hook
+ materializer. LoopDetectionMode stays OFF ⇒ byte-identical to pre-PR-7.

New (analysis/resource.rs):
- loop_states_cover_modulo_fodder_growth (pub(crate)) + board_covers_modulo_fodder
  + fodder_content_eq + is_fodder — tapped-split multiset cover for inert fodder
  growth (untapped(cur) >= untapped(prior) + strict total growth); stable-engine
  content via objects_content_eq (sole authority — GameState PartialEq is
  objects.len()-only). Drops the abstract cost-surface firewall (driven-path-only);
  detect_loop STAYS on loop_states_cover_modulo_object_growth (pinned by T-B1i).
- driving_resources_non_decreasing + projected_player_axes + project_out_player_consumables
  refactor (no-`..` compiler-total destructure shared with project_out_resources) +
  _projected_player_axes_is_total — BLOCKER-2 structural sign-check: blanket
  fail-closed veto on any controller-side decrease of a projected consumable
  (energy/poison/per-kind player_counters + per-kind monotone object counters),
  closing the project_out_resources sign-unchecked hole. CR 106.1/119/122.1/122.1a/
  306.5c/310.4c/606.3/613.4c (all grep-verified).

Tests: 15 inline (fodder cover + siblings; the 8-fixture sign-check family incl the
structural per-kind player_counter discriminator; _projected_player_axes_is_total)
+ T-B1i (detect_loop returns None on a convoke-fodder pair; asserts both None AND
fodder-cover==true). Reject-preservation regressions object_growth_k_offline_* /
object_growth_r_a2_* stay green.

Gate (direct cargo, worktree): fmt clean; check; clippy --all-targets -D warnings
0 warn; test -p engine 16175 passed / 0 failed. Plan and impl each reviewed clean by
independent agents.

4d-ii carries (flagged, unreachable in 4d-i — no live caller): (1) tie the
map-consumable sign-veto to the projected destructure (a projected_player_maps
helper from the same no-`..` site) before wiring the live caller, so a future 2nd
map consumable cannot be projected-out-and-sign-unchecked (BLOCKER-2 one field over);
(2) veto controller-side damage_marked INCREASE (CR 704.5g) once
object_resource_axes_match is dropped on the driven path.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(combo-detect): PR-7 Phase 4d-ii — live token-growth loop detection + CR 732.2a offer

Detect infinite token-growth loops live end-to-end and offer the CR 732.2a
shortcut. Acceptance bar (the 51st): Witherbloom, the Balancer + Sprout Swarm
parses, casts (convoke + affinity + buyback), detects, OFFERS, and materializes
N real untapped Saprolings on Accept.

Foundation (injector-independent):
- RecastContext + GameState.last_recast_context. EXCLUDED from impl PartialEq so
  the live CR 104.4b 2p-draw comparison is byte-identical to pre-PR-7; compared
  only via explicit cover conjuncts (eq_except_growable +
  loop_states_equal_modulo_resources), keeping the N7 discriminator non-vacuous.
- projected_player_maps: no-`..` structural tie for map-typed player consumables
  (a future map consumable build-breaks); damage_marked-INCREASE veto (CR 704.5g).
- triggers.rs trigger-order filter_map made exhaustive (future PinnedDecision
  variant build-breaks).

Live-drive:
- PinnedDecision::ConvokeTaps (CR 601.2h + CR 702.51a/b) with select_convoke_taps
  as the single convoke cost-selection authority (the ConvokeTaps replay routes
  through it; shares is_convoke_eligible/object_cant_tap; no parallel path).
- drive_recast_iteration injector: exhaustive on WaitingFor, fail-closed
  (Err(RecastAbort)) on every unpinned prompt; the ManaPayment decision loop is an
  exhaustive non-`_` match so a future ConcreteDecision variant build-breaks.
- try_offer_object_growth_shortcut hook: read-only &GameState (live write
  type-impossible); OFFERS via WaitingFor::LoopShortcut, never auto-resolves (CR
  732.2a); SimulationProbeGuard spans both drives (no hook recursion).
- Materializer third disjunct (loop_states_cover_modulo_fodder_growth) + Fixed(N)
  object-growth cycle; leaves a valid state (right controller, ring cleared,
  last_recast_context cleared, priority to next living seat CR 800.4a).
- RecastContext capture gated on loop_detection.samples() — #4603 opt-in gate: in
  default LoopDetectionMode::Off nothing is written, so the serialized surface is
  byte-identical to pre-PR-7.

Tests (real Witherbloom + Sprout Swarm, no synthetic on the positive):
- P1 offers live (LoopShortcut, TokensCreated cert, exactly one real Saproling
  from the single real cast); P2 materializes N on Accept.
- N1/N3 reject (no affinity / no buyback); N6 live no-offer control (damage-drain
  recast, CR 704.5g branch d) with a positive reach-guard, revert-probed.
- off-mode #4603 gate (OFF is_none + ON reach-guard, revert-probed);
  select_convoke_taps x4 + convoke_pin.
- The 51st loop body has NO auto-resolved randomness (vanilla Saproling, no ETB,
  deterministic convoke) — runtime-confirmed by P1.

N4 (energy) / N5 (player-counter) no-offer controls are covered at the unit +
structural-wiring level, not live fixtures: a live per-recast drain on these axes
is genuinely infeasible in today's buyback-recast mechanism (an energy cost breaks
buyback recurrence — the naive live test was proven vacuous by revert-probe and
removed rather than shipped; no engine effect drains Experience/Ticket counters).
The shared driving_resources_non_decreasing seam (branches a/b/d) is live-verified
via N6. The branch-(a)/(b) sign-checks are fail-closed defensive guards,
live-unreachable today but not dead code.

The offer path is fail-closed-modulo-auto-randomness until the A2 determinism gate
(separate follow-up pass).

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(combo-detect): PR-7 Phase 4d A2 determinism gate — reject randomness-bearing recast loops (CR 732.2a)

A CR 732.2a shortcut "can't include conditional actions, where the outcome of
a game event determines the next action" — so an object-growth loop whose
recast cycle draws game randomness must NOT be offered as a fixed-count
shortcut. This wires a two-layer fail-closed determinism gate into the live
offer path (`try_offer_object_growth_shortcut`), discharging the b132ad9f8
"fail-closed-modulo-auto-randomness" carry.

(a) Static, compile-time-exhaustive scan of the recast spell body before
    driving: `effect_is_randomness_bearing` (a wildcard-free match over `Effect`
    — a future random-bearing variant BUILD-BREAKS rather than being silently
    offered) + `spell_ability_bears_randomness`; the `collect_effects` walker
    gains the two nested `replacement_effect` arms it was missing. Covers
    auto-resolved coin (CR 705.1) / die (CR 706.1a) and the field-level
    "game selects at random" selections (CR 701.9b) via `is_random()`.

(b) Post-drive RNG stream-position backstop: the driven clone starts as an
    equal `state.clone()` (ChaCha20 derives `Clone`, preserving word position),
    so `s_n2.rng.get_word_pos() != state.rng.get_word_pos()` iff any randomness
    was consumed during the deterministic detection drive — from ANY source,
    including external triggered/replacement randomness the static scan can't
    see. Strictly-more-conservative (only turns OFFERs into NO-OFFERs).

Soundness (measured): external coin/die/`Choose` are already rejected by the
fodder cover (`scan_effect => Axes::CONSERVATIVE` reads the growing sibling
axis); the recast spell body is NOT scanned by the cover, so (a) is the gate
there. (b) is the universal runtime backstop. The custom-classified random
card-selections (`Discard`/`RevealHand`/`ChooseFromZone`) can classify
`sibling=false` and pass the cover, but each draws RNG inline inseparably from
an RNG-outcome-dependent object move/mark — breaking byte-identical loop
reproduction — so no reachable input makes (b) the SOLE gate today; (b) is a
complete future-proof backstop rather than a currently-isolable path. Verified
by an independent /review-impl pass (CLEAN-FOR-COMMIT).

Tests: `object_growth_random_recast_body_does_not_offer` (recast-body coin →
no offer; coin-free twin shell still OFFERs, proving the input reaches the
offer path and isolating the coin as the sole disqualifier) + leaf tests
discriminating `is_random()` on both selection-mode types. The paired positive
`object_growth_51st_sprout_swarm_covers_and_offers` is unchanged.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(combo-detect): PR-7 Phase G2 — loop-detect ring survives a multi-trigger OrderTriggers beat (CR 603.3b/732.2a)

A self-refilling mandatory loop whose cycle emits 2+ simultaneous distinguishable
triggers parks at `WaitingFor::OrderTriggers` each iteration (CR 603.3b). The
loop-detection ring was WIPED on that beat by two clears, so it never accrued the
>=2 samples CR 732.2a detection needs — multi-trigger loops were undetectable
while single-trigger loops (which settle at Priority each cycle) worked. Ordering
simultaneous triggers is a forced step of putting them on the stack, not a
deliberate cascade-break, so the ring must survive it.

Two match-arm edits in game/engine.rs (no new variant/field):
- SEAM1 (pass_priority_once_with_pipeline): guard the ring-clear `else` with
  `!matches!(wf, WaitingFor::OrderTriggers { .. })` — settling into the mandatory
  ordering window leaves the ring intact (the record path stays gated on
  Priority{active}; the triggers are staged off-stack in pending_trigger_order,
  so the stack is momentarily shrunk here). Every other settle (drain-to-empty,
  shrinking stack, interactive non-Priority window) still clears.
- SEAM2 (apply_action): exempt `GameAction::OrderTriggers` from the deliberate-
  action clear (`!matches!(action, PassPriority | OrderTriggers { .. })`) — the
  ordering response continues a mandatory cascade. Every other non-Pass action
  (cast/activate/play-land) still invalidates the ring. SetTriggerOrderTemplate
  early-returns before this clear, so it is unaffected.

Both edits are required: the ring is wiped at two moments of one cycle — SEAM1
when the resolution settles into the window, SEAM2 when the window is answered.

Test (mechanism-scoped): drive_multi_trigger_ring_survives_order_triggers_beat
asserts an OrderTriggers beat occurs (reach-guard) + max_ring>=2 (ring survival).
Revert-probe: reverting EITHER seam alone drops max_ring 2->1 (and the measured
end-to-end GameOver Some(P0) -> None), proving both seams load-bearing. Adds a
drive_with_trigger_ordering helper, an idx18 single-trigger no-order-beat
non-regression, and a no-refiller false-positive control. The fixture also
reaches end-to-end GameOver at beat 10 (measured, documented as a NOTE not
asserted — max_ring==2 sits at the 2-sample detection edge; the robust E2E
multi-trigger witness is the 52nd/G1).

Assisted-by: ClaudeCode:claude-opus-4.8

* refactor(engine): PR-7 rebase-adaptation — classify StaticMode::CantBeBlockedUnlessAllBlock in the loop cover gate

Rebase onto upstream/main (5efd9db32) surfaced a new `StaticMode` variant
(`CantBeBlockedUnlessAllBlock`) via the exhaustive no-`_` match in
`static_mode_references_growing_class` (analysis/resource.rs) — the cover-gate
cost-surface scanner a new variant must be classified in before it compiles.

It is a combat blocking-restriction static with no cost surface, so it reads no
growing-class resource → classified read-free (`=> false`), alongside its siblings
CantBeBlocked / CantBeBlockedExceptBy / CantBeBlockedByMoreThan / MustBeBlockedByAll.

The 11 PR-7 commits rebased patch-identical (0 conflicts). Full verify green:
check + clippy --all-targets (0 warn); test -p engine lib 16237 + integration 2713,
0 failed (partition-lock=53, loop suites, on_shortcut_byte_identical_to_pre_pr7_golden).
FORGE + coverage N/A (delta touches neither ability_rw nor the parser).

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(combo-detect): PR-7 52nd/G1 — live poison-loss loop-winner + per-victim ResourceAxis::Poison re-key (CR 704.5c)

G1 — generalize the live mandatory-loop-winner to the poison axis. The faller
partition now recognizes a per-cycle poison-accruing player (delta.poison[p] > 0)
alongside life-loss (CR 704.5a / 704.5c); the aggregate-poison firewall is removed;
classify_win_kind reads the per-victim field AND the static .counters path (dual
authority for the live + candidate-graph callers); the gate accepts
LethalDamage | PoisonLoss; a poison-faller simultaneity guard (equal per-cycle
delta AND equal absolute poison at cycle_end among fallers) closes a >=3p
staggered-poison fail-open (CR 704.3).

Option A re-key (mandated by the resource.rs / derived_views.rs guard-notes) — the
analysis poison axis is re-keyed by victim PlayerId: new
ResourceVector.poison: BTreeMap<PlayerId, i64> + ResourceAxis::Poison(PlayerId)
(a per-victim parameterization mirroring Life(PlayerId), not a proliferated
sibling); attribution_player victim-routes Poison(p) => p; has_no_loss_axis and the
From<&ResourceAxis> for AxisKey drift-gate follow. Both guard-notes discharged.
3 display-only frontend edits (ResourceAxis union/tag + HUD counters family).

Two-path witness architecture — the real Kilo/Freed/Relic activation combo is
covered by the offline certification driver (drive_offline_kilo_freed_relic); the
live equality-sampler cannot see a player-driven activation loop by construction
(the stack drains between activations => the ring-sample CLEAR arm fires => the ring
never accumulates a recurrence). A synthetic self-refilling drain-poison trigger
(interactive_poison_axis_surfaces_in_offer_certificate) E2E-proves the re-keyed
Poison(PlayerId) axis reaches a live offer certificate (real recurrence; G-5
revert-probe flips it). The win_kind==PoisonLoss full-drive and the Path-B runtime
draw-veto discriminator are waived as measured-unreachable (proliferate's
ProliferateChoice beat clears the ring) — G-15 kept load-bearing plus the in-code
proof; the novel faller/classify logic is covered by loop_check.rs unit tests.

Verification: cargo fmt; clippy -p engine --all-targets -D warnings clean;
cargo test -p engine 16240 lib + 2714 integration, 0 failed; Off/On golden
byte-identity green; frontend type-check + lint clean; coverage baseline unchanged;
FORGE N/A (no ability_rw / same-event ordering surface). All CR annotations
grep-verified against docs/MagicCompRules.txt.

Assisted-by: ClaudeCode:claude-opus-4.8

* refactor(engine): PR-7 rebase-adaptation — Effect::ForEachCategory in the A2 randomness scan

Main parameterized Effect::ForEachCategoryExile into
Effect::ForEachCategory { action: ForEachCategoryAction } (a "parameterize, don't
proliferate" refactor). Retarget the exhaustive Effect match arm in
effect_is_randomness_bearing (ability_scan.rs) — ForEachCategory is deterministic
category iteration, so it stays in the non-randomness group. The import union
(AbilityCost / AbilityDefinition / ContinuousModification from PR-7 phase 4a-core +
main's new ForEachCategoryAction) was resolved in-place during the rebase.

Rebase of feat/combo-detect-pr7 (13 commits) onto upstream/main a61e7fdd9: one
textual conflict (the ability_scan.rs import list) plus one semantic drift (this
rename — the patch applied cleanly but referenced the removed variant, surfacing at
verification not conflict). Full re-verify green: clippy -p engine --all-targets
-D warnings clean; cargo test -p engine 16252 lib + 2718 integration, 0 failed;
Off/On golden byte-identity preserved.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): PR-7 gate-relax item-4 — Typed-filter drains read projected axes

The loop-cover gate's item-4 (`stack_entry_reads_projected_resource`) rejected
every `TargetFilter::Typed`-targeted drain via a blanket
`scan_target_filter(Typed) => Axes::CONSERVATIVE` (projected:true
unconditionally). Combined with item-3's raw `targets.is_empty()` coarseness
(COMMIT 2), this co-dominated the rejection of escalating targeted-drain loops
(Vito, Thorn of the Dusk Rose), making them undetectable.

Refine ONLY the `.projected` field of the `Typed` arm to
`typed_filter_reads_projected(tf)` — the event/sibling axes are kept literal
`true` (byte-preserved). New exhaustive `scan_filter_prop` classifier (no `_`
wildcard, unknown => CONSERVATIVE): QuantityExpr/TargetFilter/PlayerFilter-bearing
props recurse; ControllerRef-bearing recurse (every outcome projected:false);
`CountersPutOnThisTurn`/`ControllerChoseLabel` fail-closed CONSERVATIVE. Ground
truth: a prop is projected iff `project_out_resources` clears the field its
runtime eval reads.

`WasDealtDamageThisTurn` (eval reads `state.damage_dealt_this_turn`) and
`ZoneChangedThisTurn` (eval reads `state.zone_changes_this_turn`) are both
cleared by `project_out_resources` and NOT strict-compared by
`object_resource_axes_match` (which compares only `damage_marked` + `counters`)
— classified CONSERVATIVE (CR 120 / CR 400 / CR 603.6a). The variant doc's
"damage_marked > 0" was stale; runtime eval reads the journal. The remaining
"this-turn" leaves (`EnteredThisTurn`/`Attacked`/`Blocked`/`ControlledContinuously`)
read non-cleared object/combat fields => NONE.

cfg-test flagged-set shrinks {Dethrone,Increment,Soulbond,Training} =>
{Dethrone,Increment}: the refinement clears Soulbond/Training fail-closed
false-positives (their Typed filters carry no projected prop).

Verify: clippy -p engine --all-targets clean; lib 16260 + integration 2719,
0 failed; all 5 typed_filter_* classifier discriminators pass.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): PR-7 gate-relax item-3 — forced-unique targets clear the ordering-input gate

The loop-cover gate's item-3 (`stack_entry_has_no_ordering_input`) read raw
`ability.targets.is_empty()`, rejecting any non-empty-target stack entry as
introducing ordering freedom. That is too coarse: a forced-unique target (the
sole legal assignment) offers no ordering choice. Combined with item-4's
Typed-filter projected-axis blind spot (COMMIT 1), the two conjuncts
co-dominated the rejection of escalating targeted-drain loops.

Widen `stack_entry_has_no_ordering_input`: a non-empty-target entry passes iff
`forced_unique_targeting(state, ability)` holds — `build_target_slots` +
`auto_select_targets_for_ability(...) == Ok(Some(_))` (exactly one legal
assignment; fail-closed on Err / >=2 candidates / empty slots). CR 603.3d /
CR 608.2b: a determinate single-target ability contributes no
resolution-choice branching to the loop cover.

Vito, Thorn of the Dusk Rose 2-player determinate-win now EMPIRICALLY detects
and offers the CR 732.2a shortcut. Discriminator = `life(victim) > 0`: early
omega-cover detection leaves the drained opponent positive, whereas losing
detection grinds them to <= 0 via natural resolution (CR 704.5a), which also
wins P0 — so `GameOver{P0}` alone is not discriminating. Negative controls:
`n1_open_target_growing_still_rejected` (open/non-unique targets still reject)
and `item6_still_vetoes_under_forced_unique_targets` (the resolution-choice
axis, item-6, still vetoes independently under forced-unique targets).

Verify: clippy clean; integration vito_bond_conqueror_2p_determinate_win +
n1_forced_unique_targeted_cover_true + 2 negative controls pass; both
per-conjunct revert-probes flip (item-3 revert => no-detect; item-4 revert =>
no-detect).

Assisted-by: ClaudeCode:claude-opus-4.8

* refactor(engine): PR-7 rebase-adaptation — classify AbilityCost::UnattachFrom in the object-growth cost scan

Rebase of feat/combo-detect-pr7 (16 commits) onto upstream/main 1c3eee9dd (12 new
commits incl. Captain America, First Avenger #5552 + release v0.22.0). One drift:
main's Captain America commit added `AbilityCost::UnattachFrom { .. }` (unattach a
named permanent as an activation cost). The Phase-4d object-growth cost scan's
`scan_ability_cost` (ability_scan.rs) is a no-wildcard exhaustive match, so the new
variant surfaced at compile (E0004), not as a textual conflict. `UnattachFrom` is a
fixed structural cost with no dynamic board read and no projected-resource drain —
classify it `Axes::NONE` alongside the existing `AbilityCost::Unattach` (main
categorizes both as `CostCategory::Unattaches`). Every other exhaustive AbilityCost
match in the engine already covers the variant (main-added or pre-existing); only
this Phase-4d scan needed the arm.

Full re-verify green: clippy -p engine --all-targets clean; lib 16272 + integration
2750, 0 failed. The two commits the 3-way merge re-anchored (phase-4b
`apply_confirmed_shortcut` context; phase-4d-ii recast capture following main's
`finalize_cast_with_phyrexian_choices` -> `_inner` split) are semantic-guarded green
by b3_materialize_* and object_growth_51st_*; Vito gate-relax E2E + poison-axis E2E
pass.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): PR-7 53rd Pentad — shared strict-growth Generic-counter loop-cover predicate

Cover the infinite charge-counter-growth proliferate loop (Pentad Prism +
Kilo/Freed/Relic) and offer the CR 732.2a resource shortcut. New sibling predicate
`loop_states_cover_modulo_counter_growth` (analysis/resource.rs): strict-growth-only
over the PRESERVED `Generic` object-counter axis, fail-closed.

- `CounterGrowthDisposition {StrictGrowth, Stable, Consumed}` (typed, not bool).
  `classify_generic_counter_growth` is a wildcard-free `CounterType` match — the
  per-type classification table itself (a new variant won't compile until
  classified), kept in lockstep with `is_monotone_loop_resource`. `Consumed` (any
  `Generic` counter fell) takes precedence over `StrictGrowth` (mixed grow+consume
  rejects) — the infinite-consume soundness trap.
- `equalize_generic_counters` overwrites only `Generic` counts with `prior`'s, then
  rides the existing `loop_states_equal_modulo_resources`, so any non-`Generic`
  drift still rejects via the untouched equality.

Wired at exactly two non-GameOver seams (the firewall): `detect_loop` gate-1
(offline `Advantage` certification) and `interactive_loop_bridge` Path-C (live
revocable-unbounded capability mark). Deliberately NOT wired into
`live_mandatory_loop_winner` (GameOver-capable) — a conservative fail-closed
boundary. A charge/burden growth loop classifies `WinKind::Advantage` (CR 104.4b:
an optional loop is not a draw), so an over-claim is a declinable offer / revocable
mark, never a wrongful game-end — the revocability soundness bound (the equalize
step introduces a new projected `Generic`-counter axis, sound by this bound, not by
firewall parity).

GENERAL over preserved-`Generic` growth: Pentad Prism (charge) and The One Ring
(burden) are the SAME cover, so One-Ring's growth cover is discharged here — its
later pass is offer-layer + card-verify only.

Two-path coverage (matches the existing architecture): the real proliferate loop is
offline-certified (`drive_offline_pentad_prism`, real Kilo/Freed/Relic + Pentad
seeded >=1 charge via Sunburst, CR 702.44a); the live Path-C disjunct is exercised
by a sampler-visible self-refilling charge-growth trigger — the live equality
sampler records only non-shrinking-stack cascades, and a `ProliferateChoice` beat
hits the pre-existing ring-clear arm.

8 discriminating tests (4 unit + #5/#8 offline + #6/#7 live), all non-vacuous: the
consume control (#2) is a same-`Generic("charge")` decrease that flips only under a
direction-blind revert; #8 asserts `Some(Advantage, Counter(Other,Other))`; #6
marks the counter axis without any GameOver; #7 proves #4603-OFF byte-identity.

Verify: clippy -p engine --all-targets 0/0; analysis lib 173 + loop_shortcut 28 +
the 8 new tests green. Plan-reviewed + impl-reviewed clean.

Assisted-by: ClaudeCode:claude-opus-4.8

* test(engine): PR-7 54th Walking Ballista — offline LethalDamage acceptance + >2p win-authority controls

Walking Ballista's infinite-damage combo (proliferate the +1/+1 counters on the
Kilo/Freed/Relic mana-neutral engine, then remove-to-ping) is already covered by
existing machinery — this pass adds only acceptance + guard tests, no production
change. Rationale (measured, twice-reviewed):
- The +1/+1 counters are MONOTONE, so `project_object_for_loop` strips them and the
  existing `loop_states_equal_modulo_resources` gate-1 already certifies the loop
  with the damage/life axes as the movers (unlike the 53rd Pentad's PRESERVED
  `Generic` charge, which needed the new counter-growth cover).
- `classify_win_kind` already returns `LethalDamage` for opponent `damage_dealt > 0`.
- The loop is offline-only: every cycle contains an `ActivateAbility` (Relic tap,
  Freed untap, ping) and `apply_action` clears `loop_detect_ring` on every
  non-PassPriority/OrderTriggers action, so the live GameOver-capable seams are
  never reached. Ballista's `LethalDamage` is an OFFLINE cert (same class as the
  52nd Kilo offline PoisonLoss), never a live game-end. So NO live-lethal offer and
  NO >2p damage-distribution pin are built here (both are unreachable for the real
  card; the distribution pin belongs to the future combo-declaration UI pass, whose
  cert application must route through the all-opponents-fall win-authority).

Tests (test/harness-only; the driver is `#[cfg(any(test, feature = "combo-verify"))]`,
absent from `DRIVERS`/`CORPUS`, so the 53/12/4/37 partition is unchanged):
- `drive_offline_kilo_freed_relic_ballista` — standalone offline driver mirroring
  `drive_offline_pentad_prism_seeded` (real cards, abilities selected by effect/cost).
- N2 `drive_kilo_freed_relic_ballista_certificate` — seed 2 → `LethalDamage` +
  `covers([DamageDealt(P1)])` + `!mandatory` (a resolved opponent ping is the only
  way to populate the damage axis).
- N3 `drive_kilo_freed_relic_ballista_x0_no_damage_axis` — seed 0 → dead 0/0, the
  ping activation is rejected (bool-returning `activate_and_resolve`, no panic),
  degrades to the pure-proliferate `Advantage` loop with no damage axis.
- N5 `ballista_mp_single_opponent_ping_no_false_win` (→ None) +
  `ballista_mp_all_opponents_distributed_ping_wins` (→ Some(P0)) — CR 104.2a: a
  >2p win requires all opponents to fall; reverting the `nonfallers.len() != 1`
  guard flips the negative None→Some(P0). (Self-ping→Advantage is covered by the
  existing `classify_win_kind_controller_only_damage_is_not_lethal` unit control.)

Verify: clippy -p engine --all-targets 0/0; the 4 new tests + partition/shape locks
(53/12/4/37) green. Plan-reviewed + impl-reviewed clean.

Assisted-by: ClaudeCode:claude-opus-4.8

* test(engine): PR-7 One-Ring — opponent-burden proliferate Advantage cert + downstream upkeep-lethal (reuse-only)

Test-only pass. The One Ring's Generic("burden") counter grows on an
OPPONENT's copy under the Kilo/Freed/Relic infinite-proliferate engine;
the combo detector already certifies this via the 53rd Pentad
loop_states_cover_modulo_counter_growth cover — ZERO production change.

Part A (offline cert): drive_offline_kilo_freed_relic_one_ring — a
cfg-gated standalone structural twin of drive_offline_pentad_prism_seeded
with The One Ring installed on P1 (opponent). NOT added to DRIVERS/CORPUS
(partition 53/12/4/37 held).
  - seed=1 -> detect_loop = Some(WinKind::Advantage), covers
    Counter(Other,Other); the burden Counter axis is player-unattributed,
    so an opponent's burden certifies identically to a controller's charge.
  - seed=0 control -> Some(Advantage) with NO Counter axis (the loop stays
    Some via Trigger(Proliferate)) — the runnable discriminator.

Part B (downstream lethal): materialize N burden via the counter authority
add_counter_with_replacement, advance to the opponent's upkeep, and let the
printed .triggers[1] LoseLife(CountersOn(Source,"burden")) fire and resolve.
N >= life -> CR 704.5a SBA -> GameOver{winner:Some(P0)} (CR 104.2a).
Sub-lethal control (N = life-1) -> opponent survives, no GameOver.

CR 701.34a (proliferate), 104.4b (optional loop != draw), 704.5a, 104.2a,
500.6/503.1a (upkeep triggers), 608.2, 122.1, 603.6a, 602.1 verified.

Fixture: surgical single-key insert of "the one ring" from the export
(one entry; no other fixture entry moved).

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): PR-7 combo-declaration UI Stage 1 — ShortcutDecisionSchema on the loop-shortcut offer (engine READ-side)

Stage 1 of 3 for the combo-declaration UI (engine schema exposure -> frontend
render/collect -> forward-pointer routing). Engine-only, READ-side: exposes a
per-viewer decision schema on WaitingFor::LoopShortcut so the frontend (Stage 3)
can render an offered CR 732.2a loop's open per-iteration choices and collect
pins, computing nothing. A clean parent commit that LOCKS the FE contract.

New serde types (analysis/decision_template.rs), the 1:1 read-side dual of the 5
loop-declaration PinnedDecision variants (Order excluded — CR 603.3b trigger-order
is not a loop-declaration choice):
  ShortcutDecisionSchema { iteration_count: IterationCount, points: Vec<DecisionPoint> }
  DecisionPointKind { Targets{legal_targets}, ConvokeTaps{tappable}, Mode, MayChoice, UnlessBreak }

- schema field on WaitingFor::LoopShortcut (#[serde(default)]), populated at both
  offer sites. build_shortcut_schema CARRIES the detection decision list
  (build_recast_template output — single authority, no re-derivation); drain offers
  carry no pins -> empty schema. The only live Stage-1 decision-point is ConvokeTaps;
  the 4 other builder producers defer to Stage 2 (debug_assert!+None fail-safe — no
  producer emits them until the targeted-loop gate-relax).
- iteration_count: UntilLethal for a determinate CR 704.5a/704.5c drain, else
  Fixed(1) (an FE-overridable display seed).
- MP redaction inside filter_state_for_viewer (CR 732.2a): a non-controller viewer's
  schema drops hidden-info legal-targets, reusing the existing hand visibility
  composite keyed on each target object's owner+zone. A structural no-op for Stage-1's
  only live (public-battlefield ConvokeTaps) set, but the seam + a two-directional
  revert-probed test lock the security contract before Stage 2 produces hidden-info
  Targets sets.

Tests: T1 live convoke-taps (board-derived, tapped-payer excluded), T2 drain
empty+UntilLethal, T3 iteration_count exhaustive over WinKind, T4 redaction
(controller keeps hidden target / non-controller drops only it — revert-probe leaks),
T6 serde round-trip FE-consumable. cargo check --workspace --all-targets green
(phase-ai/wasm/server compile with the new field).

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): PR-7 combo-declaration UI Stage 2 — pin ingestion + drive-and-measure win derivation (engine WRITE-side)

Builds the engine WRITE-side of the loop-shortcut combo-declaration UI on top of the Stage-1 schema:

- validate_pins: fail-closed value-legality firewall (PinValidation), exhaustive over PinnedDecision, hooked at the top of handle_declare_shortcut before APNAP; skipped for choice-free (empty) schemas whose win derivation is pin-independent (preserves the Fixed(N) resolve firewall). CR 608.2b/700.2/732.6.

- drive_one_shortcut_cycle + inject_pinned_answer: general mid-drive pin injector (CycleOutcome), extracted behavior-identical from materialize_fixed_shortcut. TriggerTargetSelection re-resolves pins per iteration; the OrderTriggers arm uses the internal reconcile-free apply_action(OrderTriggers) path (never drain_order_triggers_with_identity) so the drive cannot re-enter the offer/crown hook. CR 603.3b/608.2b/702.51a.

- E1 crown (apply_until_lethal_shortcut): no longer an unconditional crown. Drives one pin-faithful cycle, measures ResourceVector::delta(snapshot(boundary), snapshot(work)), runs live_mandatory_loop_winner VERBATIM, and crowns GameOver{winner: Some(controller)} only when the measured winner is the proposer — else manual fallback (clearing last_recast_context to avoid an object-growth re-offer livelock). Inherently closes the latent Advantage-loop-declared-UntilLethal mis-crown. CR 704.5a/704.5c/104.2a/800.4a/732.2a/732.2c.

- F2 hardening: the >=2-faller crown path also re-verifies fallers_lives_pairwise_equal on the boundary/pre-drive faller lives (the offer's own certification inputs), so a staggered-death unequal-absolute-life drain does not crown. CR 704.3.

Tests A-G (loop_shortcut integration + inline stage2_injector) each carry a soundness discriminator with a measured revert-probe and a paired positive reach-guard. Full suite green; cargo check/clippy --workspace --all-targets RC=0.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(client): PR-7 combo-declaration UI Stage 3 — loop-shortcut declare + respond modals (display-only)

Frontend for the loop-shortcut combo-declaration UI (engine schema locked by Stage 1/2):

- LoopShortcutModal: two display-only modals. DeclareShortcutModal (confirm-only — renders the offer summary + engine-proposed iteration_count + ConvokeTaps eligibility READ-ONLY; dispatches DeclareShortcut{count, template:null}) and RespondToShortcutModal (renders the viewer-filtered ShortcutProposal; Accept / Break-out dispatches RespondToShortcut{response}). Mirror ModeChoiceModal. CR 732.2a/2b/2c.

- 5 new TS mirror types (ShortcutDecisionSchema, DecisionPoint, DecisionPointKind, DecisionSlot, DecisionSource) matching the engine serde shapes; the stale LoopShortcut WaitingFor type gains its required schema field.

- 3-site actor-gate fix (CR 732.2a, mirrors engine acting_player()): usePlayerId.ts (human render), aiController.ts + p2p-adapter.ts (AI drivers) admit LoopShortcut{controller} into the engine-derived authorized-submitter path — pure routing, no logic. Closes the On-mode AI-controller hang.

- Registry migration: LoopShortcut + RespondToShortcut moved PENDING_MODAL_PHASE5 -> HANDLED_WAITING_FOR_TYPES + dispatch-coverage literals; both mounted in GamePage. i18n comboShortcut.* in all 7 locales (parity-gate green).

Display-only: every value from an engine schema field, template:null, ConvokeTaps read-only, zero React game-state computation. Pin-capture deferred (rides the >2p targeted lane). T1-T8 discriminating tests; targeted FE gate green (type-check, lint 0-errors, vitest).

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(client): mirror GameAction::SetTriggerOrderTemplate in the FE union (PR-7 phase-2 boundary sync)

PR-7 phase 2 (commit 67113b225, trigger-order resolver) added engine GameAction::SetTriggerOrderTemplate (types/actions.rs:641) but never mirrored it in the frontend GameAction union, leaving boundary-guardrails.test.ts's engine<->FE lockstep red. Latent because Stages 1-2 were engine-only and the frontend gate never ran on the branch until Stage 3.

Serde-faithful transcription mirroring the SetMayTriggerAutoChoice/MayTriggerAutoChoiceOp sibling: SetTriggerOrderTemplate -> TriggerOrderTemplateOp{Save{sources,order}|Remove{key}|ClearAll} -> DecisionGroupKey{sources,kind}/DecisionKind. Pure type mirror, zero logic (no dispatcher/handler — a SetTriggerOrderTemplate UI, if ever wanted, is the trigger-order-resolver feature's concern). boundary-guardrails now green; full FE suite green + type-check clean. CR 603.3b.

Assisted-by: ClaudeCode:claude-opus-4.8

* refactor(engine): PR-7 rebase-adaptation — classify StaticMode::CountersCantBeRemoved in the loop cover gate

Upstream #5663 (a21ac2493) added StaticMode::CountersCantBeRemoved (Fear of Sleep Paralysis). PR-7's exhaustive no-wildcard cost-surface scan static_mode_references_growing_class (analysis/resource.rs) must classify it: it is a counter-removal prohibition with no payment cost (counter_type is a filter, not a board read), so its cost surface is read-free -> false, grouped with CountersPersistAcrossZones. Rebase-adaptation only; no behavior change to PR-7's own commits.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): annotate analysis-time zone-clone mutations for the zone-authority census (PR-7 CI)

The CI-only engine-authority ratchet (scripts/check-engine-authorities.sh -> zone_authority_census.py; not run by clippy/test, so the local gate was green) flagged two NEW raw zone-container mutations in PR-7 code:

- analysis/resource.rs::eq_except_growable — clears battlefield on a DISCARDED comparison CLONE for loop-cover equality. Takes &GameState and mutates a local clone consumed by ==; no gameplay zone event can fire on it.
- game/engine.rs::normalize_recast_frame — prunes hand/graveyard/library on a DISCARDED recast comparison-frame CLONE. Takes &GameState and returns a normalized clone; no gameplay zone event fires on it.

Both are genuinely non-replaceable analysis-time normalizations (not live gameplay zone changes routed through zone_pipeline), so each site is annotated // allow-raw-zone: <reason> and the frozen baseline (scripts/zone-authority-baseline.txt) is regenerated via --write (2 exempt rows added; no baseline row dropped). Gate B PASS. Comment-only code change; no behavior change.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): address #5672 review — Clash randomness (CR 732.2a), R2 typed enums, FE dead code

- Classify Effect::Clash as randomness-bearing: CR 701.30a reveals the top of a shuffled
  library (hidden info at pin time) and CR 701.30d decides the winner by revealed mana value,
  so a loop whose recast body contains a clash is not shortcut-eligible under CR 732.2a. Moved
  the false->true arm in effect_is_randomness_bearing + added a discriminating assertion to
  randomness_classifier_discriminates (revert-probe: moving it back flips the assertion).
- R2 (no bool fields): PinnedDecision/ConcreteDecision take/pay bool -> MayChoiceOption{Take,
  Decline} / UnlessPaymentOption{Pay,Decline}; RecastContext.uses_buyback bool -> BuybackUsage
  {Used,NotUsed} with a pays() accessor for the DecideOptionalCost consumer.
- Remove the dead-code '?? []' guard in LoopShortcutModal (schema.points is non-optional).

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(PR-5672): expose interactive shortcut UI

* feat(engine): CR 732.2a LoopShortcut controller-decline + engine-owned convoke count (#5672 review)

Addresses maintainer matthewevans' CHANGES_REQUESTED on #5672 (un-holds follow-up #24).

BLOCKER (CR 732.2a): the loop winner was forced to propose a shortcut (only DeclareShortcut
was accepted at WaitingFor::LoopShortcut). CR 732.2a makes proposing a shortcut a MAY. Add a
unit GameAction::DeclineShortcut: the controller-only decline restores ordinary priority
(living_priority_seat) and clears the object-growth routing context (last_recast_context) so
the post-return reconcile does not re-offer. Seam-1 (interactive/loop_detect_ring) re-offer is
already suppressed by apply_action's deliberate-action ring invalidation (a deliberate break
clears the ring); the handler owns only the Seam-2 gap. A genuine re-recurrence re-arms the
offer. Controller-only authorization is enforced upstream via check_actor_authorization.

NON-BLOCKING: move the convoke tappable-count derivation out of React into the engine-owned
ShortcutDecisionSchema (convoke_tappable_count, CR 702.51a); the modal renders it directly.

Adds a FE Decline button (display-only) + comboShortcut.decline in all 7 locales. Two
discriminating end-to-end decline tests (interactive dismissal + object-growth suppression,
each with an independent revert-probe) + a wrong-actor auth-reject test. #4603 OFF stays
byte-identical (no new GameState field; the offer is never installed when OFF).

Assisted-by: ClaudeCode:claude-opus-4.8

* refactor(engine): PR-7 rebase-adaptation — #5686 legacy_ field renames + drain/draw_sequence adds in the partition guard

Rebase onto upstream/main 6f7cee8e8 crosses #5686, which renamed six GameState fields to their legacy_ serde-migration names (post_replacement_{continuation,source,applied,event_source,event_target} -> legacy_*, pending_multi_draw -> legacy_pending_multi_draw) and added post_replacement_drains + draw_sequences. Update the exhaustive _gamestate_partition_is_total destructure (no '..', so a field-set mismatch is a hard E0027/E0559) to the new field names; the two new fields join the destructure so the cover-gate re-audit tripwire stays total. Soundness unchanged: draw_sequences is loop_equal-compared in GameState PartialEq; post_replacement_drains is skip_serializing_if=is_empty (settle-empty, loop-neutral).

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(PR-5672): separate shortcut proposer and winner

* test(PR-5672): exercise split shortcut authority

* fix(PR-5672): rebase game-state totality guard

* fix(PR-5672): group shortcut offer inputs

* fix(PR-5672): clarify shortcut authorities

---------

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
andriypolanski pushed a commit to andriypolanski/phase that referenced this pull request Jul 19, 2026
…urn costs less" (phase-rs#6196)

"The second spell you cast each turn costs {N} less to cast" (Highspire
Bell-Ringer, Uthros Psionicist, Raging Battle Mouse, Monk Class, Alisaie
Leveilleur) parsed with spell_filter: None and condition: None — the ordinal
"second ... each turn" gate was silently dropped, so the reducer cheapened EVERY
spell the controller cast instead of only the second.

Root cause: `parse_first_qualified_spell_filter` (grammar.rs) hard-coded the
literal prefix "the first " and a `SpellsCastThisTurn == 0` gate, so any ordinal
past "first" returned NotApplicable and fell through to the generic, filterless,
conditionless cost-modifier path.

Fix: parameterize the existing seam by ordinal (FirstQualifiedSpell ->
NthQualifiedSpell { filter, timing, ordinal }; "first" -> 1, "second" -> 2, ...)
so the gate becomes `SpellsCastThisTurn(you) == ordinal - 1` -- exactly
ordinal-1 qualifying spells already cast this turn => the spell now being cast is
the Nth. "first" is the unchanged ordinal=1 (== 0) case, so merged first-spell
behavior is byte-for-byte preserved. A "parameterize, don't proliferate" refactor
of one combinator seam, not a new sibling. New `parse_spell_ordinal_prefix` +
`parse_ordinal_word` combinators cover first..tenth.

Parse-only: the runtime already evaluates the SpellsCastThisTurn static condition
at cost determination (collect_battlefield_cost_modifiers ->
evaluate_cost_mod_static_condition).

CR 601.2f (total cost determination) / CR 107.3 (numeric/ordinal values).

Tests: parser unit test asserting "second" -> SpellsCastThisTurn == 1; registered
runtime cast-pipeline regression (nth_spell_ordinal_cost_reduction) proving the
first spell pays full {2}, the second is discounted to {1}, and the third pays
full {2} again (fails on revert -- the pre-fix reducer discounts the first cast).

Backlog: removed Uthros Psionicist and Raging Battle Mouse from root cause phase-rs#2 and
Highspire Bell-Ringer from root cause phase-rs#28 (all now parse fully clean); decremented
the associated counts. Monk Class and Alisaie Leveilleur retain other misparses
and stay listed.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant