feat(engine): Susan Foreman planeswalk replacement arranges planar deck top - #6070
Conversation
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
|
🚨 Contributor flagged. Click here for more info: Superagent Dashboard |
matthewevans
left a comment
There was a problem hiding this comment.
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:52does not matchArrangePlanarDeckTop.crates/phase-ai/src/policies/redundancy_avoidance.rs:279does not matchArrangePlanarDeckTop.crates/phase-ai/src/search.rs:751does not matchArrangePlanarDeckTopChoice.
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.
Parse changes introduced by this PR · 1 card(s), 2 signature(s) (baseline: main
|
There was a problem hiding this comment.
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-30invokesplanechase::planeswalkdirectly for every non-sentinelEffect::Planeswalk, so none of those events reaches the newReplacementEvent::Planeswalkmatcher. 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, whilecrates/engine/src/game/planechase.rs:426-430and:463-466identify 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::Planeswalkand the existing planar-die path.
✅ Clean
2dcc152adds the three previously missingphase-aiexhaustive-match arms, addressing the earlier compile blocker.
Recommendation: request changes and route this through the shared planeswalk/replacement authority before re-review.
matthewevans
left a comment
There was a problem hiding this comment.
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 callplaneswalk(...)directly.crates/engine/src/game/effects/planeswalk.rs:27-37only consultsplaneswalk_through_replacementsforEffect::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 currentPlaneswalkCausecan distinguish onlyPlanarDieandInstruction, 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
824540277correctly routes non-sentinelEffect::Planeswalkthrough 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
left a comment
There was a problem hiding this comment.
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 rawplaneswalk(...), whereascrates/engine/src/game/effects/planeswalk.rs:30-36is the only caller ofplaneswalk_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
29557534949failed its Rust lint and Rust-tests shards:crates/mtgish-import/src/convert/replacement.rshas nineReplacementDefinitioninitializers missing the new requiredplaneswalk_scopefield (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::Planeswalkto callplaneswalk_through_replacementsand 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.
c434de0 to
944af17
Compare
matthewevans
left a comment
There was a problem hiding this comment.
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.
…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
…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
…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
Fixes #5820
Summary
Susan Foreman (Doctor Who):
This adds a building-block planeswalk replacement path: look at the top N cards of the planar deck, keep exactly
keep_on_topon top (exact-count choice, unlike scry’s any-subset), then complete the chainedEffect::Planeswalkviapending_continuationafter the player submitsSelectCards.Changes
Engine types & state
Effect::ArrangePlanarDeckTop { count, keep_on_top }— typed arrange primitive for planar deck topsWaitingFor::ArrangePlanarDeckTopChoice { player, cards, keep_on_top }— interactive exact-keep selectionability.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.rsResolver & wiring
crates/engine/src/game/effects/arrange_planar_deck.rs— setsArrangePlanarDeckTopChoicewaiting stateengine_resolution_choices.rs—validate_exact_keep_on_top_selection, reorderplanar_deck,finish_with_continuationdrainreplacement.rs—ProposedEvent::Planeswalkin player-scope gate (alongside Draw/Scry)effects/planeswalk.rs— planar-die sentinel routes throughreplace_event; chainedPlaneswalkuses non-sentinel source to avoid infinite replacement loopParser
oracle_replacement.rsparse_would_planeswalk_replacement()—"if you would planeswalk, …"parse_arrange_planar_deck_planeswalk_substitute()→[ArrangePlanarDeckTop(2,1), Planeswalk]would_planeswalk_replacement_parses_susan_foremanAI & frontend
ai_support/candidates.rs—select_cards_variants(..., Some(keep_on_top))for exact keep countphase-ai/decision_kind.rs—ArrangePlanarDeckTopChoicedecision kindclient/src/adapter/types.ts,waitingForRegistry.ts,CardChoiceModal.tsx,libraryModals.tsx—ArrangePlanarDeckTopModalwithexactKeepCountonReorderableTopChoiceTests
crates/engine/tests/integration/issue_5820_susan_foreman.rs— end-to-end: planar-diePlaneswalk→ replacement → arrange pause →SelectCards→ planeswalk to chosen top cardengine_resolution_choicesunit tests — reorder handler + stashedPlaneswalkcontinuation drainarrange_planar_deckunit test — waiting-for setupTest setup note: planar deck objects must be inserted into
objectswithoutadd_to_zone(Zone::Command)— only the active plane belongs incommand_zone. Deck cards live only inplanar_deck(face down), mirroringplanechase_tests::create_planar_object.Test plan
Tilt (when up):
clippy,test-engine,check-frontendafter merge.Notes for reviewers
keep_on_topis enforced byvalidate_exact_keep_on_top_selection(not scry’s any-subset semantics).Effect::Planeswalksub-ability uses Susan’s object source (non-sentinel), so it planeswalks directly after arrange without re-entering the replacement pipeline.planar_ability_sentinel_idplaneswalking ability is replaceable (Fixed Point in Time precedent); card/SBA planeswalks bypass replacement per CR 701.31c discrimination inplaneswalk.rs.