Skip to content

feat(engine): ∞ unbounded-resource display for confirmed infinite loops (combo PR-6) - #4603

Merged
matthewevans merged 5 commits into
phase-rs:mainfrom
lgray:feat/combo-detect-pr6
Jun 30, 2026
Merged

feat(engine): ∞ unbounded-resource display for confirmed infinite loops (combo PR-6)#4603
matthewevans merged 5 commits into
phase-rs:mainfrom
lgray:feat/combo-detect-pr6

Conversation

@lgray

@lgray lgray commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

Revision (commit 8e461da8b) — addresses @matthewevans' review

Two requests resolved:

1. Live producer (the [HIGH] CHANGES_REQUESTED — display had no live writer).
A confirmed mandatory loop at the reconcile seam (reconcile_terminal_result) now
persists its unbounded axes via
mark_unbounded_loop(winner, delta.unbounded_axes_for(winner)) — the same axes
detect_loop records in LoopCertificate.unbounded. Real detected game-ending
loops now project the HUD rows; previously the only writer of
unbounded_resources was the debug SetInfiniteMana toggle.

2. User-controllable opt-in gate, default OFF (the follow-up comment).
New game-wide GameState::loop_detection: LoopDetectionMode { Off, On }, toggled at
runtime via GameAction::SetLoopDetection and an in-game UI toggle
(LoopDetectionToggle). OFF (the default) gates all three live seams — the ring
sampler, the CR 732.2a game-ending shortcut, and the producer — restoring exact
pre-combo-detector behavior
(and paying zero per-resolution normalize_for_loop
cost). This supersedes the "zero gameplay change" framing in the Summary below:
with the detector ON the mandatory-loop shortcut resolves a loop to its
determinate outcome; OFF it does not and the game proceeds exactly as before.
The debug SetInfiniteMana toggle remains a separate, ungated producer.

Frontend is display + dispatch only; the engine owns the flag and all gating.
loop_detection is excluded from loop-equality (control state, same family as
unbounded_resources). CR 732.2a / 732.5 / 704.5a. engine-inventory.json gains
LoopDetectionMode + GameAction::SetLoopDetection (this revision is no longer
byte-identical
to the prior PR-6 inventory).

Added tests (6): ON → game ends + row; OFF → no shortcut / no / ring
never populated (perf gate); flag-A/B shortcut-guard isolation from a byte-identical
populated-ring pre-state; runtime toggle clears the ring; loop-equality exclusion;
serde back-compat (missing field → Off). All three live seams (sampler, shortcut,
producer) independently revert-probed non-vacuous.


Summary

Generalizes the debug-infinite-mana mechanism into an engine-owned set of
unbounded resources (∞) surfaced for any confirmed infinite net-progress
loop — built for the whole ResourceAxis class (mana, tokens, damage,
life-drain, mill, poison, counters, draws, casts, triggers), not just mana.
Data source: the detector's LoopCertificate.unbounded: Vec<ResourceAxis>
(analysis/loop_check.rs). The engine owns all logic; the frontend is
display-only
(renders ∞, performs no derivation or attribution).

Zero gameplay change beyond the byte-preserved mana toggle. (Superseded by
the Revision above: true only with the detector OFF, which is the default.)

What changed

  • Engine state. Replace GameState.debug_infinite_mana: BTreeSet<PlayerId>
    with unbounded_resources: BTreeMap<PlayerId, BTreeSet<ResourceAxis>>. Single
    write authority: mark_unbounded_loop / clear_unbounded_loop.
  • Mana byte-preserved. refill (mana_payment.rs) + keep gate (turns.rs,
    CR 500.5) fire for any player whose set contains ResourceAxis::Mana(_);
    INFINITE_MANA_AXES seeds the six colours. Refill/keep bodies unchanged.
  • Loop-equality safety. unbounded_resources excluded from manual
    PartialEq, normalize_for_loop, loop_fingerprint — preserves CR 732.2a /
    104.4b loop-detection equality + AI-search dedup; guarded by a discriminating test.
  • Projection. DerivedViewsUnboundedResourceView { player, axis } via
    exhaustive attribution_player: payload axes (Life/DamageDealt/LibraryDelta)
    → carried PlayerId; aggregates → loop controller (CR 704.5a/b/c, 120, 119).
  • Serde/Ord. ResourceAxis (+ ObjectClass/CounterClass/TriggerKind)
    gain Serialize/Deserialize; ResourceAxis + ManaType gain PartialOrd/Ord.
    Additive migration (skip-if-empty +
    serde default); old debug_infinite_mana keys ignored.
  • Frontend (display-only). ∞ badges from engine-provided player/axis
    (usePlayerDesignations, HudBadges, PlayerHud/OpponentHud/ManaPoolSummary),
    exhaustive ResourceAxisTag family map, i18n in all seven locales.

Tests

9 building-block tests: loop-equality exclusion guard, mana-axis-only refill/keep
gates, attribution_player both directions + aggregates, real-cert → victim-HUD
projection, hostile-control case, non-mana-axis projection, wire round-trip.
Plus the 6 revision tests above.

Combo-detector series

Pos PR Delivers
PR-0 #4092 ResourceVector + modulo-resource loop equality (additive).
PR-1 #4097 Analysis sim harness feeding ResourceVector.
PR-2 #4119 Net-progress detect_loopLoopCertificate + corpus harness.
PR-3 #4480 Live mandatory-loop winner shortcut (drain-cascade, CR 704.5a).
PR-4a #4493 Engine B static ability-graph extractor (scaffold + 5 families + SCC).
PR-4b #4534 Engine B effect/trigger breadth + life-symmetry cost.
PR-5 #4547 cargo combo-verify CLI over the 53-row corpus.
PR-6 (this PR) unbounded-resource display + live producer + user opt-in gate — generalize infinite-mana to the whole ResourceAxis class.

Predecessor: PR-5 — #4547#4547
delivered the cargo combo-verify CLI over the 53-row corpus. PR-6 consumes the
same LoopCertificate.unbounded set and surfaces it through the engine's
DerivedViews projection into the UI.

@lgray
lgray requested a review from matthewevans as a code owner June 29, 2026 16:10

@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 backend and frontend infrastructure for tracking and displaying unbounded-resource loops (CR 732.2a). It replaces the simple debug_infinite_mana set with a more robust unbounded_resources map in GameState that tracks specific resource axes (such as mana, life, damage, and tokens) pumped by unbounded loops. The engine computes player attribution for these axes and exposes them via DerivedViews to the frontend, which groups them into display families and renders corresponding badges on the player and opponent HUDs, as well as an marker on the mana pool. Comprehensive unit and integration tests are added to verify the serialization, loop-equality exclusion, and HUD rendering. No review comments were provided, so there is no feedback to evaluate.

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.

@matthewevans matthewevans self-assigned this Jun 29, 2026
@matthewevans matthewevans added the feature Larger-scoped feature label Jun 29, 2026

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

[HIGH] Confirmed loop axes never reach the new display state. Evidence: crates/engine/src/analysis/loop_check.rs:275 builds the live LoopCertificate, but the only non-test writer to GameState::unbounded_resources is the debug toggle at crates/engine/src/game/engine_debug.rs:407; the new write authority itself still documents the detector producer as future work at crates/engine/src/types/game_state.rs:8008. Why it matters: the PR claims to surface confirmed infinite-loop resources, but live detected loops will still serialize no derived.unbounded_resources, so the HUD only lights up for SetInfiniteMana. Suggested fix: wire the live confirmation seam to persist cert.unbounded through mark_unbounded_loop (or deliberately retitle/scope this as debug-only infrastructure) and add a runtime test that drives an actual confirmed loop through the reducer, then asserts the resulting DerivedViews.unbounded_resources row is present.

@matthewevans matthewevans removed their assignment Jun 29, 2026
@matthewevans

Copy link
Copy Markdown
Member

Please add a UI change and hooks to make the combo-detector opt-in (default off).

lgray pushed a commit to lgray/phase that referenced this pull request Jun 29, 2026
…producer (phase-rs#4603)

Addresses matthewevans' CHANGES_REQUESTED and follow-up comment on PR phase-rs#4603.

1. Live producer. At the reconcile-seam mandatory-loop shortcut
   (`reconcile_terminal_result`), a confirmed loop now persists its unbounded
   axes via `mark_unbounded_loop(winner, delta.unbounded_axes_for(winner))` —
   the same axes `detect_loop` records in `LoopCertificate.unbounded`. A real
   detected loop now projects the `∞` HUD rows; previously the only writer of
   `unbounded_resources` was the debug `SetInfiniteMana` toggle.

2. User-controllable opt-in gate, default OFF. New game-wide
   `GameState::loop_detection: LoopDetectionMode { Off, On }`, toggled at runtime
   via `GameAction::SetLoopDetection` (a preference action: any seat, any
   WaitingFor, no game logic). OFF gates BOTH the loop-detection ring sampler
   (no per-resolution `normalize_for_loop` clone) and the game-ending shortcut +
   ∞ producer, restoring exact pre-combo-detector behavior. New game-changing
   functionality is opt-in so it can be developed safely. The debug
   `SetInfiniteMana` toggle is a separate, ungated producer.

Frontend: `LoopDetectionToggle` in the in-game menu — pure display + dispatch;
the engine owns the flag and all gating. `loop_detection` is excluded from
loop-equality (`PartialEq`/`loop_fingerprint`), the same control-state treatment
as `unbounded_resources`.

CR 732.2a (shortcuts/loops) / CR 732.5 (no player forced to break a loop) /
CR 704.5a (0-or-less life loses).

Tests: ON→game ends + ∞ row; OFF→no shortcut, no ∞, ring never populated;
flag-A/B shortcut-guard isolation; runtime toggle; loop-equality exclusion;
serde back-compat default Off.

Assisted-by: ClaudeCode:claude-opus-4.8
@lgray

lgray commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

@matthewevans — pushed 8e461da8b addressing both points:

1. Live producer (your [HIGH]). The reconcile-seam mandatory-loop shortcut now writes the confirmed loop's unbounded axes via mark_unbounded_loop(winner, delta.unbounded_axes_for(winner)) immediately before the GameOver, so real detected game-ending loops project the rows — not just the debug SetInfiniteMana toggle.

2. Opt-in toggle (your follow-up). New GameState::loop_detection: LoopDetectionMode { Off, On } (default Off), flipped via GameAction::SetLoopDetection + an in-game LoopDetectionToggle. Off gates all three live seams — the ring sampler, the game-ending shortcut, and the producer — so the default config is the pre-combo-detector engine (and pays zero per-resolution normalize_for_loop cost). Frontend is display+dispatch only; the engine owns the flag and all gating.

Six discriminating tests added (ON→ends+∞; OFF→no-shortcut/no-∞/empty-ring; flag-A/B shortcut isolation from a byte-identical populated-ring pre-state; runtime toggle; loop-equality exclusion; serde default-Off). All three seams revert-probed non-vacuous. CR 732.2a / 732.5 / 704.5a. Engine inventory now gains LoopDetectionMode + SetLoopDetection (no longer byte-identical to the prior PR-6). Ready for re-review.

lgray pushed a commit to lgray/phase that referenced this pull request Jun 29, 2026
…-rs#4603)

The LoopDetectionToggle i18n keys were added to en/common.json only; the
locale key-parity test (client/src/i18n/resources.test.ts) requires all seven
locales to share the same key set, so the Frontend CI job (lint, type-check,
test) failed on de/es/fr/it/pl/pt. Adds translated comboDetector/Title/On/Off
to the six missing locales.

Assisted-by: ClaudeCode:claude-opus-4.8
@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

@lgray

lgray commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

Setting the bot on the parser changes already - these are unexpected.

lgray pushed a commit to lgray/phase that referenced this pull request Jun 29, 2026
…producer (phase-rs#4603)

Addresses matthewevans' CHANGES_REQUESTED and follow-up comment on PR phase-rs#4603.

1. Live producer. At the reconcile-seam mandatory-loop shortcut
   (`reconcile_terminal_result`), a confirmed loop now persists its unbounded
   axes via `mark_unbounded_loop(winner, delta.unbounded_axes_for(winner))` —
   the same axes `detect_loop` records in `LoopCertificate.unbounded`. A real
   detected loop now projects the `∞` HUD rows; previously the only writer of
   `unbounded_resources` was the debug `SetInfiniteMana` toggle.

2. User-controllable opt-in gate, default OFF. New game-wide
   `GameState::loop_detection: LoopDetectionMode { Off, On }`, toggled at runtime
   via `GameAction::SetLoopDetection` (a preference action: any seat, any
   WaitingFor, no game logic). OFF gates BOTH the loop-detection ring sampler
   (no per-resolution `normalize_for_loop` clone) and the game-ending shortcut +
   ∞ producer, restoring exact pre-combo-detector behavior. New game-changing
   functionality is opt-in so it can be developed safely. The debug
   `SetInfiniteMana` toggle is a separate, ungated producer.

Frontend: `LoopDetectionToggle` in the in-game menu — pure display + dispatch;
the engine owns the flag and all gating. `loop_detection` is excluded from
loop-equality (`PartialEq`/`loop_fingerprint`), the same control-state treatment
as `unbounded_resources`.

CR 732.2a (shortcuts/loops) / CR 732.5 (no player forced to break a loop) /
CR 704.5a (0-or-less life loses).

Tests: ON→game ends + ∞ row; OFF→no shortcut, no ∞, ring never populated;
flag-A/B shortcut-guard isolation; runtime toggle; loop-equality exclusion;
serde back-compat default Off.

Assisted-by: ClaudeCode:claude-opus-4.8
@lgray
lgray force-pushed the feat/combo-detect-pr6 branch from 716748e to 660feb1 Compare June 29, 2026 23:00
lgray pushed a commit to lgray/phase that referenced this pull request Jun 29, 2026
…-rs#4603)

The LoopDetectionToggle i18n keys were added to en/common.json only; the
locale key-parity test (client/src/i18n/resources.test.ts) requires all seven
locales to share the same key set, so the Frontend CI job (lint, type-check,
test) failed on de/es/fr/it/pl/pt. Adds translated comboDetector/Title/On/Off
to the six missing locales.

Assisted-by: ClaudeCode:claude-opus-4.8
@lgray

lgray commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Re: the coverage-parse-diff bot's "parser changes" — this was a stale-baseline false positive, now cleared by rebasing onto current main.

None of this branch's 3 commits touch parser/. The actual diff was (and remains) 37 files, 0 under parser/, +1487/−43 — engine analysis/game/types, server-core, and client TS/i18n only.

Root cause (measured, not guessed): the branch was based on dd6c22ea7, which was ~20 commits behind main (12 of them parser commits). GitHub's floating refs/pull/4603/merge ref had its main-side parent at a post-base parser commit (bd13b96eb, #4513), while the bot's parse-diff baseline stayed pinned at the stale base dd6c22ea7. That mismatch made the intervening main-only parser commits leak into the bot's diff as if this PR introduced them. Each flagged bucket maps 1:1 to a main commit that landed after the base, not to anything in this PR:

bot-flagged bucket actually from main commit
another (Sacrifice) #4513
optional (up to) #4606
CantPhaseIn #4475
become removed #4579
single-quote #4605

This is the same cross-PR baseline contamination the workflow's own comment documents from the #4303 incident.

Fix applied: rebased the 3 commits onto current main (65223a44a) — clean, zero conflicts, byte-identical file set (37 files / 0 parser / +1487/−43). With the base now at main's tip, the bot's baseline and the merge ref's main-parent are back in lockstep, so the parse-diff recomputes to this PR's own (~0) parser delta. The Files-changed tab was always clean; a squash-merge lands only the 37-file/0-parser diff.

@lgray

lgray commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

@matthewevans review now appreciated :-)

@matthewevans matthewevans self-assigned this Jun 29, 2026

@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 prior live-producer blocker is fixed on this head, but I found one remaining blocker before this can be merge-when-ready:

[HIGH] Any online seat can opt the whole match into the live game-ending loop detector. Evidence: client/src/components/chrome/GameMenu.tsx:167 exposes the toggle unconditionally in the game menu, crates/server-core/src/session.rs:1084 routes SetLoopDetection before normal turn/legal-action checks for whatever authenticated seat submitted it, and crates/engine/src/game/engine.rs:397 treats SetLoopDetection as an actor-legal preference action. Why it matters: the detector is default-off because it can enable a global shortcut that produces GameOver/unbounded_resources, but any connected player can flip that game-wide behavior for the whole table at any time. Suggested fix: gate SetLoopDetection behind the appropriate whole-table authority (host/table consent or match creation config), and make the client/server/engine authority model consistent with that gate.

@matthewevans matthewevans removed their assignment Jun 29, 2026
@lgray

lgray commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

Going to make it a set-at-match-creation/lobby time setting. I think that's the best way to go for now.

Lindsey Gray and others added 4 commits June 29, 2026 21:20
…ps (combo PR-6)

Generalize the debug-infinite-mana mechanism into an engine-owned set of
unbounded resources (∞) surfaced for any confirmed infinite net-progress
loop, built for the whole ResourceAxis class (mana, tokens, damage,
life-drain, mill, poison, counters, draws, casts, triggers) rather than
mana alone.

Engine owns all logic; the frontend is display-only.

- Replace GameState.debug_infinite_mana: BTreeSet<PlayerId> with
  unbounded_resources: BTreeMap<PlayerId, BTreeSet<ResourceAxis>>.
  Single write authority: mark_unbounded_loop / clear_unbounded_loop.
- Mana behaviour is byte-preserved: the refill (mana_payment.rs) and
  keep-on-phase-change (turns.rs, CR 500.5) gates now fire for any player
  whose set contains a ResourceAxis::Mana(_); INFINITE_MANA_AXES seeds the
  six colours for the debug toggle. Refill/keep bodies are unchanged.
- unbounded_resources is excluded from the manual PartialEq for GameState,
  normalize_for_loop, and loop_fingerprint (display/annotation state, not
  game state for equality) — preserves CR 732.2a / 104.4b loop-detection
  equality and AI-search position dedup. Guarded by a discriminating test.
- DerivedViews projects the set into UnboundedResourceView { player, axis }
  via exhaustive attribution_player: payload-keyed axes
  (Life/DamageDealt/LibraryDelta) attribute to the carried PlayerId,
  aggregate axes to the loop controller (CR 704.5a/b/c, 120, 119).
- ResourceAxis (+ ObjectClass/CounterClass/TriggerKind) gain Serialize/
  Deserialize; ResourceAxis + ManaType gain PartialOrd/Ord for the BTreeSet.
- Frontend renders ∞ badges from the engine-provided player/axis fields
  (usePlayerDesignations, HudBadges, PlayerHud/OpponentHud/ManaPoolSummary)
  with an exhaustive ResourceAxisTag family map and i18n labels in all
  seven locales. No derivation or attribution in the display layer.

engine-inventory.json is byte-identical (no new variant). Serde migration
is additive (skip-if-empty + serde default); old debug_infinite_mana
snapshot keys are ignored.

Assisted-by: ClaudeCode:claude-opus-4.8
…producer (phase-rs#4603)

Addresses matthewevans' CHANGES_REQUESTED and follow-up comment on PR phase-rs#4603.

1. Live producer. At the reconcile-seam mandatory-loop shortcut
   (`reconcile_terminal_result`), a confirmed loop now persists its unbounded
   axes via `mark_unbounded_loop(winner, delta.unbounded_axes_for(winner))` —
   the same axes `detect_loop` records in `LoopCertificate.unbounded`. A real
   detected loop now projects the `∞` HUD rows; previously the only writer of
   `unbounded_resources` was the debug `SetInfiniteMana` toggle.

2. User-controllable opt-in gate, default OFF. New game-wide
   `GameState::loop_detection: LoopDetectionMode { Off, On }`, toggled at runtime
   via `GameAction::SetLoopDetection` (a preference action: any seat, any
   WaitingFor, no game logic). OFF gates BOTH the loop-detection ring sampler
   (no per-resolution `normalize_for_loop` clone) and the game-ending shortcut +
   ∞ producer, restoring exact pre-combo-detector behavior. New game-changing
   functionality is opt-in so it can be developed safely. The debug
   `SetInfiniteMana` toggle is a separate, ungated producer.

Frontend: `LoopDetectionToggle` in the in-game menu — pure display + dispatch;
the engine owns the flag and all gating. `loop_detection` is excluded from
loop-equality (`PartialEq`/`loop_fingerprint`), the same control-state treatment
as `unbounded_resources`.

CR 732.2a (shortcuts/loops) / CR 732.5 (no player forced to break a loop) /
CR 704.5a (0-or-less life loses).

Tests: ON→game ends + ∞ row; OFF→no shortcut, no ∞, ring never populated;
flag-A/B shortcut-guard isolation; runtime toggle; loop-equality exclusion;
serde back-compat default Off.

Assisted-by: ClaudeCode:claude-opus-4.8
…-rs#4603)

The LoopDetectionToggle i18n keys were added to en/common.json only; the
locale key-parity test (client/src/i18n/resources.test.ts) requires all seven
locales to share the same key set, so the Frontend CI job (lint, type-check,
test) failed on de/es/fr/it/pl/pt. Adds translated comboDetector/Title/On/Off
to the six missing locales.

Assisted-by: ClaudeCode:claude-opus-4.8
…CR 732.2a) (phase-rs#4603)

Resolves matthewevans' CHANGES_REQUESTED security review on phase-rs#4603: any
connected seat could opt the whole match into the live game-ending loop
detector mid-game via GameAction::SetLoopDetection. Loop detection is now a
match-creation config (MatchConfig.loop_detection) -- immutable during play
and whole-table by construction, with no in-game mutation path remaining.

- Remove GameAction::SetLoopDetection, its handler, the check_actor_authorization
  arm, and the server payload-guard entry; delete the in-game LoopDetectionToggle.
- Add MatchConfig.loop_detection (serde-elided when Off for byte-stable wire
  compatibility) and project it onto GameState.loop_detection through the single
  authority GameState::set_match_config at every init site: server create, server
  between-games rebuild, wasm local/P2P create, and the engine match_flow
  between-games rebuild (previously a raw assignment that dropped the opt-in for
  games 2+ of a Bo3 / archenemy restart).
- Keep match_type 2p-gated, but carry loop_detection through 3- and 4-player
  tables (infinite loops are a multiplayer / Commander staple).
- Add creation-time controls on both surfaces -- HostSetup (online lobby) and
  GameSetupPage (local) -- with comboDetector i18n keys across all 7 locales.
- Generalize the corpus drain harness to N players and add multiplayer validation
  tests (sole-survivor win, single-faller firewall, targeted-drain stop, net-zero
  draw-gate predicate) plus a between-games opt-in persistence test.

Default Off restores exact pre-detector behavior (opt-in invariant, phase-rs#4603).

CR 732.2a (combo/shortcut opt-in) / CR 732.5 (no forced loop break) /
CR 104.4b (mandatory-loop draw) / CR 104.2a (last-standing win).

Assisted-by: ClaudeCode:claude-opus-4.8
@lgray
lgray force-pushed the feat/combo-detect-pr6 branch from a9be1e9 to 9e04981 Compare June 30, 2026 02:59
@lgray

lgray commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

@matthewevans — pushed a fix for your CHANGES_REQUESTED (any connected seat could opt the whole match into the live game-ending loop detector mid-game via GameAction::SetLoopDetection).

Fix: the mid-game SetLoopDetection action is removed entirely — there is no in-game mutation path for the detector flag anymore. Loop detection is now a match-creation config (MatchConfig.loop_detection), immutable during play and whole-table by construction, projected onto the runtime GameState.loop_detection through the single authority GameState::set_match_config at every init site: server create, server between-games rebuild, wasm local/P2P create, and the engine match_flow between-games rebuild (the last previously dropped the opt-in for games 2+ of a Bo3 / archenemy restart — fixed + regression-tested here).

  • Removed: GameAction::SetLoopDetection + its apply_action handler + the check_actor_authorization arm + the server payload-guard entry + the in-game LoopDetectionToggle component.
  • Added: creation-time controls (online lobby HostSetupmultiplayerStore; local GameSetupPage → wasm), rendered at all player counts; match_type stays 2p-gated but loop_detection carries through 3–4 player tables. Default Off, serde-elided when Off for byte-stable wire/save compatibility.

Rebased onto latest main; full CI-equiv green (clippy --workspace -D warnings, engine 14241 / server / draft / wasm tests, type-check, vitest incl. i18n locale parity 60/60). Commit 9e0498116.

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

Re-reviewed latest head 28bf7d9. The prior live-producer blocker is fixed: the reconcile seam persists confirmed loop axes through mark_unbounded_loop under the opt-in gate, and the OFF path gates both sampling and shortcut behavior. The prior authority blocker is fixed: the mutable SetLoopDetection action and in-game control path are gone, with loop_detection now coming from MatchConfig at creation/rebuild sites. CI is green and I did not find remaining blockers.

@matthewevans
matthewevans added this pull request to the merge queue Jun 30, 2026
Merged via the queue into phase-rs:main with commit 22b212f Jun 30, 2026
11 checks passed
lgray added a commit to lgray/phase that referenced this pull request Jul 12, 2026
…on + 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() — phase-rs#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 phase-rs#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
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
…on + 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() — phase-rs#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 phase-rs#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
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
…on + 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() — phase-rs#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 phase-rs#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
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
…d convoke count (phase-rs#5672 review)

Addresses maintainer matthewevans' CHANGES_REQUESTED on phase-rs#5672 (un-holds follow-up phase-rs#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. phase-rs#4603 OFF stays
byte-identical (no new GameState field; the offer is never installed when OFF).

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>
lgray added a commit to lgray/phase that referenced this pull request Jul 14, 2026
…lt is Off, not On

combo-plan-author caught it, and it is textbook Appendix-B: I grepped
LoopDetectionMode::On, read the line numbers, and INFERRED 'production default'
WITHOUT reading the surrounding context. Both citations are #[cfg(test)] fixtures:
match_config.rs:60-61 is '#[cfg(test)] mod tests', and session.rs:1601 is
fn loop_detection_config_persists_across_bo3_rebuild().

THE SHIPPED DEFAULT IS Off. match_config.rs:27, verbatim: 'Default Off = exact
pre-detector behavior (opt-in invariant, issue phase-rs#4603)', enforced at the wire layer
by #[serde(default, skip_serializing_if = LoopDetectionMode::is_off)].

THE USER'S DIRECTIVE IS UNAFFECTED -- only my rationale was wrong, and the true one
is stronger. Off is the shipped default and must stay (phase-rs#4603). But when a user OPTS
IN today they face a confusing THREE-way choice: Off / On (auto-win only, NO OFFERS
-- a crippled half-detector) / Interactive (auto-win + offers). On is strictly
dominated by Interactive and adds nothing. So 'trap them into the detector' means:
once you opt in, you get the FULL detector, not a half one. The toggle becomes an
honest binary -- Off (pre-feature) / On (the full detector).

Re-measured blast radius: 18 On sites (not 16 -- the earlier count omitted the two in
the definition file). TWO predicates, not one: samples() (:5819) AND is_on() (:5804);
is_on() has ZERO production callers -- all six sites are tests -- which SIMPLIFIES the
collapse. WinKind has SIX variants, not five: I omitted ImmediateWin (loop_check.rs:98,
CR 104.2), and C5 v2's lifetime-to-claim mapping must classify all six.

Assisted-by: ClaudeCode:claude-opus-4.8
lgray added a commit to lgray/phase that referenced this pull request Jul 14, 2026
Every rev4 measurement set Interactive. This measures all three modes.

MEASURED:
  On          => WaitingFor::LoopShortcut  (unbounded=[TokensCreated], Advantage)
  Interactive => WaitingFor::LoopShortcut  (identical)
  Off         => Priority                  (detector never fires; phase-rs#4603 holds)

On and Interactive are IDENTICAL on the object-growth path: the bridge gates on
samples() == On|Interactive (engine.rs:448, game_state.rs:5975), not on a mode
match.

⛔ THE GAP: On is the AUTO-RESOLVE analysis mode (it auto-wins a lethal drain
with no offer), but on the object-growth path it OFFERS and blocks for a player
decision. On is now internally inconsistent — and its only real consumer is the
combo-verify corpus classifier (corpus.rs:2039), which handles a dozen
WaitingFor states (incl. PrecastCopyShortcutOffer / RespondToPrecastCopyShortcut)
but has NO arm for LoopShortcut or RespondToShortcut.

Before rev4 this was latent: the object-growth bridge armed only on a
buyback-paid token-creating SPELL, which no corpus row exercises. Rev4 widens
the arming class to ANY token-creating activated ability ⇒ combo-verify will now
hit LoopShortcut on exactly the combos it exists to find, and bail rather than
classify them.

FOLLOW-UP (not in rev4): give the corpus harness a LoopShortcut arm —
auto-DeclareShortcut with a bounded count, or auto-Decline.

Assisted-by: ClaudeCode:claude-opus-4.8
lgray added a commit to lgray/phase that referenced this pull request Jul 20, 2026
…ts (CR 732.2a)

CORE Unit-1 (P0+P2) of the loop/combo detector. Introduces
ScanMode {Conservative, LoopFirewall} in ability_scan so the CR 732.2a
loop-shortcut firewall reads token/mana ability bodies precisely
(LoopFirewall) while the CR 603.3b trigger-ordering gate keeps its
conservative answer (Conservative). Off is byte-identical (phase-rs#4603): all
existing pub(crate) entries pass Conservative, unchanged.

P0: ScanMode enum + minimal mode thread (6 fns on the path to the
Effect::Token/Mana leaves) + LoopFirewall _for_loop entries.
P2: Token/Mana descend under LoopFirewall; new exhaustive scanners
scan_continuous_modification (53), scan_keyword (198, reuses
keyword_cost_reads_growing_class), scan_mana_production (15, board
aggregates self-assert sibling), scan_pt_value. resource.rs firewall
descends per-modification on sibling||projected (M9, both axes).

14 discriminating tests (P0-1..4, P2-1..10), 13/14 revert-probe-proven;
independent review re-ran 3 probes + audited the full descent for
under-reads (none). clippy -D warnings clean; 16762 passed / 0 failed.

Assisted-by: ClaudeCode:claude-opus-4.8
lgray added a commit to lgray/phase that referenced this pull request Jul 20, 2026
…ts (CR 732.2a)

CORE Unit-1 (P0+P2) of the loop/combo detector. Introduces
ScanMode {Conservative, LoopFirewall} in ability_scan so the CR 732.2a
loop-shortcut firewall reads token/mana ability bodies precisely
(LoopFirewall) while the CR 603.3b trigger-ordering gate keeps its
conservative answer (Conservative). Off is byte-identical (phase-rs#4603): all
existing pub(crate) entries pass Conservative, unchanged.

P0: ScanMode enum + minimal mode thread (6 fns on the path to the
Effect::Token/Mana leaves) + LoopFirewall _for_loop entries.
P2: Token/Mana descend under LoopFirewall; new exhaustive scanners
scan_continuous_modification (53), scan_keyword (198, reuses
keyword_cost_reads_growing_class), scan_mana_production (15, board
aggregates self-assert sibling), scan_pt_value. resource.rs firewall
descends per-modification on sibling||projected (M9, both axes).

14 discriminating tests (P0-1..4, P2-1..10), 13/14 revert-probe-proven;
independent review re-ran 3 probes + audited the full descent for
under-reads (none). clippy -D warnings clean; 16762 passed / 0 failed.

Assisted-by: ClaudeCode:claude-opus-4.8
matthewevans pushed a commit that referenced this pull request Jul 21, 2026
…cuts at the phase boundary (#6238)

* feat(engine): add firewall ScanMode split + descend token/mana blankets (CR 732.2a)

CORE Unit-1 (P0+P2) of the loop/combo detector. Introduces
ScanMode {Conservative, LoopFirewall} in ability_scan so the CR 732.2a
loop-shortcut firewall reads token/mana ability bodies precisely
(LoopFirewall) while the CR 603.3b trigger-ordering gate keeps its
conservative answer (Conservative). Off is byte-identical (#4603): all
existing pub(crate) entries pass Conservative, unchanged.

P0: ScanMode enum + minimal mode thread (6 fns on the path to the
Effect::Token/Mana leaves) + LoopFirewall _for_loop entries.
P2: Token/Mana descend under LoopFirewall; new exhaustive scanners
scan_continuous_modification (53), scan_keyword (198, reuses
keyword_cost_reads_growing_class), scan_mana_production (15, board
aggregates self-assert sibling), scan_pt_value. resource.rs firewall
descends per-modification on sibling||projected (M9, both axes).

14 discriminating tests (P0-1..4, P2-1..10), 13/14 revert-probe-proven;
independent review re-ran 3 probes + audited the full descent for
under-reads (none). clippy -D warnings clean; 16762 passed / 0 failed.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): capture activated-ability loops for CR 732.2a shortcut offers

Extend the loop-shortcut detector to capture repeated activated abilities alongside recasts, unifying both under a flat LoopAction {Recast, Activate} carried by LoopActionContext (renamed from RecastContext, with #[serde(from)] back-compat so pre-rename combo saves still load). This is the REACH milestone: activated-ability loops now capture, drive, and sustain; the offer path itself is exercised by an #[ignore]d canary pending the P3 firewall-precision change (Presence of Gond + Intruder Alarm).

- game/engine.rs: Activate capture arm + action-agnostic drive/normalize/offer hook
- types/game_state.rs: LoopAction + LoopActionContext + LoopActionContextRepr serde migration + discriminating round-trip test
- game/casting_costs.rs: action-agnostic loop-context setter
- analysis/resource.rs: object-growth cover conjuncts over the renamed context
- tests/integration/loop_shortcut_activation.rs: capture/sustain canary + sustain-failure negative (offer test #[ignore]d, P3-load-bearing)

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): object-growth census firewall for the CR 732.2a loop-shortcut detector (P3-B)

The loop-shortcut detector certifies a game loop as no-net-progress so it can be
shortcut (CR 732.2a). A mass-battlefield read — a resolver that enumerates the
battlefield and applies to every matching object, scaling with board growth — can
escalate each iteration, so relaxing its census read yields a false combo
certificate. Add the census firewall: two exhaustive no-wildcard classifier
oracles (effect_target_ctx → LiveBoardCensus, effect_census_role → Census) tag the
28 mass-population effects (incl. 8 dual-mode resolvers: PhaseOut/PhaseIn,
GainActivatedAbilitiesOfTarget, BecomeCopy, TurnFaceUp/Down, MultiplyCounter,
CopyTokenBlockingAttacker); guard#3 + census_partition pin the 28-set; a durable
read_dir forward-guard asserts the mass-scan-idiom file set == a curated 14-file
classification, each census file tied to the oracle. Canary coverage (Sprout
Swarm, Intruder Alarm, token-storm) confirms offer-when-sound / veto-when-growing.

CR 732.2a, 702.26, 611.2c, 707.2, 708.2/708.2a, 701.10, 508.1.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): classify upstream ArrangePlanarDeckTop + RetainAllOtherAbilitiesFromSource in the CR 732.2a census firewall

Rebase adaptation onto upstream/main. Two new upstream Effect/ContinuousModification
variants broke the loop-shortcut census firewall's exhaustive no-wildcard matches
(the design catching drift at compile):

- Effect::ArrangePlanarDeckTop (#6070, Susan Foreman) reorders the planar deck top
  (Planechase), not a battlefield population, so it is not a live-board census:
  classified relax (SnapshotOrEvent / CensusRole::Relax) in both effect_target_ctx
  and effect_census_role, keeping their Census sets byte-identical
  (census_partition invariant holds). Not a mass-scan-idiom resolver, so guard#3's
  census tag-set and the durable CLASSIFIED const are unchanged.
- ContinuousModification::RetainAllOtherAbilitiesFromSource (#6009, Sakashima) is a
  copy-layer ability-retention mod with no inner walker, same class as
  RetainPrinted{Trigger,Ability}FromSource: Axes::CONSERVATIVE (fail-closed).

CR 732.2a.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): drive multi-action loop shortcuts for infinite-mana combos (CR 732.2a)

Generalize the CR 732.2a loop-shortcut detector from single-action recast/
activate loops to multi-action sequences, so real two-activation mana engines
(Basalt Monolith + Power Artifact) are detected and OFFERED as an
Advantage-status shortcut.

- game_state: last_loop_action_context (Option) -> last_loop_action_sequence
  (Vec<LoopActionContext>); empty = unarmed. Adds LoopAction::is_voluntarily_
  repeatable (CR 601.2a/602.2/605.3a) and a serde shim migrating
  null/single-object/array to Vec (aliases last_recast_context /
  last_loop_action_context).
- engine: accumulate_loop_action_step (controller-reset + 16-step cap); mana
  and else arms seed/accumulate/clear; drive_loop_sequence_iteration loops the
  existing single-action drive; STEP-D voluntary-repeatability gate at the
  offer site; materialize drives N real cycles (per-cycle break-on-err
  defensive floor, offered-finite-loop class measured empty).
- casting_costs: recast capture writes a 1-element Vec gated
  !in_simulation_probe() so detection/materialize clone-drives never pollute
  the compared sequence (cover-compare invariant).
- resource: last_loop_action_sequence excluded from GameState PartialEq but
  explicitly compared where recurrence identity requires it.
- tests: Basalt+Power acceptance, accumulation, materialize Fixed(1)/Fixed(5),
  interruptibility pass-vs-respond, Off byte-identity, serde migration, and the
  cond-A non-targeted-opponent depletion no-op probe (12 new tests + field
  rename adaptations across existing loop_shortcut suites).

Assisted-by: ClaudeCode:claude-opus-4.8

* test(engine): interruptibility matched pairs for object-growth + activation loop shortcuts (CR 732.2a)

Add matched discriminating pairs proving the CR 732.2a loop-shortcut offer is
genuinely interruptible for two more banked combos: an opponent holding a real
defuse GRANTS the shortcut when it passes and gets NO grant beyond the stack
when it responds -- the pass-vs-respond is the sole delta and flips the outcome.

- object-growth (Witherbloom, the Balancer + Sprout Swarm): opponent holds
  Murder; passing while Sprout is on the stack (CR 601.2i / CR 117.3c) offers
  the shortcut, while Murdering Witherbloom in that window removes granted
  affinity so the convoke-only {4}{G} recast is unpayable -> no offer
  (arm_murder + sprout_swarm_scenario_with_murder, n_fodder pinned to the
  no-affinity arithmetic).
- activation (Presence of Gond + Intruder Alarm): opponent holds Disenchant;
  passing offers, while Disenchanting Intruder Alarm on the {T} stack window
  (CR 602.2a) leaves the host tapped so the 2nd activation is illegal -> no
  offer (ported arm_disenchant / place_in_hand + intruder_alarm helper).

Each defused arm carries reach-guards (enabler destroyed AND the loop's own
action still occurred: a Saproling / one Elf made) so the no-offer is the
enabler-removal break, not a vacuous upstream failure. Non-vacuity is
revert-probed: swapping the opponent's defuse cast for a pass flips the
terminal state back to LoopShortcut{proposer:P0}.

Assisted-by: ClaudeCode:claude-opus-4.8

* test(engine): combo-4 Vito-drain interruptibility matched pair + untap-window CR fix

CR 732.2a loop-shortcut interruptibility matched pair for the Vito + Sanguine
Bond + Bloodthirsty Conqueror drain combo. Undefused: opponent holds Murder but
passes -> loop settles -> LoopShortcut{proposer:P0} offered (win_kind=LethalDamage,
mandatory=false). Defused: opponent responds Murder->Bloodthirsty Conqueror (the
single load-bearing closer; Vito/Sanguine are redundant drainers) at the pre-offer
drain-on-stack priority window -> closer destroyed -> the two in-flight drains
resolve for exactly -2 (no re-gain) -> empty stack -> no offer. The opponent's
pass-vs-respond is the sole delta and flips offer<->no-offer (revert-probe measured
by the executor, independently re-measured byte-identical by review-impl).

Also corrects the untap-on-stack window citation in the mana-engine pair:
CR 605.3b (mana-ability-off-stack, the wrong rule for a non-mana ability) ->
CR 602.2a (activated ability created on the stack). CR 605.3b retained for the
mana beat's no-window contrast; line-613 cost-fuel 602.2b left untouched.

Test-only; no engine logic changed. HELD on the fork branch (not for push/PR).

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): zone-gate combo loop-firewall observer scans (CR 603.4 / CR 113.6)

The CR 732.2a loop-shortcut detector's board-recurrence firewalls scanned
abilities in ALL zones when deciding whether a live observer reads the growing /
projected class. A permanent trigger / static / replacement on a card in the
LIBRARY (an inert deck card) cannot function (CR 603.4 / CR 113.6) yet was
treated as a live observer, so the recurrence cover was rejected and the
interactive combo shortcut was never offered.

Reproduced from a real 4-player game: Witherbloom, the Balancer + Sprout Swarm
(infinite Saprolings) failed to prompt because Kodama of the East Tree sitting in
the player's LIBRARY was scanned as an observer of the growing creature class.

Fix: gate every observer scan on zone-of-function, reusing canonical predicates:
- triggers     -> triggers::trigger_definition_functions_in_zone (now pub(crate))
- statics      -> functioning_abilities::static_functions_in_zone
- replacements -> [Battlefield, Command] (find_applicable_replacements scope)

Sites fixed in analysis/resource.rs:
- fire_time_conditions_read_growing_class: (1) triggers, (3) replacements, (4) statics
- fire_time_conditions_read_projected_resource: (i) triggers, (ii) replacements, (iii) statics
- life_event_replacements_may_prompt (drain-cover life-prompt firewall)
- cost_surface_references_growing_class: skip Library (never a cost source) and
  zone-gate the static cost-mod sub-scan; the HAND surface stays (the loop's own
  recast spell rides there)

Adds discriminating regression test object_growth_library_observer_does_not_
suppress_offer; revert-probe verified (disabling the block-(1) gate flips it to
no-offer -> "got Priority").

Verified: analysis units 270 pass, loop_shortcut 66 pass, combo-verify corpus
13 confirmed / 4 gated / 37 deferred / 0 failed, engine lib clippy clean.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): apply ∞ status on loop-shortcut accept instead of driving N iterations

CR 732.2a: accepting an unbounded object-growth (fodder/token) or mana-engine loop
shortcut now marks the certificate's unbounded axes via the shared mark_unbounded_loop
writer — the same path the reconcile/determinate crown uses — rather than replaying N
discrete iterations. The old drive was O(N) (~0.4s per token; 212s for 500 Saprolings)
and capped the 'infinite' at N; the frontend echoed the schema's Fixed(1) verbatim, so
accepting only ever minted ONE token.

Now accept APPLIES the infinite status: unbounded_resources gains the certified axes,
so the infinity HUD badge projects and (for mana) refill_infinite_mana holds the pool
at INFINITE_MANA_PER_TYPE. Zero objects are minted at accept; the finite count is named
later at the CR 500.5 phase/step boundary (follow-up: boundary finite-resolution prompt).

Measured on the reported 4-player state: accept marks {P0: {TokensCreated}}, +0
Saprolings (was +1..+500), 1.48s vs 212s. Mana engine: pool topped to 100,
count-independent (+98 for Fixed(1) and Fixed(5)).

Part 1 of the infinite-status lifecycle (mark on accept). Part 2 (CR 500.5 boundary
finite-resolution: prompt finite N for persistent axes, empty transient mana) follows.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(combo): render accepted object-growth loop's ∞ pile on the battlefield

DESIGN step 4 of the CR 732.2a combo detector. When a player accepts an
object-growth loop shortcut, the engine already marked the per-player ∞
status + HUD badge, but the per-object battlefield pile was never rendered —
so tapped fodder tokens (e.g. Saprolings) showed ×N instead of ∞ in a live
game. This builds the missing rendering.

Engine:
- Snapshot the winning controller's tapped fodder-class members into a new
  `unbounded_loop_pile` map at loop-accept, re-derived via a one-period drive
  on a throwaway clone (live state untouched — shared-borrow signature).
- Excluded from PartialEq / normalize_for_loop / loop_fingerprint so display
  state cannot perturb CR 104.4b / CR 732.2a loop-detection equality.
- Project to `DerivedViews.unbounded_pile` (filtered by battlefield membership,
  CR 110.1), threaded through the multiplayer `filter_state_for_viewer` path.

Frontend (display-only): render `∞` count-independently via pure Set-membership
on `derived.unbounded_pile` across all three identical-permanent-group surfaces —
main board, opponent board-peek popover, and combat attack-target picker.

Tests: real-4p load-dump + build-fresh acceptance tests assert the pile equals
the controller's tapped fodder members (non-circular check; register revert-probe
flips both); frontend specs render ∞ vs ×N discriminatingly on every surface.

Known gap (deferred to a separate unit, disclosed in-code at select_convoke_taps):
the object-growth detection replay's convoke tap-selection (lowest-ObjectId-per-
color) can tap an untapped green cost-reducer instead of fodder, suppressing the
offer when the reducer is untapped at cast time.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): refill realized-infinite mana only in the recorded colors

Basalt Monolith + Power Artifact makes infinite COLORLESS mana, but a real
4-player game showed the pool flooded with 100 units of every color. The
combo detector had correctly recorded `unbounded_resources = [Mana(Colorless)]`;
the bug was downstream in `refill_infinite_mana`, which flagged on any
`Mana(_)` axis and then unconditionally topped up all six `INFINITE_MANA_TYPES`
— fabricating W/U/B/R/G that no ability in the loop ever produced, and
illegally enabling colored-pip payment from a colorless-only engine.

CR 106.1b (six distinct mana types; colorless is its own type) + CR 106.4
(only mana an effect actually adds enters the pool): refill now tops up only
the mana colors present in that player's recorded axes. The debug
`SetInfiniteMana` toggle stores all six axes, so its output is byte-identical;
a color-specific loop (colorless) now refills colorless only.

Tests (real-4p load-dump + unit): the real Basalt dump refills colorless-only
with zero fabricated colors; a subset axis refills only its color; the all-six
debug axis still refills all six (over-narrowing guard). Revert-probe confirmed:
the pre-fix all-six body fails the first two and passes the third.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): tap fodder first in the loop-detection convoke replay

The CR 732.2a object-growth loop detector replays the loop and checks net
board growth. Its convoke tap-selection (`select_convoke_taps`) picked the
lowest ObjectId per color, so an untapped green cost-reducer with a lower id
than the fodder tokens (e.g. Witherbloom, the Balancer below the Saprolings)
got convoke-tapped instead of fodder. That tapped the stable engine in the
replay, drifting the board-cover check, so the loop offer never surfaced —
observed live: with Witherbloom untapped, casting Sprout Swarm produced no
offer; tapping Witherbloom first made it fire.

CR 702.51a (convoke lets a player choose which creatures to tap) + CR 732.2a
(a legal, predictable loop sequence): a sustaining loop taps its fodder, not
its engine. Add `ConvokeTapOrder{Canonical, DetectionFodderFirst}`; the sole
production caller `resolve_pin(ConvokeTaps)` passes `DetectionFodderFirst`
(tokens/fodder first, then lowest id). The order is local to resolve_pin and
does not thread through `resolve()` — `select_convoke_taps` has exactly one
caller and live/AI/human convoke (the `TapForConvoke` path) never touches it,
so live gameplay is byte-unchanged. Fodder-first is preference-with-fallback:
the picker still taps the engine if fodder cannot cover a colored pip.

Tests: the real untapped-Witherbloom 4p playtest dump now surfaces the
LoopShortcut offer; two selector discriminators distinguish the modes (mixed
board: fodder-first taps the token, Canonical the nontoken); the four existing
unit tests keep asserting lowest-id under Canonical. Revert-probe: forcing
Canonical, or neutering the fodder-first sort, flips the real-dump offer to
no-offer.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(combo): collapse an accepted object-growth loop into N tapped tokens at the boundary

Part 2 of the CR 732.2a combo detector. Part 1 marks the infinite status and
renders the pile but mints zero objects. This adds the payoff: when a player has
accepted an object-growth loop shortcut, the next phase/step boundary prompts the
loop controller for a finite count N (CR 500.5 boundary, reusing the
PayAmountChoice machinery), mints N concrete tapped tokens, and ends the infinite
status.

Engine:
- New `PayableResource::LoopCollapse` (unit variant) drives the boundary prompt;
  the mint arm early-returns and mints N tapped copies of the captured fodder
  profile via the copy-token path (CR 111.10 + CR 707.2 — the token is a copy of
  the loop fodder's copiable values).
- Capture the fodder's `CopiableValues` at loop-accept (where the fodder class is
  already derived and the loop sequence is still intact) into a new
  `pending_unbounded_materialization` stash — excluded from the loop-equality
  family like the rest of the unbounded_* state.
- A second collapse pass in `drain_pending_phase_transition_progress` runs after
  the CR 500.5 mana-empty APNAP drain, in APNAP order, leaving the empty phase
  progress intact so the post-mint re-drain restores priority in one action.
- `clear_unbounded_token_loop` clears only the `TokensCreated` axis (+ stash +
  token pile), preserving any coexisting axis (e.g. a debug infinite-mana axis)
  and the loop enablers — distinct from the whole-player `clear_unbounded_loop`.
- AI names N=1 (a conservative default that bounds search against the 1000-cycle
  MAX_SHORTCUT_CYCLES cap).

Frontend (display-only): render the LoopCollapse prompt through the existing
PayAmountChoice UI (types union + switch arm + i18n across all 7 locales).

Tests (real-4p load-dump + building-block, each revert-probed):
- T1 drives the real Sprout Swarm 4p dump accept -> boundary -> prompt ->
  SubmitPayAmount{5} -> 5 tapped Saprolings -> infinite cleared -> no re-prompt
  (revert-probe: no collapse pass -> no prompt).
- T2 the real Basalt mana dump does NOT prompt at the boundary (mana writes no
  stash; the collapse is token-only, the mana boundary-empty is deferred).
- T5 the axis-scoped clear preserves a coexisting mana axis (revert-probe: the
  whole-player clear wrongly wipes it).
- An AI-seam test asserts N=1 (revert-probe: the default explodes to 1001).

The CR 500.5 boundary mana-empty for realized-infinite mana is deferred (it
entangles with the debug infinite-mana toggle) and documented as a follow-up.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): drain loop-backed infinite mana at the CR 500.5 boundary

Basalt Monolith + Power Artifact (and every infinite-mana combo) produced a
realized-infinite mana pool that persisted across step/phase boundaries: the
pool kept refilling because `keep_for_infinite_mana` retained mana for ANY
player carrying an `unbounded_resources` `Mana(_)` axis, which cannot tell the
developer `SetInfiniteMana` debug toggle apart from a detected/accepted loop
(their footprints are identical). So a loop the engine correctly marked infinite
also stayed infinite forever, contrary to CR 500.5 (unspent mana empties as a
step or phase ends).

Add a provenance discriminator and drain the loop-backed case:

- New `GameState.debug_infinite_mana: BTreeSet<PlayerId>` marks the players
  whose `Mana(_)` axes come from the `SetInfiniteMana` debug toggle. Written
  only by that debug handler (insert on enable, remove on disable). Excluded
  from `PartialEq` / `normalize_for_loop` / `loop_fingerprint` like the rest of
  the `unbounded_*` display state; `#[serde(default)]` keeps old dumps loading.
- Scope the keep-gate (turns.rs) to the debug marker instead of "has a Mana
  axis": a loop-backed pool now drains at the boundary (CR 500.5); the debug
  toggle still persists.
- New axis-scoped `clear_unbounded_mana_loop` de-realizes the drained Mana axis
  at the boundary so `refill_infinite_mana` cannot re-seed it, dropping the
  player's `unbounded_loop_enablers` in lockstep iff the axis set empties
  (CR 104.4b / CR 110.1) — a coexisting non-Mana axis (a Path-C `{Mana, Counter}`
  cover) keeps its enablers. Mirrors `clear_unbounded_token_loop`; mana carries
  no pile/stash (a mana engine reproduces no fodder).
- The boundary clear runs BEFORE the token-collapse pause so a player holding
  both a mana loop and a token loop has its mana axis cleared before the
  `SubmitPayAmount` re-drain, preventing a refill re-seed.

The debug `SetInfiniteMana` toggle is unchanged and still yields persistent
all-color infinite mana for playtesting.

Tests (real-4p load-dump + building-block, each revert-probed):
- Loop-backed mana drains + de-realizes at the boundary on the real Basalt dump
  (colorless 100 -> 0, Mana axis removed); reverting the axis-clear lets refill
  re-seed -> flips.
- The debug toggle persists (multi-authority: a player both debug-toggled and
  loop-backed keeps mana -- debug dominates).
- The axis-scoped clear preserves a coexisting Counter axis + enablers
  (lockstep-iff-empty) and drops both when only the Mana axis remains.
- A coexisting mana+token boundary drains the mana and still collapses the token
  loop; moving the clear after the token check re-seeds the pool -> flips.
- The `SetInfiniteMana` handler records/removes the marker (guards the sole
  production writer).

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): iterative liminal copy-token mint + reword LoopCollapse prompt

Bug B (stack overflow): the liminal-immediate copy-token batch drove one
mutually-recursive frame per token (commit_liminal_..._and_continue_copy_batch
-> continue_liminal_copy_token_batch -> apply_copy_token_after_replacement),
each frame a large im::HashMap COW insert, so minting large N overflowed the
stack (~200 tokens in WASM's smaller stack). Make the batch iterative (O(1)
stack) like the sibling non-liminal branch, preserving every pause/resume path
(counter-pause, post-replacement-drain, NeedsChoice, Prevented, terminal drain).
The terminal step COMPUTES Completed-vs-Paused instead of hardcoding Completed,
so multi-source copy effects don't double-mint when a later batch pauses on a
replacement choice.

Bug A (wording): the LoopCollapse token-count prompt no longer frames the
choice as 'pay N tokens'; it reads as choosing how many tokens to create from
infinite (display-only, all 7 locales).

CR 603.7 + CR 701.36a (created-token id ledger), CR 707.2 (copy), CR 616.1 (replacement choice), CR 732.2a (loop shortcut collapse).

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): materialize the tapped infinite pile when convoke taps a non-fodder creature (CR 732.2a)

When the Sprout Swarm + Witherbloom, the Balancer convoke loop is demonstrated by
tapping Witherbloom (a non-fodder creature) instead of a Saproling, the CR 732.2a
object-growth shortcut was granted but no infinite tapped Saprolings materialized —
the infinite pile was built from live tapped fodder, which is empty when convoke
tapped Witherbloom rather than a token.

Fix: at accept, when the certified period actually taps a fodder creature each cycle
(period.taps_fodder) and the live board has no tapped fodder yet, seed a tapped
representative Saproling (the infinite-pile anchor, CR 111.1 + CR 110.5b) and an
untapped representative (the +1 remainder left by the final non-convoke-paid cast,
CR 702.51a). taps_fodder is measured on the same clone-drive the cover check already
runs, so it discriminates a convoke/tap-cost growth loop (seed) from a pure
untapped-partition growth loop (no seed) — closing the over-fire the cover's `>=`
admits. register_unbounded_loop_pile/register_pending_materialization stay ungated.

Board after accept: 6 untapped Saprolings + a tapped infinite pile + Witherbloom
tapped, cashing out to N tapped + 6 untapped at the loop boundary (CR 707.2 — tapped
status is set explicitly, not copied). Boundary handler byte-unchanged.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): fire the Kilo proliferate combo by replaying pinned in-cycle decisions (CR 732.2a)

The live loop-shortcut detector failed to offer the infinite-charge shortcut for the
Kilo, Apogee Mind + Freed from the Real + Relic of Legends + Pentad Prism proliferate
combo — a mana-neutral, +1-charge/cycle unbounded loop (WinKind::Advantage, CR 104.4b).
Three fixes make it fire from a real game:

- FIX-1: record & replay the three fixed in-cycle choices (tap-target, mana-color,
  proliferate-target) via LoopActionContext.pins and a new PinnedDecision::ManaColor
  (CR 608.2d — choices announced while applying an effect), threaded through the
  decision-template schema, drive beat arms, viewer redaction, trigger ordering, the
  server-core payload guard, and the frontend DecisionPointKind union.
- FIX-2: wire loop_states_cover_modulo_counter_growth into the object-growth shortcut's
  empty-cover arm so the +1-charge/cycle growth certifies as an unbounded loop.
- FIX-3: conditional load-migration (GameState::migrate_transient_loop_sequence) drops a
  loaded save's stale pinless loop-history unless waiting_for is a shortcut window
  ({LoopShortcut, RespondToShortcut}), so a pre-fix save fires the offer promptly on
  reload without corrupting an offer-save's pinned sequence.

Acceptance is driven from the real 4-player playtest dump through the production
into_game_state chokepoint (the "combo fires in a real game" criterion), with an
interruptibility matched pair (undefused -> grant / Freed removed -> no grant) plus
identity-binding and mana-color pin-replay hostile tests.

Assisted-by: ClaudeCode:claude-opus-4.8
(cherry picked from commit dc7cc134017f7392fc8b94b320d5f852dd06dbea)

* fix(engine): render infinite on a permanent's counter when a counter-growth loop is accepted (CR 732.2a)

Accepting the Kilo, Apogee Mind + Freed from the Real + Relic of Legends + Pentad
Prism proliferate loop (a +1-charge/cycle counter-growth loop, CR 701.34a) granted
the infinity HUD badge but left Pentad Prism's charge counter rendering its literal
value. Counter-growth marked only the per-player `unbounded_resources` axis, with no
per-object projection (object-growth has the `unbounded_pile` channel that renders
infinity on the token objects, but the counter axis `Counter(Other, Other)` is
object-agnostic — the object id is not recoverable from it).

Add a display-only per-object unbounded-counter channel mirroring `unbounded_pile`:
- `GameState.unbounded_counter_targets` (BTreeMap<PlayerId, BTreeSet<(ObjectId,
  CounterType)>>) — display state, EXCLUDED from loop-equality/normalize/fingerprint
  exactly like `unbounded_loop_pile`, guarded by a revert-probed exclusion test.
- Populated at `materialize_object_growth_shortcut` accept by RE-DERIVING the grown
  (object, counter_type) pairs: driving one period on a throwaway clone and diffing
  Generic counters (`grown_generic_counter_targets`, sharing the single-source
  `generic_counter_is_growable` with `classify_generic_counter_growth`). General over
  the class (One Ring burden, etc.), not proliferate-only.
- Projected to `DerivedViews.unbounded_counters` (mirrors the per-object
  `battlefield_keyword_badges` channel) -> `PermanentCard` renders the infinity glyph
  in place of the count on the matching counter pill.

Pentad's real counter count is never mutated (rules-correct; the infinity is
display-only, CR 122.1). Driven from the real 4-player dump through the production
accept path; the shared-drive extraction from `current_period_fodder` is
byte-preserving, so the Sprout convoke-fix tests stay green.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): skip provably-disjoint ETB observers in the object-growth loop firewall (CR 603.6a)

The CR 732.2a object-growth loop-shortcut offer was suppressed in realistic
multiplayer games whenever an opponent's Eminence commander (e.g. Inalla,
Archmage Ritualist) sat in the command zone. The observer firewall
fire_time_conditions_read_growing_class scans ETB observers in all zones and
vetoed on Inalla's CopyTokenOf body — even though its entry matcher ('another
nontoken Wizard you control', controller = the opponent) can never match the
loop's Saproling fodder (CR 603.6a checks the entering permanent against the
matcher).

Gate block(1) to skip an ETB observer whose entry matcher PROVABLY excludes the
growing fodder class, via new game::triggers::etb_observer_provably_excludes_class
(composing the same trigger_matchers::valid_card_matches used at fire time).
Fail-closed: a broad (no valid_card), disjunctive (zone_change_clauses),
non-battlefield-destination, or genuinely-matching observer still vetoes.
Soundness rests on the cover gate (board_covers_modulo_fodder, all-zones
content-equality) preceding the firewall, so the fodder is the only per-cycle
battlefield entrant.

Real 4p driven test (live cast via apply(), not load-then-inspect): the Sprout
Swarm + Witherbloom loop now OFFERS despite the opponent's command-zone Inalla;
a matched negative proves a broad matching observer still vetoes.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(frontend): render infinite on the art-crop counter pill for accepted counter-growth loops (CR 732.2a)

The art-crop battlefield display mode (ArtCropCard, battlefieldCardDisplay ==
"art_crop") rendered the raw finite counter count with no unbounded-counter
subscription, so an accepted CR 732.2a counter-growth loop showed the finite
count (e.g. "2" charge) instead of the infinity glyph the full-card
PermanentCard mode already renders from derived.unbounded_counters. Subscribe
to derived.unbounded_counters here too and render infinity for a counter the
engine marks unbounded. Matched-pair component test guards the render flip
(marked => infinity, unmarked => count) so a future missed render site fails.

Assisted-by: ClaudeCode:claude-opus-4.8

* feat(engine): collapse persistent-axis ∞ loops to a finite N at the phase boundary (CR 732.2a)

When a counter / life / token growth loop is accepted as unbounded (CR 732.2a), defer its
persistent axes to the next CR 500.5 phase/step boundary, where the loop controller is
prompted (PayAmountChoice { LoopCollapse }) to name a finite N and the axes resolve to
exactly that many. Fixes the reported bug where an accepted ∞-counter loop was never
prompted to a finite number on moving to combat.

- Persistent-axis materialization stash (PersistentAxisMaterialization: Tokens / Counters /
  Life / DriveSequence). Unobserved axes batch an O(1) N×δ apply; observed axes replay the
  captured period N times through real apply() so per-cycle observers fire (CR 701.34a
  proliferate, replacement doublers). Transient mana drains at the boundary.
- Per-axis observation firewall (counter_growth_is_observed / life_growth_is_observed): a
  coarse axis-agnostic firewall mis-routed on a real 4p board carrying an incidental life
  observer. Re-checked at submit so an observer that drifts in during the accept→boundary
  window declines only its own axis (apply_counter_addition bypasses replacements; CR 732.2b
  never forces a shortcut).
- O(1) iterative mint replaces the recursive O(N)-depth copy path that overflowed the WASM
  stack at ~N=200.
- Regression tests drive the real accept-time registration end-to-end on real 4p dumps,
  including kilo_accept_collapses_at_boundary_to_exactly_n_counters (Kilo proliferate: accept
  ∞ → prompted at the boundary → SubmitPayAmount{5} → exactly +5 charge, pill cleared; a
  revert-probe on the DriveSequence registration FLIPS the boundary prompt).

Assisted-by: ClaudeCode:claude-opus-4.8

* chore(engine): compile-drift adaptation for rebase onto upstream/main

Mechanical/semantic adaptations to upstream API changes surfaced by cargo check
after replaying the combo-detector series onto upstream/main:
- resource.rs board_has_event_observer: adopt upstream ActiveTriggerDefinition
  iterator form (active.definition) — third active_trigger_definitions caller;
  CR 603.4/113.6 zone-gate preserved.
- ability_scan.rs: thread ScanMode/FilterReadContext through scan_player_filter
  and scan_target_filter; add ContinuousModification::SetTextName axes arm
  (CR 612.8/613.1c, sibling of SetChosenName — reads no board aggregate).
- triggers.rs etb_observer_provably_excludes_class: project the live functioning
  source via trigger_source_context_for_latch to match upstream's LKI-by-
  incarnation valid_card_matches source-context refactor; fail-closed.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): label the LoopCollapse collapse-count prompt by its growth axis (CR 732.2a)

The finite-count prompt for an accepted CR 732.2a object-growth loop always read "tokens" even for counter/life loops. Add a display-only `LoopCollapseAxis { Tokens, Counters, Life, Mixed }` field on `PayableResource::LoopCollapse`, derived at the CR 500.5 phase/step boundary from the controller's pending-materialization stash via `LoopCollapseAxis::from_materializations` (exhaustive over all 17 `ResourceAxis` arms, no wildcard; the flagship Kilo combo's observed-growth `DriveSequence` counter axis is mapped).

The frontend `PayAmountChoiceUI` and all 7 locales now select axis-correct title/button keys. Counter/life labels are iteration-framed ("x{{value}}"), never a raw resource count: N is the loop's cycle count and each cycle applies per_cycle_delta, so N tokens but Nxdelta counters/life. The axis is a pure display descriptor -- the submit handler ignores it and resolves growth from the typed stash, so a stale label can never mis-resolve.

Tests: discriminating T1-T4 (counter/token/life/mixed) + a `from_materializations` unit test + a frontend render test + the flagship Kilo real-dump end-to-end `axis == Counters` assertion, each revert-probed.

add-engine-variant scope: display-only descriptor on an existing variant (no new sibling); cross-CR unification lives only at the display layer, allowed.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(wasm): enlarge the shadow stack to 16 MiB so the CR 732.2a loop replay can't overflow

Executing the Kilo, Apogee Mind + Relic of Legends + Freed from the Real +
Pentad Prism proliferate/charge loop dropped the engine ("connection lost").
Root cause: the accept-time loop-replay recursion (drive_persistent_axis_collapse
-> drive_loop_sequence_iteration -> apply) has a huge by-value GameState/
ActionResult frame; one loop period's replay needs 1-2 MiB of release-optimized
stack (measured: SIGABRTs a 1 MiB thread, survives 2 MiB), but the shipped WASM
had only wasm-ld's default 1 MiB shadow stack (17 memory pages) while the engine
assumes native's 16 MiB (RUST_MIN_STACK). It overflowed on replay cycle 1 in the
browser but never natively.

Give wasm32-unknown-unknown a matching 16 MiB shadow stack via a wasm-ld
-z stack-size link-arg in .cargo/config.toml. Proven in V8/node: the runtime
shadow stack scales exactly with the flag. No download-size change; cost is a
one-time ~15 MiB linear-memory reservation per module.

Guard: scripts/build-wasm.sh asserts the shipped engine_wasm_bg.wasm declares
initial memory min >= 200 pages, so a rebase silently dropping the config line
fails the build loudly (with flag: 365 pages; without: ~17). deploy.yml's
build-wasm job gains an explicit setup-node for the guard's node parser.

The throwaway native crash-repro is removed: a native test runs at
RUST_MIN_STACK=16 MiB and ignores -z stack-size, so it cannot guard a WASM
link-arg; the build-side memory-min assertion is the discriminating guard.

Assisted-by: ClaudeCode:claude-opus-4.8

* ci(ai-gate): raise PR quick-gate timeouts to 60m

The Paired-seed and Decision-cost quick gates ran with timeout-minutes: 30,
which flaked out on slow hosted runners. Measured budget on a card-data cache
miss: the "Generate card data" step alone is ~6.5m (5m `tool` build + gen),
then a cold debug build, then the debug run — the paired gate drives 30 games
(~34m at slow-runner ~1.1m/game) and the perf gate runs 5 cold child processes
x 3 scenarios x 3000 actions (~21m). Hosted-runner speed varies ~2x, so a fast
runner finishes under 30m while a slow one overruns and the job is cancelled.

Raise both quick gates to 60m. Coverage and the fixed perf workload are
unchanged; the higher ceiling only bills the failure case (same rationale the
nightly drift monitor already documents). The per-counter median assertion
(#4878) stays variance-robust; only the wall-clock ceiling changes.

Assisted-by: ClaudeCode:claude-opus-4.8
lgray added a commit to lgray/phase that referenced this pull request Aug 1, 2026
… growing class does not observe a CR 732.2a loop (CR 608.2i)

The CR 732.2a loop-shortcut firewall's growing-class axis vetoes any fire-time
condition that reads a mutable sibling surface. Block (1)'s `execute` scan
vetoed on the read alone, with no look at WHAT the read counts: a trigger whose
body is gated on `BattlefieldEntriesThisTurn { filter }` where the filter
provably cannot match the growing class reads a value that is INVARIANT across
the loop's growth, so it does not observe the loop (CR 608.2h + CR 608.2i).

Narrowing is four fail-closed conjuncts, all required, in
`execute_ledger_condition_provably_excludes_class`:

  (0) `activation_restrictions` must be empty — the firewall is blind to them,
      so a restricted ability fails closed rather than being reasoned about.
  (a) sole-source: a single-field clone with `condition: None` must NOT still
      read a sibling-mutable surface, i.e. the ledger condition is the ONLY
      such read in the ability. No visitor (phase-rs#4603's refusal stands).
  (b) shape: exactly `QuantityCheck { lhs: Ref(BattlefieldEntriesThisTurn),
      rhs: Fixed }` at a single level; everything else `=> false`.
  (c) exclusion: `battlefield_entry_matches_filter` must reject BOTH the
      synthesized entry record for the live class member AND every real
      recorded entry for it. Fail-closed if the member is absent from the
      scanned frame.

MEASURED DELTA, with its population predicate: on today's
`data/card-data.json` the relief population is ZERO — no real card is relieved
by this narrowing. The three cards the directive names are relieved by the
foreign-controller / phase-unreachability narrowings in the two preceding
commits, not by this one. 2c ships a SHAPE, as scaffolding, with that null
delta disclosed rather than papered over. Its soundness residual is likewise
measured and disclosed in the predicate's doc: of `BattlefieldEntryRecord`'s
8 fields the fodder relation (`object_content_eq`, 32 compared fields) covers
only `name` + `controller`, and four of the five uncompared fields are read
verdict-bearingly by a live filter on today's pool — so the residual is
REACHABLE, not latent, and must be re-derived if the card pool is regenerated.

Also extracts `battlefield_entry_record_for` in `game/restrictions.rs` so the
read-only firewall can build an entry record without `&mut GameState`;
`record_battlefield_entry` now calls it, giving the 8-field list one authority.
Behaviour-identical (identical field list, `object_id: obj.id` is the one
mechanically-forced substitution for the removed parameter).

Assisted-by: ClaudeCode:claude-opus-4.8
lgray added a commit to lgray/phase that referenced this pull request Aug 1, 2026
… growing class does not observe a CR 732.2a loop (CR 608.2i)

The CR 732.2a loop-shortcut firewall's growing-class axis vetoes any fire-time
condition that reads a mutable sibling surface. Block (1)'s `execute` scan
vetoed on the read alone, with no look at WHAT the read counts: a trigger whose
body is gated on `BattlefieldEntriesThisTurn { filter }` where the filter
provably cannot match the growing class reads a value that is INVARIANT across
the loop's growth, so it does not observe the loop (CR 608.2h + CR 608.2i).

Narrowing is four fail-closed conjuncts, all required, in
`execute_ledger_condition_provably_excludes_class`:

  (0) `activation_restrictions` must be empty — the firewall is blind to them,
      so a restricted ability fails closed rather than being reasoned about.
  (a) sole-source: a single-field clone with `condition: None` must NOT still
      read a sibling-mutable surface, i.e. the ledger condition is the ONLY
      such read in the ability. No visitor (phase-rs#4603's refusal stands).
  (b) shape: exactly `QuantityCheck { lhs: Ref(BattlefieldEntriesThisTurn),
      rhs: Fixed }` at a single level; everything else `=> false`.
  (c) exclusion: `battlefield_entry_matches_filter` must reject BOTH the
      synthesized entry record for the live class member AND every real
      recorded entry for it. Fail-closed if the member is absent from the
      scanned frame.

MEASURED DELTA, with its population predicate: on today's
`data/card-data.json` the relief population is ZERO — no real card is relieved
by this narrowing. The three cards the directive names are relieved by the
foreign-controller / phase-unreachability narrowings in the two preceding
commits, not by this one. 2c ships a SHAPE, as scaffolding, with that null
delta disclosed rather than papered over. Its soundness residual is likewise
measured and disclosed in the predicate's doc: of `BattlefieldEntryRecord`'s
8 fields the fodder relation (`object_content_eq`, 32 compared fields) covers
only `name` + `controller`, and four of the five uncompared fields are read
verdict-bearingly by a live filter on today's pool — so the residual is
REACHABLE, not latent, and must be re-derived if the card pool is regenerated.

Also extracts `battlefield_entry_record_for` in `game/restrictions.rs` so the
read-only firewall can build an entry record without `&mut GameState`;
`record_battlefield_entry` now calls it, giving the 8-field list one authority.
Behaviour-identical (identical field list, `object_id: obj.id` is the one
mechanically-forced substitution for the removed parameter).

Assisted-by: ClaudeCode:claude-opus-4.8
lgray added a commit to lgray/phase that referenced this pull request Aug 1, 2026
… growing class does not observe a CR 732.2a loop (CR 608.2i)

The CR 732.2a loop-shortcut firewall's growing-class axis vetoes any fire-time
condition that reads a mutable sibling surface. Block (1)'s `execute` scan
vetoed on the read alone, with no look at WHAT the read counts: a trigger whose
body is gated on `BattlefieldEntriesThisTurn { filter }` where the filter
provably cannot match the growing class reads a value that is INVARIANT across
the loop's growth, so it does not observe the loop (CR 608.2h + CR 608.2i).

Narrowing is four fail-closed conjuncts, all required, in
`execute_ledger_condition_provably_excludes_class`:

  (0) `activation_restrictions` must be empty — the firewall is blind to them,
      so a restricted ability fails closed rather than being reasoned about.
  (a) sole-source: a single-field clone with `condition: None` must NOT still
      read a sibling-mutable surface, i.e. the ledger condition is the ONLY
      such read in the ability. No visitor (phase-rs#4603's refusal stands).
  (b) shape: exactly `QuantityCheck { lhs: Ref(BattlefieldEntriesThisTurn),
      rhs: Fixed }` at a single level; everything else `=> false`.
  (c) exclusion: `battlefield_entry_matches_filter` must reject BOTH the
      synthesized entry record for the live class member AND every real
      recorded entry for it. Fail-closed if the member is absent from the
      scanned frame.

MEASURED DELTA, with its population predicate: on today's
`data/card-data.json` the relief population is ZERO — no real card is relieved
by this narrowing. The three cards the directive names are relieved by the
foreign-controller / phase-unreachability narrowings in the two preceding
commits, not by this one. 2c ships a SHAPE, as scaffolding, with that null
delta disclosed rather than papered over. Its soundness residual is likewise
measured and disclosed in the predicate's doc: of `BattlefieldEntryRecord`'s
8 fields the fodder relation (`object_content_eq`, 32 compared fields) covers
only `name` + `controller`, and four of the five uncompared fields are read
verdict-bearingly by a live filter on today's pool — so the residual is
REACHABLE, not latent, and must be re-derived if the card pool is regenerated.

Also extracts `battlefield_entry_record_for` in `game/restrictions.rs` so the
read-only firewall can build an entry record without `&mut GameState`;
`record_battlefield_entry` now calls it, giving the 8-field list one authority.
Behaviour-identical (identical field list, `object_id: obj.id` is the one
mechanically-forced substitution for the removed parameter).

Assisted-by: ClaudeCode:claude-opus-4.8
lgray added a commit to lgray/phase that referenced this pull request Aug 1, 2026
…ative rulings (combo-fb phases 0-2, chain 1) (phase-rs#6838)

* feat(engine): retain loop-detection frames across forced pre-priority windows

A merely-riding observer suppressed the combo shortcut offer: the loop-detection
ring was cleared at two independent sites, so any forced pre-priority window
discarded every sampled frame and detection restarted from empty. Measured on a
real 4p dump: clears were {apply_action: 1152 at len 0, sampler: 16 at len 2} --
the sampler wiped a len-2 ring exactly once per 99-beat period.

- WaitingFor::is_forced_cascade_window() classifies the windows the engine forces
  before any player receives priority (CR 117.1) -- trigger ordering, trigger
  target selection, optional-effect choice, commander zone choice, the legend
  rule (CR 704.5j) and battle protector designation (CR 310.10, a state-based
  action). Fail-closed via '_ => false' over the remaining 121 variants.
- Both clear sites now honour the class. Fixing either alone yields zero
  retention, so the pair is the unit of change.
- ResourceVector gains the axes the multiplayer gain/drain proof needs;
  elimination_bounds now carries an explicit conditional-soundness contract
  naming the attribution precondition its max() form requires.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): fence the loop ring on CR 510.2 damage; widen the forced-window class to turn-based actions

Two coupled changes that land together on purpose: the widening is what makes
the fence necessary, so splitting them would leave an intermediate commit in
which a retained frame pair can straddle unobserved combat damage.

Widening (CR 703.1 / CR 117.3a). is_forced_cascade_window() gains the seven
turn-based-action windows the engine forces before any player receives
priority: untap choice, bounded untap subset, declare attackers, exert,
enlist, declare blockers, cleanup discard. Measured on a real 4-player dump,
the ring was force-cleared exactly once per 99-beat turn period at
declare-attackers, capping it at 2 frames; the widened class reaches 13.
Retention across turns is necessary but NOT yet sufficient for the cross-turn
shortcut CR 732.2a contemplates -- loop_states_equal still compares
turn_number -- and the docs now say that rather than claiming otherwise.

Damage fence (CR 510.2 / CR 119.3). The class doc excluded AssignCombatDamage
as life-moving, but that window opens only when a damage-division choice is
required. An unblocked attacker deals damage with no window at all, so the
exclusion protected nothing once declare-attackers became exempt. The
prohibition is now keyed on the damage EVENT: one guard in the shared
apply_combat_damage, placed after the prevention riders so it catches Phase C
damage, lifelink and CR 615.5 riders alike. The predicate is != rather than
<, because CR 119.3 moves life in both directions. Per-batch (not hoisted)
capture is what keeps double-strike correct.

AssignCombatDamage/AssignBlockerDamage remain non-members, now documented with
the CR 510.2 rationale rather than left to inference. CombatTaxPayment is
documented as a life-mover (CR 508.1h + CR 107.4f: Phyrexian attack taxes),
correcting a false claim that the turn-based members change no life.

Tests: the exempt-window cast proof now fails loudly on class drift in both
directions, and every member row carries a reach-guard, so a fixture offering
no legal answer can no longer pass on an inert zero -- which caught three
pre-existing vacuous rows. Two fixtures that could not occur in a real game
(a player attacking themselves; a singleton trigger-ordering prompt the engine
auto-orders) were corrected rather than left as passing scaffolding.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): a foreign or phase-unreachable observer does not observe a CR 732.2a loop (CR 117.1b / CR 510.2)

The CR 732.2a object-growth firewall vetoed the loop-shortcut offer for two
classes of bystander that provably cannot observe the loop:

* CR 117.1b + CR 732.2c — no player but the sole driver receives priority
  inside the taken shortcut, so an OPPONENT-controlled activated ability
  cannot be activated during the window. CR 605.3a bounds this: a mana
  ability is activatable outside the priority rule, so it is NOT relieved.
  The relief is per-ABILITY, never per-object.
* CR 510.2 / CR 506.1 (CR 500.1 for the phase list) — a trigger whose event
  is confined to a phase or step the window provably never reaches cannot
  fire inside the loop. `TriggerMode::Phase` with a differing `phase`, plus
  the combat-damage family gated on `damage_kind == CombatOnly`.

Both proofs come from one new authority, `window_scope_from_cover_frames`,
which populates the already-plumbed `LoopWindowScope` at both suppressing
covers. Every guard sits inside `if let Some(..)`, so `unproven()` reaches
none of them and the 2-arg wrappers stay identity
(`scoped_wrappers_are_identity`, unmodified).

Shape pins, both asserted with paired polarities:

* the `Phase` arm is STRICT inequality. Relieving `p == phase` is a
  SOUNDNESS change, not a precision one — CR 117.3a puts beginning-of-phase
  abilities on the stack BEFORE the priority at which CR 732.2a lets a
  shortcut be proposed, and CR 608.2h reads their information at resolution,
  inside the window.
* the damage arm REQUIRES `CombatOnly`; a `damage_kind: Any` trigger fires
  on noncombat damage in any phase.
* every unclassified `TriggerMode` falls to `_ => false` and keeps its veto.

Park Heights Pegasus and Smuggler's Share stop suppressing by the
PHASE/STEP mechanism, not by filter-matching: Pegasus's ledger filter is
`Typed{Creature}`, which genuinely does match a Saproling token. The
mis-attributing in-tree note is corrected and the row renamed accordingly.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): a conditioned self-cost static in a zone the window never casts from does not observe a CR 732.2a loop (CR 601.2f)

CR 601.2f vs CR 604.1 / CR 613.1: a `StaticMode::ModifyCost` static whose
`affected` is `SelfRef`, sitting on a card the loop window provably never
casts, cannot modify any cost paid inside the window — so its condition's
read of a projected player resource is not an observation of the loop.

The proof is `LoopWindowScope::cast_card_ids`, derived at the projected
cover's conjunct (5) by the new `window_cast_card_ids`. Two soundness
properties, both pinned by tests:

* CR 601.2a — the derivation takes every `LoopActionContext::card_id`
  regardless of `action`, a SUPERSET of the true cast set (only `Recast`
  actually casts). Over-stating the cast set makes `!ids.contains(..)` false
  more often, i.e. FEWER relieved defs. Narrowing to `Recast` is a precision
  upgrade for a successor, with its own paired-polarity row.
* an EMPTY `last_loop_action_sequence` yields `None`, never `Some(vec![])`.
  Empty means NO RECORDED PROOF, not "this window casts nothing"; the latter
  would relieve every conditioned self-cost static. `None` = scan everything.

Extracting the derivation gives the emptiness contract its own directly
callable seam, and a second row drives the real 2-arg cover predicate so the
conjunct-(5) BINDING is pinned too — measured, degrading that binding to
`Some(&[])` re-opens the fail-open while every helper-level row still passes.

Evidence includes the real 4-player Dina/Conqueror capture, whose obj 90
Mortality Spear (library-visible conditioned `ModifyCost`/`SelfRef`) is
measured to be the ONLY projected-resource-reading fire-time surface on the
board, so the flip is attributable to it alone.

`scoped_wrappers_are_identity` is unmodified: the guard sits inside an
`is_some_and`, so `LoopWindowScope::unproven()` never reaches it.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): a ledger observer whose filter provably cannot count the growing class does not observe a CR 732.2a loop (CR 608.2i)

The CR 732.2a loop-shortcut firewall's growing-class axis vetoes any fire-time
condition that reads a mutable sibling surface. Block (1)'s `execute` scan
vetoed on the read alone, with no look at WHAT the read counts: a trigger whose
body is gated on `BattlefieldEntriesThisTurn { filter }` where the filter
provably cannot match the growing class reads a value that is INVARIANT across
the loop's growth, so it does not observe the loop (CR 608.2h + CR 608.2i).

Narrowing is four fail-closed conjuncts, all required, in
`execute_ledger_condition_provably_excludes_class`:

  (0) `activation_restrictions` must be empty — the firewall is blind to them,
      so a restricted ability fails closed rather than being reasoned about.
  (a) sole-source: a single-field clone with `condition: None` must NOT still
      read a sibling-mutable surface, i.e. the ledger condition is the ONLY
      such read in the ability. No visitor (phase-rs#4603's refusal stands).
  (b) shape: exactly `QuantityCheck { lhs: Ref(BattlefieldEntriesThisTurn),
      rhs: Fixed }` at a single level; everything else `=> false`.
  (c) exclusion: `battlefield_entry_matches_filter` must reject BOTH the
      synthesized entry record for the live class member AND every real
      recorded entry for it. Fail-closed if the member is absent from the
      scanned frame.

MEASURED DELTA, with its population predicate: on today's
`data/card-data.json` the relief population is ZERO — no real card is relieved
by this narrowing. The three cards the directive names are relieved by the
foreign-controller / phase-unreachability narrowings in the two preceding
commits, not by this one. 2c ships a SHAPE, as scaffolding, with that null
delta disclosed rather than papered over. Its soundness residual is likewise
measured and disclosed in the predicate's doc: of `BattlefieldEntryRecord`'s
8 fields the fodder relation (`object_content_eq`, 32 compared fields) covers
only `name` + `controller`, and four of the five uncompared fields are read
verdict-bearingly by a live filter on today's pool — so the residual is
REACHABLE, not latent, and must be re-derived if the card pool is regenerated.

Also extracts `battlefield_entry_record_for` in `game/restrictions.rs` so the
read-only firewall can build an entry record without `&mut GameState`;
`record_battlefield_entry` now calls it, giving the 8-field list one authority.
Behaviour-identical (identical field list, `object_id: obj.id` is the one
mechanically-forced substitution for the removed parameter).

Assisted-by: ClaudeCode:claude-opus-4.8

* style(engine): rewrap the triggers.rs ability import list to rustfmt canonical form

CI "Rust lint (fmt, clippy, parser gate)" failed at its first step,
`cargo fmt --all -- --check`, with a single hunk:

    Diff in crates/engine/src/game/triggers.rs:8:
    -    TriggerDefinitionRef,
    -    TriggerEntry, TriggerGrantProducerKey, TypeFilter, TypedFilter,
    +    TriggerDefinitionRef, TriggerEntry, TriggerGrantProducerKey, TypeFilter, TypedFilter,

The `TriggerDefinitionOccurrenceRef` import added earlier in this chain
pushed the list past a wrap point without being reflowed, leaving a short
line rustfmt joins. Import-list rewrap only — no semantic change.

Assisted-by: ClaudeCode:claude-opus-5

* fix(engine): migrate the 4p retention fixture to the effect_kind contract

Review finding [MED] "New production-fixture regression does not load"
(matthewevans, 2026-07-31): CI Rust shard 1/2 failed loading the four-player
dump at crates/engine/tests/integration/loop_shortcut.rs:4713-4718 with
`missing field effect_kind`, so `two_site_retention_survives_a_prompt_and_its_answer`
never reached the apply pipeline.

Measured cause: the committed blob predates the `effect_kind` serialization
contract. Inflated + key-sorted, the blob differs from its migrated twin by
exactly one line, `+ "effect_kind": "LoseLife"`.

The new blob is extracted BYTE-IDENTICALLY from the already-migrated tree
(`git show <migrated-tip>:<path>`) rather than regenerated by a migration
script, and no `serde(default)` shim was added to `effect_kind`. Byte identity
is the load-bearing property: it keeps this file a no-op when the two histories
are folded, instead of a binary conflict.

Discriminating evidence (both runs local, same test binary):
  - pre-fix blob  => FAILED at loop_shortcut.rs:4717,
    `Error("missing field \`effect_kind\`", line: 0, column: 0)`
  - post-fix blob => ok, 60.41s
The test's existing post-restore reach guards (`state.loop_detection.samples()`
and the block below it) are retained unchanged, per the review's request to
keep a load reach guard.

Assisted-by: ClaudeCode:claude-opus-5

* fix(engine): fail-closed CR 732.2a loop relief and elimination bound

Resolves the review findings on phase-rs#6838.

- Elimination bound is fail-closed: `observed_life_loss.max(0) +
  declared_life_magnitude` replaces `observed.max(declared)`, which admitted an
  unattributed-loss elimination inside a proposed shortcut (CR 732.2a forbids
  conditional actions whose outcome determines the next choice).
- Foreign-observer relief now requires `AbilityKind::Activated` and an absent
  `activator_filter` (CR 602.2): a filtered or non-activated ability is not
  relieved by sole-driver.
- Class-member exclusion is universally quantified at both the ETB and ledger
  sites, with an explicit non-empty guard — `Iterator::all` on an empty set
  returns true, which previously granted relief to every empty class.
- `max_iterations == 0` is rejected rather than clamped; clamping panicked in
  release builds via `Ord::clamp`'s `min <= max` assert.
- Ring invalidation checks snapshot length before `zip`, whose silent
  truncation hid a moved life total.
- Observer-negative rows gain parse reach-guards so a misparse fails loudly
  instead of passing vacuously.
- CR anchors re-seated to the sub-rule each sentence actually claims
  (510.1c->510.1, 702.154a->702.154b, 608.2i->608.2j, 120.3->120.1).

Assisted-by: ClaudeCode:claude-opus-4.8
lgray added a commit to lgray/phase that referenced this pull request Aug 3, 2026
User-directed: the game-start options box that gates the CR 732.2a combo detector
(phase-rs#4603's opt-in) now reads "Combo Detector (experimental)", so players meet the
feature labelled as alpha-stage with the lower performance expectations that implies.

One key, `common:comboDetector.label`, which titles the options group on both entry
paths — GameSetupPage.tsx:544 and the multiplayer HostSetup.tsx:532. They share the
key; there is no separate string for one path, and leaving the host path unlabelled
would invert the intent.

Deliberately NOT labelled: the offer/response dialog titles, the off/on/interactive
segment labels, the descriptive tooltip, and the infinity badge (measured: a bare
glyph with no tooltip naming the feature). The label belongs where the feature is
titled, once per surface, not on every string that mentions combos.

English carries the requested string verbatim, lowercase. That knowingly differs from
the one existing marker in settings.json ("(Experimental)", capitalised): the exact
wording was specified, and an explicit instruction outranks a one-instance house
style. The other six locales follow the established pattern of translating the
marker, since leaving them English would break with every precedent in those files.

Verified: eslint rc=0, tsc rc=0, 280 vitest files passed, all 7 locales carry a
marker, and en matches the requested string exactly. No test or snapshot pinned the
old text (grep positive-controlled against a known assertion in the same suite).

Assisted-by: ClaudeCode:claude-opus-5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Larger-scoped feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants