Skip to content

feat(engine): implement CountersCantBeRemoved static (Fear of Sleep Paralysis) - #5663

Merged
matthewevans merged 1 commit into
phase-rs:mainfrom
Whovencroft:card/fear-of-sleep-paralysis-counters-cant-be-removed
Jul 12, 2026
Merged

feat(engine): implement CountersCantBeRemoved static (Fear of Sleep Paralysis)#5663
matthewevans merged 1 commit into
phase-rs:mainfrom
Whovencroft:card/fear-of-sleep-paralysis-counters-cant-be-removed

Conversation

@Whovencroft

Copy link
Copy Markdown
Contributor

Summary

Implements the missing static/replacement effect for Fear of Sleep Paralysis (DSC #12 ):

Stun counters can't be removed from permanents your opponents control.

This is a counter-removal prohibition governed by CR 122.1d (stun counter removal as a replacement for untapping) and CR 101.2 ("can't" overrides "can").

Changes

File Purpose
crates/engine/src/types/statics.rs Add StaticMode::CountersCantBeRemoved { counter_type: CounterType } variant with all required trait impls (Hash, Display, FromStr, to_kind, as_keyword).
crates/engine/src/game/coverage.rs Register in is_data_carrying_static() so the coverage classifier recognizes the new data-carrying variant.
crates/engine/src/game/turns.rs Add counter_removal_blocked() helper that checks active CountersCantBeRemoved statics against the target permanent; gate the untap-step stun-counter removal in both the main untap path and the Seedborn-Muse secondary untap path.
crates/engine/src/parser/oracle_static/dispatch.rs Add parse_counters_cant_be_removed_static() to parse the Oracle text pattern "<counter> counters can't be removed from <subject>".
crates/engine/src/game/turns.rs (tests) Two integration tests: (1) prohibition blocks stun counter removal, (2) baseline removal still works without the prohibition.

Design Decisions

  • Data-carrying variant: CountersCantBeRemoved carries a CounterType field so the same mechanism can be reused for future cards that protect other counter types (e.g., oil counters, shield counters).
  • Enforcement point: The gate is placed at the untap-step stun-counter-removal site in turns.rs (both main path and Seedborn path), which is the only place CR 122.1d removal currently occurs. Future counter-removal sites (e.g., explicit "remove a counter" effects) would need their own gate.
  • Filter matching: Uses static_filter_matches with a StaticCheckContext { target_id } to evaluate the affected filter (e.g., "permanents your opponents control") against the permanent whose counter would be removed.
  • Parser: Simple str::find anchor on " counters can't be removed from ", then parse_strict_counter_type for the prefix and parse_type_phrase for the subject suffix.

Comprehensive Rules

  • CR 122.1d: If a permanent with a stun counter would become untapped, instead a stun counter is removed from it.
  • CR 101.2: "Can't" effects take precedence — if a static says counters can't be removed, the CR 122.1d replacement doesn't happen.

Testing

  • execute_untap_honors_counters_cant_be_removed_static — verifies that with Fear of Sleep Paralysis on the battlefield, an opponent's stunned creature stays tapped AND keeps its stun counter.
  • execute_untap_removes_stun_counter_without_prohibition — baseline: without the prohibition, stun counter is removed normally per CR 122.1d.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the static ability "Counters can't be removed from [subject]" (e.g., Fear of Sleep Paralysis) to prevent stun counters from being removed during the untap step. It introduces the CountersCantBeRemoved variant to StaticMode, implements the runtime enforcement check in turns.rs, and adds a parser for this static ability. However, the parser implementation in dispatch.rs violates the repository's architectural rules by using .find() for parser dispatch instead of nom combinators, and it swallows errors using .ok()?. It should be refactored to use idiomatic nom combinators.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +3590 to +3602
// Step 1: Match the fixed anchor " counters can't be removed from " in the
// lowercase text, splitting into counter-type prefix and subject suffix.
let anchor = " counters can't be removed from ";
let anchor_pos = tp.lower.find(anchor)?;
let counter_text = &tp.lower[..anchor_pos];
let subject_start = anchor_pos + anchor.len();
let _subject_lower = tp.lower[subject_start..].trim_end_matches('.').trim();

// Step 2: Parse the counter type from the prefix.
let (_, counter_type) = nom_primitives::parse_strict_counter_type(counter_text.trim()).ok()?;

// Step 3: Parse the subject from the original-case text (same byte offsets).
let subject_original = tp.original[subject_start..].trim_end_matches('.').trim();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[HIGH] Use of .find() for parser dispatch violates the repository rules. Avoid verbatim string equality or non-nom string search methods for parsing Oracle phrases to maintain a robust, composable parser architecture. Additionally, avoid swallowing errors with .ok()? and instead propagate them to prevent masking critical bugs.

Suggested change
// Step 1: Match the fixed anchor " counters can't be removed from " in the
// lowercase text, splitting into counter-type prefix and subject suffix.
let anchor = " counters can't be removed from ";
let anchor_pos = tp.lower.find(anchor)?;
let counter_text = &tp.lower[..anchor_pos];
let subject_start = anchor_pos + anchor.len();
let _subject_lower = tp.lower[subject_start..].trim_end_matches('.').trim();
// Step 2: Parse the counter type from the prefix.
let (_, counter_type) = nom_primitives::parse_strict_counter_type(counter_text.trim()).ok()?;
// Step 3: Parse the subject from the original-case text (same byte offsets).
let subject_original = tp.original[subject_start..].trim_end_matches('.').trim();
let (remainder_lower, counter_type) = nom_primitives::parse_strict_counter_type(tp.lower)?;
let (subject_lower, _) = nom::bytes::complete::tag_no_case::<_, _, nom::error::Error<&str>>(" counters can't be removed from ")(remainder_lower)?;
let consumed_bytes = tp.lower.len() - subject_lower.len();
let subject_original = tp.original[consumed_bytes..].trim_end_matches('.').trim();
References
  1. Avoid swallowing all errors (e.g., using .ok()) when only specific errors are expected to be handled or ignored. Propagate unexpected errors (such as invariant violations or invalid actions) to prevent masking critical bugs.
  2. Avoid verbatim string equality for parsing Oracle phrases as it bypasses the robust nom-based parser and creates fragile matches. Instead, decompose compound phrases into modular, reusable parsers for constituent parts (e.g., subjects, conjunctions) and compose them using idiomatic combinator aggregates (like nested alt and tag sequences) to prevent combinatorial explosion and improve maintainability.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 73607505eb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/engine/src/game/turns.rs Outdated
if has_stun {
// CR 122.1d + CR 101.2: Skip removal when blocked by
// CountersCantBeRemoved (Fear of Sleep Paralysis).
if !counter_removal_blocked(state, object_id, &CounterType::Stun) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce counter-removal bans at the removal authority

This only consults CountersCantBeRemoved in the untap-step stun branch. Existing explicit counter removal and counter moves go through effects/counters.rs::remove_counter_with_replacement / apply_counter_removal (for Effect::RemoveCounter, remove-counter costs, and pending counter moves), which never call this helper, so effects that remove stun counters from a protected opponent's permanent can still remove them even though the new parser/coverage now marks Fear of Sleep Paralysis' “can't be removed” static supported. Put the prohibition at the central counter-removal/payability seam rather than this special untap site.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Jul 12, 2026

Copy link
Copy Markdown

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

1 card(s) · static/CountersCantBeRemoved(Stun) · added: CountersCantBeRemoved(Stun) (affects=opponent controls permanent)

Examples: Fear of Sleep Paralysis

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

Examples: Fear of Sleep Paralysis

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

@Whovencroft
Whovencroft force-pushed the card/fear-of-sleep-paralysis-counters-cant-be-removed branch from 7360750 to d6fd9df Compare July 12, 2026 09:06

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mechanic is right and the seam is right — but an unrelated CR annotation is deleted as collateral, and the two broadest gates in the change have no test. Both are small; fix them and this lands.

I verified the card and the rules before the diff. Fear of Sleep Paralysis (Duskmourn Commander) reads exactly as claimed — "Stun counters can't be removed from permanents your opponents control. (They won't untap if they have stun counters.)" — and the card's own reminder text confirms the behavior your tests assert: the permanent stays tapped and keeps the counter. CR 122.1d and CR 101.2 are both grep-verified in docs/MagicCompRules.txt and are the correct citations.

🔴 Blocker

1. turns.rs — an unrelated CR annotation is deleted

-/// CR 502.3: Collect the active `MaxUntapPerType` restrictions (Smoke /
 /// Damping Field / Winter Orb class). Each governs the untap turn-based action

max_untap_restrictions is nowhere near this PR's scope, and this drops the first line of its doc comment. Two consequences: the mandatory CR 502.3 annotation is destroyed (CR 502.3 is verified-correct for that function — it governs the untap turn-based action), and the surviving comment now opens mid-sentence with a dangling close-paren and no subject.

Fix: restore the line verbatim. Nothing else in the PR touches that function.

2. The two gates in counters.rs are untested

The change adds counter_removal_blocked at three sites, but the tests only cover one of them:

Gate Tested?
turns.rs untap step (main + Seedborn) ✅ both tests
remove_counter_with_replacement (counters.rs:935) ❌ none
move_counter_with_replacement_entry (counters.rs:1102) ❌ none

Sites 2 and 3 are the broadest-blast-radius part of this PR — remove_counter_with_replacement is the single authority for counter removal, reached from costs.rs, casting_costs.rs, mana_abilities.rs, deal_damage.rs, and engine_payment_choices.rs. Right now you could revert both of those hunks and the entire suite would stay green. That fails the repo's discriminating-test bar.

Fix: one test driving a stun-counter removal through remove_counter_with_replacement against a protected permanent (asserting it no-ops), and one through the counter-move path asserting the move is blocked at the source. Please make them fail if the gate is removed.

🟡 Non-blocking

  • Perf. counter_removal_blocked runs battlefield_active_statics(state).any(...) on every call to remove_counter_with_replacement — a full battlefield × statics walk paid on loyalty payments, -1/-1 removal, and damage resolution, even when no CountersCantBeRemoved static exists anywhere in the game. .any() short-circuits on a hit, but the common miss case walks the whole battlefield. Not a blocker (the scan is lazy and the existing code does this elsewhere), but worth a cheap presence check if this shows up in a profile.
  • The PR body describes an earlier iteration than the code. It says the parser is a "Simple str::find anchor" and that the gate is "placed at the untap-step … the only place CR 122.1d removal currently occurs", with future sites needing "their own gate." The code does neither — it uses nom combinators, and it already gates the general counter-removal authority. The code is better than the body claims. Please sync the body; a reviewer trusting it would look for defects that aren't there and miss the hunks that matter.

✅ Clean

  • Parameterized on the right axis. CountersCantBeRemoved { counter_type: CounterType } carries the counter type rather than hardcoding a Stun-specific variant, so the next "«X» counters can't be removed" card is a data change, not a new enum arm. That is exactly the axis the repo asks for.
  • No duplicated vocabulary. I grepped for an existing counter-removal prohibition; there is none. The Cant* family is large but every member governs a different domain (attacking, blocking, targeting, casting) — this is a genuinely new axis, not a second name for an existing concept.
  • The gate is at the single authority, not scattered across call sites, and move_counter_with_replacement_entry's return true correctly matches the existing Prevented => true convention ("complete, nothing happened") rather than falsely reporting a move.
  • Parser is combinator-basedparse_strict_counter_type + nom_tag_lower + parse_type_phrase, no string dispatch. .trim_end_matches('.') is structural punctuation stripping, which is fine.
  • The baseline test earns its place. execute_untap_removes_stun_counter_without_prohibition pins the CR 122.1d behavior without the static, so the positive test can't pass vacuously.

Recommendation: request-changes. Restore the CR 502.3 line and add coverage for the two counters.rs gates. Everything else here is ready.

@Whovencroft
Whovencroft force-pushed the card/fear-of-sleep-paralysis-counters-cant-be-removed branch 2 times, most recently from cb15f98 to a9f58b6 Compare July 12, 2026 09:26

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CR 502.3 restored — thank you. One blocker still stands: three of the four counter_removal_blocked gates are still revert-safe. The implementation is right; the coverage does not yet pin it.

🔴 Blocker — 3 of 4 enforcement sites have no test

counter_removal_blocked is wired into four places:

Gate site Covered by a test?
effects/counters.rsremove_counter_with_replacement no
effects/counters.rsmove_counter_with_replacement_entry no
turns.rsexecute_untap_with_choices yes
turns.rsexecute_seedborn_statics no

Both new tests call execute_untap(...), so only the third row is exercised. And because StaticMode::CountersCantBeRemoved is a new variant, no pre-existing test can reach the other three either — the only test that constructs the static is execute_untap_honors_counters_cant_be_removed_static. Delete the if counter_removal_blocked(...) { return; } guard from remove_counter_with_replacement and the suite still goes green.

That first row is the one that matters most: remove_counter_with_replacement is the documented single authority for counter removal (CR 614.1), reached from activation costs, casting costs, mana abilities, and damage. It is the broadest gate in the PR and the only one with no test standing behind it.

Three tests close this, and they are cheap because the setup already exists in your untap test — same StaticDefinition::new(StaticMode::CountersCantBeRemoved { counter_type: CounterType::Stun }).affected(...), different entry point:

  1. remove_counter_with_replacement — an effect removing a stun counter from an opponent's stunned permanent under Fear of Sleep Paralysis is a no-op: counter still present, no CounterRemoved event.
  2. move_counter_with_replacement_entry — a counter move whose source is a protected permanent is blocked (moving away is removal from the source, which is the rationale your comment gives).
  3. execute_seedborn_statics — the Seedborn Muse untap path honors the same gate as the main untap pass.

Each should also assert the inverse (removal succeeds with no prohibition on the battlefield), the way execute_untap_removes_stun_counter_without_prohibition does for the untap path — that inverse is what makes the untap test discriminating rather than vacuous, and it is the right pattern to carry across.

🟡 Non-blocking

parse_counters_cant_be_removed_static has no unit test. The parse-diff sticky is currently the only evidence the grammar works, and it only proves the one card. The combinator is parameterized on both CounterType and the subject filter, so a test in oracle_static/tests.rs covering a second axis — e.g. "+1/+1 counters can't be removed from creatures you control" — would prove the parameterization rather than the instance. This is the repo's "test the building block, not the special case" bar. Not blocking, because the sticky does establish the shipped card parses correctly.

✅ Clean

  • CR 502.3 is back, byte-identical to main. That was my fault to catch late and you fixed it exactly.
  • Parameterized on CounterType, not a Stun-specific variant — this covers the class, not the card.
  • Gated at the single authority rather than patching each caller.
  • Nom-based parser (parse_strict_counter_type + nom_tag_lower + parse_type_phrase), no verbatim-string dispatch.
  • The rules read is right, and the card's own reminder text confirms it: blocked removal means the permanent stays tapped and keeps its counter — "They won't untap if they have stun counters." CR 122.1d replaces the untap with a counter removal; when CR 101.2 forbids that removal, the untap does not happen either.
  • Parse-diff scope is exactly one card — no collateral.
  • CI is fully green (12 checks, 0 failing).

Recommendation: request-changes. Add the three gate tests (each with its inverse); the parser test is a nice-to-have you can fold in or skip. Everything else here is ready to land.

…aralysis)

Implements the missing static/replacement effect for Fear of Sleep
Paralysis: "Stun counters can't be removed from permanents your
opponents control."

Changes:
- types/statics.rs: Add StaticMode::CountersCantBeRemoved { counter_type }
  variant with all required trait impls (Hash, Display, FromStr, to_kind,
  as_keyword).
- game/coverage.rs: Register in is_data_carrying_static().
- game/turns.rs: Add counter_removal_blocked() helper gating the untap-step
  stun-counter removal (both main path and Seedborn-Muse secondary path).
- parser/oracle_static/dispatch.rs: Add parse_counters_cant_be_removed_static()
  to parse the Oracle text pattern.
- game/turns.rs (tests): Two integration tests verifying the prohibition
  blocks removal and the baseline still works.

Comprehensive Rules: CR 122.1d, CR 101.2
Card: Fear of Sleep Paralysis (DSC #12)
@Whovencroft
Whovencroft force-pushed the card/fear-of-sleep-paralysis-counters-cant-be-removed branch from a9f58b6 to 9872246 Compare July 12, 2026 09:47

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both blockers cleared on 9872246c — approving. The CR 502.3 doc comment is restored byte-identical to main, and all four counter_removal_blocked gate sites now carry a blocking test and an inverse.

✅ Blockers resolved

1. CR 502.3 annotation restored. max_untap_restrictions' doc comment in turns.rs is now byte-identical to main — the citation and the sentence it opens are intact.

2. All four gate sites are now discriminating. Previously only execute_untap_with_choices was covered, leaving the broadest gate — the CR 614.1 single authority — revert-safe. Now each site has a blocked case and an inverse baseline:

Gate site Blocked test Inverse
remove_counter_with_replacement (CR 614.1 single authority) remove_counter_with_replacement_blocked_by_counters_cant_be_removed
move_counter_with_replacement_entry move_counter_blocked_by_counters_cant_be_removed
execute_seedborn_statics execute_seedborn_statics_honors_counters_cant_be_removed
execute_untap_with_choices execute_untap_honors_counters_cant_be_removed_static

The inverses matter as much as the blocks: StaticMode::CountersCantBeRemoved is a new variant, so no pre-existing test can reach these gates. Without a baseline showing removal succeeding absent the prohibition, a gate that returned true unconditionally would pass every blocked-case assertion.

✅ Verified

  • Oracle text (Scryfall): "Stun counters can't be removed from permanents your opponents control. (They won't untap if they have stun counters.)" — the parsed signature CountersCantBeRemoved(Stun) (affects=opponent controls permanent) matches the printed subject exactly.
  • CR citations, all grep-verified: CR 122.1d (stun counters create the replacement effect that stops untapping), CR 101.2 ("can't" takes precedence over "can"), CR 614.1 (replacement pipeline), CR 502.3 (untap turn-based action). Each is applied where it actually governs.
  • Parse impact on this head: +1 card supported, 0 engine regressions, 0 coverage-honesty regressions, 0 oracle-changed. Gained exactly Fear of Sleep Paralysis — the claimed population, no collateral.
  • Parameterized, not proliferated: the static carries a CounterType axis rather than hardcoding Stun, and the prohibition is gated at the single counter-removal authority instead of being scattered across call sites.
  • Parser is combinator-based: parse_strict_counter_type + nom_tag_lower + parse_type_phrase. The matches! uses in the diff are enum matching on GameEvent/TargetFilter variants, not Oracle-text dispatch; the starts_with in FromStr deserializes the engine's own Display output and mirrors the EntersWithAdditionalCounters sibling directly above it.

🟡 Non-blocking

The new parser combinator has no unit tests of its own. The card-level evidence proves the one printed subject parses, but not the CounterType / subject parameterization the static is built around — a Charge-counter or permanents you control case would pin the axis you deliberately generalized. Worth a follow-up, not a merge blocker.

Recommendation: approve and enqueue. CI is fully green on 9872246c.

@matthewevans matthewevans added the bug Bug fix label Jul 12, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jul 12, 2026
Merged via the queue into phase-rs:main with commit a21ac24 Jul 12, 2026
12 checks passed
lgray added a commit to lgray/phase that referenced this pull request Jul 12, 2026
…ersCantBeRemoved in the loop cover gate

Upstream phase-rs#5663 (a21ac24) 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
lgray added a commit to lgray/phase that referenced this pull request Jul 12, 2026
…ersCantBeRemoved in the loop cover gate

Upstream phase-rs#5663 (a21ac24) 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
lgray added a commit to lgray/phase that referenced this pull request Jul 12, 2026
…ersCantBeRemoved in the loop cover gate

Upstream phase-rs#5663 (a21ac24) 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
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>
@Whovencroft
Whovencroft deleted the card/fear-of-sleep-paralysis-counters-cant-be-removed branch July 16, 2026 09:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants