Skip to content

feat(engine): Susan Foreman planeswalk replacement arranges planar deck top - #6070

Merged
matthewevans merged 9 commits into
phase-rs:mainfrom
andriypolanski:fix/5820-susan-foreman-planar-deck-arrange
Jul 17, 2026
Merged

feat(engine): Susan Foreman planeswalk replacement arranges planar deck top#6070
matthewevans merged 9 commits into
phase-rs:mainfrom
andriypolanski:fix/5820-susan-foreman-planar-deck-arrange

Conversation

@andriypolanski

Copy link
Copy Markdown
Contributor

Fixes #5820

Summary

Susan Foreman (Doctor Who):

If you would planeswalk, instead look at the top two cards of your planar deck, put one on the bottom of your planar deck and the other on top, then planeswalk.

This adds a building-block planeswalk replacement path: look at the top N cards of the planar deck, keep exactly keep_on_top on top (exact-count choice, unlike scry’s any-subset), then complete the chained Effect::Planeswalk via pending_continuation after the player submits SelectCards.

Changes

Engine types & state

  • Effect::ArrangePlanarDeckTop { count, keep_on_top } — typed arrange primitive for planar deck tops
  • WaitingFor::ArrangePlanarDeckTopChoice { player, cards, keep_on_top } — interactive exact-keep selection
  • Registrations across ability.rs, game_state.rs, ability_rw.rs, ability_scan.rs, coverage.rs, printed_cards.rs, trigger_index.rs, analysis/ability_graph.rs, oracle_effect/sequence.rs, oracle_ir/doc.rs, scenario.rs

Resolver & wiring

  • crates/engine/src/game/effects/arrange_planar_deck.rs — sets ArrangePlanarDeckTopChoice waiting state
  • engine_resolution_choices.rsvalidate_exact_keep_on_top_selection, reorder planar_deck, finish_with_continuation drain
  • replacement.rsProposedEvent::Planeswalk in player-scope gate (alongside Draw/Scry)
  • effects/planeswalk.rs — planar-die sentinel routes through replace_event; chained Planeswalk uses non-sentinel source to avoid infinite replacement loop

Parser

  • oracle_replacement.rs
    • parse_would_planeswalk_replacement()"if you would planeswalk, …"
    • parse_arrange_planar_deck_planeswalk_substitute()[ArrangePlanarDeckTop(2,1), Planeswalk]
    • Unit test: would_planeswalk_replacement_parses_susan_foreman

AI & frontend

  • ai_support/candidates.rsselect_cards_variants(..., Some(keep_on_top)) for exact keep count
  • phase-ai/decision_kind.rsArrangePlanarDeckTopChoice decision kind
  • client/src/adapter/types.ts, waitingForRegistry.ts, CardChoiceModal.tsx, libraryModals.tsxArrangePlanarDeckTopModal with exactKeepCount on ReorderableTopChoice

Tests

  • crates/engine/tests/integration/issue_5820_susan_foreman.rs — end-to-end: planar-die Planeswalk → replacement → arrange pause → SelectCards → planeswalk to chosen top card
  • engine_resolution_choices unit tests — reorder handler + stashed Planeswalk continuation drain
  • arrange_planar_deck unit test — waiting-for setup

Test setup note: planar deck objects must be inserted into objects without add_to_zone(Zone::Command) — only the active plane belongs in command_zone. Deck cards live only in planar_deck (face down), mirroring planechase_tests::create_planar_object.

Test plan

cargo fmt --all -- --check
cargo test -p engine susan_foreman_planeswalk_arranges
cargo test -p engine --lib arrange_planar_deck_top_choice
cargo test -p engine --lib arrange_planar_deck_top_sets
cargo test -p engine --lib would_planeswalk_replacement_parses

Tilt (when up): clippy, test-engine, check-frontend after merge.

Notes for reviewers

  • Exact keep vs scry: keep_on_top is enforced by validate_exact_keep_on_top_selection (not scry’s any-subset semantics).
  • Replacement loop safety: the chained Effect::Planeswalk sub-ability uses Susan’s object source (non-sentinel), so it planeswalks directly after arrange without re-entering the replacement pipeline.
  • Planar-die entry point: only the synthetic planar_ability_sentinel_id planeswalking ability is replaceable (Fixed Point in Time precedent); card/SBA planeswalks bypass replacement per CR 701.31c discrimination in planeswalk.rs.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@superagent-security superagent-security Bot added the contributor:flagged Contributor flagged for review by trust analysis. label Jul 17, 2026
@superagent-security

Copy link
Copy Markdown

🚨 Contributor flagged. Click here for more info: Superagent Dashboard

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

Review — changes requested

Verdict: blocker

The new Effect::ArrangePlanarDeckTop and WaitingFor::ArrangePlanarDeckTopChoice variants are not integrated with phase-ai, so the PR cannot compile the workspace.

  • crates/phase-ai/src/policies/effect_classify.rs:52 does not match ArrangePlanarDeckTop.
  • crates/phase-ai/src/policies/redundancy_avoidance.rs:279 does not match ArrangePlanarDeckTop.
  • crates/phase-ai/src/search.rs:751 does not match ArrangePlanarDeckTopChoice.

Evidence: the required Decision-cost perf gate failed with E0004 for all three exhaustive matches (GitHub Actions run 29555228669, job 87805802188). Please add rules-appropriate AI classification and choice generation/handling, then rerun CI.

Clean: isolated diff review found no unsafe command/network or prompt-injection pattern; the failing contributor-trust comment is advisory only.

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

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

🟢 Added (1 signature)

  • 1 card · ➕ replacement/Planeswalk · added: Planeswalk (player scope=You)
    • Affected (first 3): Susan Foreman

🔴 Removed (1 signature)

  • 1 card · ➖ ability/replacement_structure · removed: replacement_structure
    • Affected (first 3): Susan Foreman

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

@matthewevans matthewevans self-assigned this Jul 17, 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.

Verdict: changes requested — the phase-AI exhaustiveness fix is present, but the new replacement is still restricted to the wrong planeswalk source class.

🔴 Blocker

  • crates/engine/src/game/effects/planeswalk.rs:27-30 invokes planechase::planeswalk directly for every non-sentinel Effect::Planeswalk, so none of those events reaches the new ReplacementEvent::Planeswalk matcher. This contradicts Susan Foreman's Oracle text: “If you would planeswalk, instead look at the top two cards of your planar deck, put one on the bottom of your planar deck and the other on top, then planeswalk.” The effect has no “as a result of rolling the planar die” restriction. The same source file labels card-instruction and state-based planeswalks as nonreplaceable, while crates/engine/src/game/planechase.rs:426-430 and :463-466 identify direct phenomenon/SBA planeswalk paths.

    The new integration test only constructs the synthetic planar-die sentinel, so it cannot fail when a non-sentinel planeswalk bypasses the replacement. Route every planeswalk event through one replacement-aware authority, carrying the applied-replacement state so Susan's trailing “then planeswalk” does not reapply Susan while other eligible replacement effects still compose. Add runtime coverage for a non-sentinel Effect::Planeswalk and the existing planar-die path.

✅ Clean

  • 2dcc152 adds the three previously missing phase-ai exhaustive-match arms, addressing the earlier compile blocker.

Recommendation: request changes and route this through the shared planeswalk/replacement authority before re-review.

@matthewevans matthewevans added the enhancement New feature or request label Jul 17, 2026
@matthewevans matthewevans removed their assignment Jul 17, 2026
@matthewevans matthewevans self-assigned this Jul 17, 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.

Verdict: changes requested — the new head fixes Effect::Planeswalk, but the shared replacement authority still excludes two rules-defined planeswalk causes.

🔴 Blocker

  • crates/engine/src/game/planechase.rs:433 (encounter) and :469 (check_phenomenon_planeswalk_sba) still call planeswalk(...) directly. crates/engine/src/game/effects/planeswalk.rs:27-37 only consults planeswalk_through_replacements for Effect::Planeswalk; therefore Susan Foreman never applies when a phenomenon is encountered or when its state-based-action planeswalk occurs. The verified Oracle text is: “If you would planeswalk, instead look at the top two cards of your planar deck, put one on the bottom of your planar deck and the other on top, then planeswalk.” It has no planar-die or ability-instruction restriction. docs/MagicCompRules.txt:3574 (CR 701.31c) expressly lists the planeswalking ability, phenomenon/SBA paths, leaving-game path, and ability instructions as planeswalk causes; :3054-3056 (CR 614.1/614.1a) makes this an event replacement. The current PlaneswalkCause can distinguish only PlanarDie and Instruction, so the direct paths cannot preserve Fixed Point in Time's narrower scope while applying Susan, and they have no choice/continuation handling for Susan's arrangement prompt.

    Route every CR 701.31c cause through one replacement-aware planeswalk authority with an explicit non-planar-die cause, preserving the applied-replacement set and correctly pausing/resuming the phenomenon/SBA path. Keep Fixed Point limited to PlanarDie. Add runtime tests that Susan replaces encounter and the phenomenon SBA, alongside the existing planar-die and card-instruction coverage.

✅ Clean

  • 824540277 correctly routes non-sentinel Effect::Planeswalk through the replacement pipeline, carries the applied set into Susan's trailing planeswalk, and adds a card-instruction regression test; that resolves the prior review finding but does not cover the direct CR 701.31c callers.

Recommendation: request changes — complete the shared authority for every planeswalk cause, then re-request review.

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

Verdict: changes requested — #6070 is still rules-incomplete and the current head does not compile the workspace.

🔴 Blocker

  • crates/engine/src/game/planechase.rs:433 (encounter) and :469 (check_phenomenon_planeswalk_sba) call raw planeswalk(...), whereas crates/engine/src/game/effects/planeswalk.rs:30-36 is the only caller of planeswalk_through_replacements. Susan Foreman's verified text is “If you would planeswalk, instead … then planeswalk,” with no planar-die/instruction restriction. docs/MagicCompRules.txt:3574 (CR 701.31c) lists phenomenon and state-based paths among planeswalk causes, and CR 614.1a makes this a replacement event. Therefore Susan does not replace those two legal planeswalk causes. Route every CR 701.31c cause through the shared replacement-aware authority, preserve the applied set, and add runtime coverage for encounter and the phenomenon SBA while keeping Fixed Point restricted to planar-die causes.

  • CI run 29557534949 failed its Rust lint and Rust-tests shards: crates/mtgish-import/src/convert/replacement.rs has nine ReplacementDefinition initializers missing the new required planeswalk_scope field (for example lines 156, 222, 599, 737, 988, 1079, 1264, and 1383). Thread the new field through every construction or provide a constructor/default at the type authority; the current head cannot be enqueued.

✅ Clean

  • The head correctly changed ordinary Effect::Planeswalk to call planeswalk_through_replacements and retains the applied-replacement state for Susan's chained planeswalk.

Recommendation: request changes — complete the shared replacement authority and all ReplacementDefinition construction paths, add the two runtime regressions, then re-request review.

@matthewevans matthewevans removed their assignment Jul 17, 2026
@andriypolanski
andriypolanski force-pushed the fix/5820-susan-foreman-planar-deck-arrange branch from c434de0 to 944af17 Compare July 17, 2026 08:27
@matthewevans matthewevans self-assigned this Jul 17, 2026
@matthewevans matthewevans removed their assignment Jul 17, 2026
@matthewevans matthewevans self-assigned this Jul 17, 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.

Approved: the current head routes planar-die, card-instruction, encounter, and phenomenon-SBA planeswalks through the replacement-aware authority; current runtime coverage exercises each previously blocked path.

@matthewevans
matthewevans added this pull request to the merge queue Jul 17, 2026
@matthewevans matthewevans removed their assignment Jul 17, 2026
Merged via the queue into phase-rs:main with commit e216726 Jul 17, 2026
15 of 17 checks passed
lgray added a commit to lgray/phase that referenced this pull request Jul 20, 2026
…bilitiesFromSource 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 (phase-rs#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 (phase-rs#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
lgray added a commit to lgray/phase that referenced this pull request Jul 20, 2026
…bilitiesFromSource 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 (phase-rs#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 (phase-rs#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
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor:flagged Contributor flagged for review by trust analysis. enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Seam: planeswalk replacement + planar-deck top-2 arrange (Susan Foreman) — requesting accepted

3 participants