chore: update coverage stats and badges - #5
Merged
Conversation
matthewevans
enabled auto-merge (squash)
April 8, 2026 13:29
matthewevans
added a commit
to AlexD-Richardson/phase
that referenced
this pull request
Apr 27, 2026
…handle synchronous-continuation drain Two interlocking bugs in the `repeat_for` resume path surfaced during PR phase-rs#124 review of overloaded Winds of Abandon: 1. Iteration-resume path dropped sub_ability for iterations 2+. The stash site cleared `resume_ability.sub_ability = None` and the drain called `resolve_effect` directly, bypassing the chain-level wiring at line 1461 that stashes the SearchChoice continuation. For iteration 0, the put-onto-battlefield + shuffle continuation runs correctly; for iterations 1+, it never ran at all — opponents 2+ would be prompted to pick a card but their chosen card stayed in their library. Fix: keep `sub_ability` on the resumed copy and clear `repeat_for` instead (so the resume call doesn't re-enter the outer iteration loop). The drain now calls `resolve_ability_chain` (depth=1, to preserve chain-local tracked-set state) so each resumed iteration goes through the same line-1660 SearchChoice continuation wiring as iteration 0. Per-iteration parent-target rebinding still propagates correctly because `rebind_first_object_target` updates `iter_ability.targets`, which the line-1651 sub_ability propagation copies onto the continuation chain. 2. Synchronous-continuation case was unhandled. If an iteration set `pending_continuation` without changing `waiting_for`, the inner loop would increment and run the next iteration — clobbering the continuation — or, on the trailing iteration, fall through and break without re-stashing remaining iterations. Fix: detect a None→Some `pending_continuation` transition and re-stash with `next_iteration = iteration + 1` before breaking, so the outer drain runs the continuation and then re-enters this drain for the next iteration. Tests: - `repeat_for_resumed_iteration_runs_full_sub_ability_chain`: end-to-end across two distinct opponents, asserts both chosen lands land on the battlefield AND both Shuffle resolutions emit EffectResolved events. Would have caught finding phase-rs#1 directly. - `drain_pending_repeat_iteration_restashes_on_synchronous_continuation`: exercises the synchronous-continuation case with a multi-iteration resume that completes without entering any choice state. PR phase-rs#124 review feedback (findings phase-rs#1, phase-rs#2, phase-rs#5 first/second tests).
matthewevans
added a commit
to AlexD-Richardson/phase
that referenced
this pull request
Apr 27, 2026
…e verbs The previous orthographic rule stripped `-es` whenever the resulting stem ended in `ch`/`sh`/`ss`/`x`/`z`. This was correct for `searches → search` but over-applied to the `-eze`/`-eeze` family, producing invented stems like `freezes → freez`, `breezes → breez`, `sneezes → sneez` — none of which match any downstream lookup, but they violate the project's "parser must not swallow" rule by silently fabricating non-existent words for unknown inputs. Narrow the rule to only strip `-es` when the resulting stem is a registered `PREDICATE_VERBS` member. Unknown verbs now pass through unchanged, and the only verbs that take the strip are ones the parser explicitly knows about. Adds a regression test that asserts neither `freezes` nor `breezes` nor `sneezes` produces an invented stem, while the registered `searches → search` case still works. PR phase-rs#124 review feedback (findings phase-rs#4, phase-rs#5 third test).
matthewevans
added a commit
that referenced
this pull request
Apr 27, 2026
* Add Winds of Abandon * fix(overload): spell out ChangeZone field drops explicitly (no `..` rest) The overload `Effect::ChangeZone → ChangeZoneAll` arm previously used `..` to absorb every field other than `origin`/`destination`/`target`. Today's dropped fields are semantically inert for hidden-zone exile, but `..` would silently absorb any newly-added `ChangeZone` field too, meaning a future field addition could go missing in the overloaded form without compiler help. Bind every field by name and annotate the rationale for each drop. Adding a new `ChangeZone` field will now fail to compile here, forcing a deliberate decision about overload semantics. PR #124 review feedback (finding #3). * fix(engine): preserve sub_ability on resumed repeat_for iterations + handle synchronous-continuation drain Two interlocking bugs in the `repeat_for` resume path surfaced during PR #124 review of overloaded Winds of Abandon: 1. Iteration-resume path dropped sub_ability for iterations 2+. The stash site cleared `resume_ability.sub_ability = None` and the drain called `resolve_effect` directly, bypassing the chain-level wiring at line 1461 that stashes the SearchChoice continuation. For iteration 0, the put-onto-battlefield + shuffle continuation runs correctly; for iterations 1+, it never ran at all — opponents 2+ would be prompted to pick a card but their chosen card stayed in their library. Fix: keep `sub_ability` on the resumed copy and clear `repeat_for` instead (so the resume call doesn't re-enter the outer iteration loop). The drain now calls `resolve_ability_chain` (depth=1, to preserve chain-local tracked-set state) so each resumed iteration goes through the same line-1660 SearchChoice continuation wiring as iteration 0. Per-iteration parent-target rebinding still propagates correctly because `rebind_first_object_target` updates `iter_ability.targets`, which the line-1651 sub_ability propagation copies onto the continuation chain. 2. Synchronous-continuation case was unhandled. If an iteration set `pending_continuation` without changing `waiting_for`, the inner loop would increment and run the next iteration — clobbering the continuation — or, on the trailing iteration, fall through and break without re-stashing remaining iterations. Fix: detect a None→Some `pending_continuation` transition and re-stash with `next_iteration = iteration + 1` before breaking, so the outer drain runs the continuation and then re-enters this drain for the next iteration. Tests: - `repeat_for_resumed_iteration_runs_full_sub_ability_chain`: end-to-end across two distinct opponents, asserts both chosen lands land on the battlefield AND both Shuffle resolutions emit EffectResolved events. Would have caught finding #1 directly. - `drain_pending_repeat_iteration_restashes_on_synchronous_continuation`: exercises the synchronous-continuation case with a multi-iteration resume that completes without entering any choice state. PR #124 review feedback (findings #1, #2, #5 first/second tests). * fix(parser): narrow normalize_verb_token's -es rule to known predicate verbs The previous orthographic rule stripped `-es` whenever the resulting stem ended in `ch`/`sh`/`ss`/`x`/`z`. This was correct for `searches → search` but over-applied to the `-eze`/`-eeze` family, producing invented stems like `freezes → freez`, `breezes → breez`, `sneezes → sneez` — none of which match any downstream lookup, but they violate the project's "parser must not swallow" rule by silently fabricating non-existent words for unknown inputs. Narrow the rule to only strip `-es` when the resulting stem is a registered `PREDICATE_VERBS` member. Unknown verbs now pass through unchanged, and the only verbs that take the strip are ones the parser explicitly knows about. Adds a regression test that asserts neither `freezes` nor `breezes` nor `sneezes` produces an invented stem, while the registered `searches → search` case still works. PR #124 review feedback (findings #4, #5 third test). * test(engine): genuinely exercise synchronous-continuation re-stash predicate The previous version of `drain_pending_repeat_iteration_restashes_on_synchronous_continuation` constructed a Draw + sub-Draw setup that completed each iteration cleanly without ever installing a `pending_continuation` synchronously — meaning the `installed_continuation` predicate was never evaluated true. The test passed regardless of whether the predicate existed. Rewrite the test to use `ConditionInstead` with an `else_ability` and a non-Priority pre-set `waiting_for`, which exercises the line-1486 path that synchronously stashes the else branch into `pending_continuation` without changing `waiting_for`. Verified by temporarily disabling the `installed_continuation` predicate: the test now FAILS with the exact "pending_continuation overwritten before consumption" debug_assert that finding #2 describes. Asserts after one resumed iteration: - `pending_continuation` is Some (else_ability was stashed synchronously) - `pending_repeat_iteration` is re-stashed with `next_iteration = 2` - only iteration 1's parent Draw fired (1 card), proving the drain broke immediately on the synchronous-continuation transition PR #124 review feedback (finding #2 — proper test coverage). --------- Co-authored-by: Matt Evans <1388610+matthewevans@users.noreply.github.com>
This was referenced May 29, 2026
matthewevans
pushed a commit
to mike-theDude/phase
that referenced
this pull request
May 29, 2026
…e (refs phase-rs#594) (phase-rs#1401) * Fix phase-rs#594 (partial): parser drops count+controller for "target opponent's library" exile Extend parse_exile_ast to handle "target opponent's library" / "target player's library" patterns. Restores count and controller filter for the ExileTop effect emitted by Maralen, Fae Ascendant and Court of Locthwain ETB triggers. Variant choices (canonical leaves already produced by parse_target): - "target opponent" → TargetFilter::Typed(TypedFilter::default().controller(Opponent)) - "target player" → TargetFilter::Player Also routes "once each turn, you may cast" lines through the static classifier in preparation for the follow-up cast-from-exile-permission work (Step 10-14 of the investigator plan, tracked as a separate PR). Today this changes the Unimplemented "name" marker on Maralen's second ability from "effect_structure" to "static_structure" — both stay Unimplemented; the routing change only sets up the static handler that the follow-up PR will implement. Defect phase-rs#3 in the issue (subject expansion dropped) was investigated and confirmed already correct in code — no change needed. Defect phase-rs#4+phase-rs#5 (this-turn exile-link scoping + ExileCastPermission static) are deferred to a follow-up PR per investigator's recommended sequencing. Tests: - exile_top_target_opponents_library (parser-level): ExileTop count=2, target-opponent - exile_top_target_players_library_singular (parser-level): ExileTop count=1, Player - trigger_maralen_etb_exile_top_two_of_target_opponents_library (trigger integration): full Oracle text round-trips to ChangesZone trigger with ExileTop(Opponent, 2) Refs phase-rs#594 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * parser: correct CR citation on "once each turn, you may cast" classifier entry CR 601.3e specifically governs static abilities that allow casting spells from non-hand zones (Garruk's Horde, Melek, Maralen). The earlier citation (CR 113.6 + CR 117.1) was adjacent rather than targeted. Per PR phase-rs#1401 review. --------- Co-authored-by: Michael Briningstool <mbriningstool@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
YB0y
pushed a commit
to YB0y/phase
that referenced
this pull request
May 29, 2026
…rom-exile (phase-rs#1404) * feat(engine): ExileCastPermission static for Maralen-class cast-from-exile Closes the remaining gap on phase-rs#594 left after phase-rs#1401 (count + controller fix on the trigger half). Adds the engine support for "Once each turn, you may cast a spell ... from among cards exiled with ~ this turn without paying its mana cost." — defects phase-rs#4 (per-turn exile-link scoping) and phase-rs#5 (cast-from-exile permission static) in the issue. New `StaticMode::ExileCastPermission { frequency, play_mode, without_paying_mana_cost }` mirrors `GraveyardCastPermission` for the exile-pool sibling. Runtime additions: - `GameState::cards_exiled_with_source_this_turn` — per-source per-turn rolling list, populated alongside `exile_links::push_tracked_by_source` and cleared at turn cleanup. Keeps the persistent `exile_links` pool untouched (still backs the open-ended `ExiledBySource` filter). - `GameState::exile_cast_permissions_used` — per-source `OncePerTurn` slot, mirroring `graveyard_cast_permissions_used`. - `CastingVariant::ExilePermission { source, frequency }` — casting context that finalize-cast consults to stamp the slot. - `casting.rs::exile_objects_castable_by_permission` + `exile_cast_permission_source` extend `spell_objects_available_to_cast` and `has_exile_cast_permission`. - `casting.rs::is_exile_permission_free_cast` zeroes the mana cost when the static carries `without_paying_mana_cost: true`. - `exile_links::LINKED_EXILE_CONSUMER_TAGS` gains `"ExileCastPermission"` so a Maralen source is auto-detected as a tracked-exile consumer; her ETB trigger then populates exile_links + the per-turn map for free. - Parser handler in `oracle_static.rs::try_parse_exile_cast_permission` uses the shared nom combinator chain — `parse_type_phrase` already composes the dynamic "with mana value …" suffix through `parse_mana_value_suffix`, so Maralen's filter reaches `Cmc(LE, ObjectCount{Elf|Faerie, You})` through one call. CR annotations: 601.2a (cast permission grant), 113.6b (zone-restricted functioning), 118.9 (alternative cost), 400.7 (zone-change resets the source ObjectId / per-turn slot), 305.1 (play vs cast). All verified against docs/MagicCompRules.txt. Tests: - Parser: Maralen full line, longer "once during each of your turns" synonym, rejects missing "this turn" suffix, regression-guarded against the graveyard branch intercepting. - Engine: surface the per-turn pool, OncePerTurn slot gates and resets, cards outside the per-turn map (stale exile from prior turn) are pruned. Branch: bugfix/594-maralen-cast-from-exile Refs phase-rs#594. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(engine): typed ExileCastCost enum replaces bool field (R2) Replaces the `without_paying_mana_cost: bool` field on StaticMode::ExileCastPermission with a typed `ExileCastCost` enum (PayNormalCost | WithoutPayingManaCost), per CLAUDE.md rule R2 (no raw bool fields — use typed enums that express the design space). Addresses Gemini review comment on PR phase-rs#1404. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * perf(PR-1404): empty-pool fast-exit for exile-cast permission scans exile_permission_sources scans the whole battlefield and allocates an active_static_definitions iterator per controlled permanent. Both exile_objects_castable_by_permission (once per legal-actions / AI-search node) and exile_cast_permission_source (per-object castability predicate, casting.rs:829) call it on the hot path. Guard both with a single cards_exiled_with_source_this_turn.is_empty() check: a card is castable-via-permission only if it was exiled-with-a- source this turn (it must live in that pool), so an empty pool provably yields no offers. Skips the scan in the ~100% of board states with no Maralen-class permanent active; output is identical. Add start_next_turn_resets_exile_cast_permission_tracking: a discriminating regression test that drives start_next_turn and fails if either per-turn reset line (exile_cast_permissions_used / cards_exiled_with_source_this_turn) is dropped. --------- Co-authored-by: Michael Briningstool <mbriningstool@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Matt Evans <1388610+matthewevans@users.noreply.github.com>
Lobster-0429
pushed a commit
to Lobster-0429/phase
that referenced
this pull request
May 31, 2026
* fix(ai): combat correctness — attack planeswalkers, avoid commander trades, gate lifelink Three attack-declaration correctness fixes in combat_ai (reported in the #ai-suggestions channel). Attack declaration is decided directly by combat_ai, not the policy registry, so these are surgical heuristics there. - phase-rs#8 lifelink: gate the lifelink attack bonus on free_damage || favorable_trade || attacker_survives, so it can no longer justify a pure-loss chump block (CR 702.15b: life is still gained on a simultaneous trade, so even/surviving swings are unaffected — but throwing the creature away for nothing is not worth the life). - phase-rs#6 commander: don't trade the commander into a block it does not survive unless pushing lethal (CR 903.8 commander tax). Also excluded from the desperation alpha-strike fallback, computed before its cost/benefit math so a wide board cannot leak it back in. - phase-rs#5 planeswalkers: consume the engine's pre-computed valid_attack_targets to redirect the fewest large attackers (largest-power-first) needed to KILL the highest-loyalty opponent planeswalker, single-opponent path only; never dilutes a lethal/near-lethal swing and never empties the face. Multiplayer pods and Battle targets deferred. Plan reviewed to convergence via /review-plan (3 rounds, perf-verified: no per-node GameState clone, projection, or O(N^2) battlefield sweep). New tests drive the chooser through the real engine target list and are mutation-checked (reverting either subtle gate fails exactly its discriminating test). clippy + test-ai green. * fix(ai): penalize self-protection ACTIVATIONS with no threat (not just spells) Extend ReactiveSelfProtectionPolicy to fire on ActivateAbility, not only CastSpell. The AI repeatedly paid "discard a card: ~ gains protection from everything" until hand-empty, and activated Sylvan Safekeeper ("sacrifice a land: target creature you control gains shroud") on turn 1 for nothing — the same class as casting Teferi's Protection into an empty board, which the policy already handled for spells. - decision_kinds() now includes ActivateAbility; the verdict guard accepts both CastSpell and ActivateAbility. - Classifier extension (required): a GenericEffect static scoped to ParentTarget grants to whatever the parent ability targets, so self-scoping is decided by the enclosing GenericEffect.target — this covers the "target creature you control gains <defensive keyword>" class (Sylvan Safekeeper). The discard-cost "~ gains X" class already lowered to SelfRef and was caught. - Reuses is_self_protection_effect + any_immediate_threat unchanged; the expensive threat check stays gated behind the cheap classifier, so there is no per-candidate cost for non-protection activations. Plan reviewed to convergence via /review-plan (caught that Sylvan Safekeeper's grant is ParentTarget-scoped, which the classifier would have silently missed). Two discriminating tests (SelfRef + ParentTarget shapes) drive the verdict with a real ActivateAbility candidate and are mutation-checked. clippy + test-ai green. * feat(ai): PlaneswalkerLoyaltyPolicy — use the planeswalker, don't sac it for a trick New tactical policy on ActivateAbility for planeswalker loyalty abilities (reported in #ai-suggestions): the AI didn't use a loyalty ability the turn a walker landed (#4d), and fired value-negative minuses that sacrificed the walker for a single-target combat trick instead of the value plus (#4e, Quintorius "-4: target creature gains double strike"). Blunder-only design: the ONLY penalized case is sacrificing/crippling the planeswalker (left at <=1 loyalty) for a single-target combat trick; every other loyalty activation — plus abilities, removal, emblems/ultimates, tokens, draw, mass effects, steal, reanimation — earns a modest "use your planeswalker" bonus. This avoids classifying effect value (which would misclassify the 76 CreateEmblem ultimates as low-value and suppress them); by penalizing only the combat-trick-sacrifice shape, ultimates and removal are structurally never penalized. #4d falls out because using a walker beats passing; #4e is the penalty branch. is_combat_trick = Effect::Pump gated on effect_polarity == Beneficial (excludes negative-pump removal like Davriel's -3/-3), OR a GenericEffect granting only beneficial keyword/+P/+T mods to the single targeted creature (affected == ParentTarget, distinguishing it from a Typed{You} team anthem). Reuses effect_polarity (FreeOutletActivation precedent). CR 606.1. Plan reviewed to convergence via /review-plan (3 rounds — caught that effect_profile misses 76 emblem-ultimates, and that an unconditional Pump arm would penalize negative-pump removal). 9 tests drive the verdict with a real ActivateAbility candidate, incl. BLOCKER regression guards for emblem-ultimate and negative-pump removal; the negative-pump guard is mutation-checked. clippy + test-ai green. * feat(ai): EquipmentPriorityPolicy — stop the every-turn equip shuffle New tactical policy on ActivateAbility (reported in #ai-suggestions): the AI spent all its remaining mana every turn moving an Equipment between creatures for no board improvement. The AI reaches equip via ActivateAbility on the equip ability (effect Effect::Attach), where mana is committed and PassPriority is a sibling candidate — so the policy fires there and penalizes the activation when the equipment is already on the best body, making the AI keep its mana. - Unattached equip and genuine upgrades are neutral (0.0); the policy never rewards equipping (the problem is over-equipping). Only a re-equip with no bigger body to move to is penalized. - "Bigger body" compares base_power, NOT live power: the host's live power already includes this equipment's own +P buff (folded in via attached_to by the layer system), so comparing live power would make the equipped host out-power every bare upgrade target and wrongly penalize legitimate moves. base_power is the printed-baseline proxy, consistent on both sides. Guard: source has subtype "Equipment" + ability effect is Effect::Attach (CR 301.5: Equipment hosts are creatures). Performance: gated behind that cheap guard, then one own-creature base-power scan; no clone/projection. Plan reviewed to convergence via /review-plan (caught that live `power` includes the equipment's own buff, which would suppress every upgrade). The equip_upgrade_allowed test is built on a buffed host (live 4, base 2) with a base-3 target, so it fails under live-power and passes only under base_power — self-discriminating. clippy + test-ai green. * feat(ai): LandSequencingPolicy — play a normal land before a Karoo bounce-land New tactical policy on PlayLand (reported in #ai-suggestions "AI Cast & Bounce lands"): the AI played a Karoo bounce-land (Simic Growth Chamber) first when it should play a different land first, losing tempo. PlayLand was declared in BoardDevelopmentPolicy.decision_kinds() but never scored, so land choice was softmax noise; this fills the gap for the bounce-land case. When the land being played is a self-bouncing land AND a non-bouncing land is also in hand, deprioritize the bounce-land (-1.5) so the non-bounce land wins the argmax and is played first. When the bounce-land is the only land in hand, no penalty — it plays normally. The deferral is recoverable (within-turn reorder only). Detection is structural (no card names): an ETB trigger (ChangesZone, destination Battlefield, valid_card SelfRef) whose execute chain bounces a Land you control (controller == You). Reuses ability_chain::collect_chain_effects. This matches the whole Ravnica/MOM Effect::Bounce bounce-land cycle; the Mercadian Karoo/Coral Atoll sacrifice-self cycle (Effect::Sacrifice) has no sequencing downside and is deliberately not matched. Deferred (#4b, separate SelectTarget decision, documented): when the bounce ETB returns "a land you control," the AI should pick the least-useful land, not the just-played bounce-land — needs its own target-selection policy. Plan reviewed to convergence via /review-plan (confirmed trigger_definitions are populated on hand objects, so detection fires at the PlayLand step; corrected a false coverage claim and tightened the target to controller==You). Tests drive a real PlayLand candidate; the SGC-detected penalty test plus the non-bounce-ETB na test together discriminate the structural matcher. clippy + test-ai green. * fix(engine): smarter auto-tap source order — colorless-for-generic, spare creatures The auto-tap planner's card_tier sort axis ignored two cheap, in-payment signals, so it would drain a dual land's colored capacity to pay a generic cost (when a painland's free {C} row was available) and tap a creature mana dork when a non-creature rock could pay the same shard. Refine the card_tier middle key of auto_tap_mana_sources_inner's sort closure into a 6-tier ladder (penalty axis unchanged): 0 free-colorless land row (ideal generic filler — commits no colored production a later shard in this same payment needs) 1 other land row 2 non-land non-creature mana source (rock / Signet) 3 non-land creature mana dork — CR 509.1a: keep would-be blockers untapped 4 land-creature (animated manland) — CR 509.1a 5 deprioritized own-source The colorless-row signal is read per-row off the existing ManaSourceOption fields (atomic_combination + mana_type), not the per-object flexibility flag (which is stamped identically on every row and can't tell a painland's {C} row from its colored rows). O(1) per source, no hand lookahead — safe in the AI cost-projection hot loop. Two discriminating tests (mutation-checked: both fail on the old 4-tier ladder): auto_tap_uses_colorless_row_for_generic_preserving_dual ({1}{G}) and auto_tap_prefers_rock_over_creature_dork ({G}). Reported via Discord #ai-suggestions ("Auto Cast Mana Selections"). * feat(ai): don't pay for activations whose payoff is gated off (ConditionGatedActivation) The AI activated hideaway payoff abilities (e.g. Mosswort Bridge's "{G},{T}: play the exiled card if your creatures' total power is 10 or greater") every turn even far under the threshold, burning mana for an effect that does nothing. The engine is rules-correct here — per CR 602.5 / the Shelldock Isle ruling the ability is legal to activate regardless; only the CastFromZone effect is gated at resolution (CR 608.2c) — so this is purely an AI-value miss. New ConditionGatedActivationPolicy (DecisionKind::ActivateAbility): when an activated ability with a cost has a top-level intervening-if condition that is currently false, apply a soft penalty so PassPriority (or a better line) wins. Generalizes to the whole "Cost: do X if [board condition]" class — every hideaway land and beyond, not one card. Soft (not Reject) because an ability's cost can change the very thing its condition checks (sacrifice-then-"if you control no creatures"); the condition is read pre-cost. Condition evaluation is delegated to a new public engine helper `ability_condition_currently_met`, which wraps `effects::evaluate_condition` behind a conservative allowlist: it judges only board/controller-relative QuantityCheck conditions and returns None for anything needing resolution-time context (chosen targets, cast/trigger event), so the policy never guesses. Tests: engine helper unit test (board-relative Some(false)/Some(true), no-condition None, target-relative None) + 5 policy tests. Discriminating via unique reason-kind sentinels. Reported via Discord #ai-suggestions ("Mosswort Bridge"). * fix(engine): make Omo's everything counter grant all land/creature types Omo, Queen of Vesuva placed an "everything counter" that did nothing: the two type-granting static abilities silently mis-parsed, and the trigger only targeted a land (the "and up to one target creature" half became Unimplemented). Three fixes: - Trigger: parse "put a counter on each of up to one target A and up to one target B" as two PutCounter siblings, each with its own optional target. Also recover the PRIMARY clause's "up to one" cardinality, which try_parse_verb_and_target dropped when lowering to ZoneCounterProxy — a latent bug across the whole compound-PutCounter class, not just Omo. - Land static: "is every land type" now lowers to a new ContinuousModification::AddAllLandTypes (all 17 land subtypes per CR 205.3i), not the no-op AddType{Land} it produced before. The new Layer-4 marker iterates the canonical card_type::LAND_SUBTYPES; basic types among them grant mana abilities automatically via the existing intrinsic-mana pass (CR 305.7). Distinct from AddAllBasicLandTypes (CR 305.6, 5 basic types). - Creature static: "each nonland creature with an everything counter on it is every creature type" now parses (counter-conditioned subject built from the existing TypeFilter::Non(Land) + parse_counter_suffix) and maps to the existing AddAllCreatureTypes. Tests: parser units (both statics + the two-target trigger), a layer test (counter -> all types; mutation-checked that no-counter objects gain nothing), and a full-pipeline integration test (Omo enters -> resolve trigger -> select land + creature -> assert all land/creature types via the layer system). Reported via Discord #ai-suggestions ("AI Cast & Bounce lands" / Omo). * feat(ai): draft bots value and play nonbasic fixing lands Draftbots never picked or played nonbasic fixing lands: evaluate_draft_card zeroed every non-creature land, and the deck-builder filled the manabase with basics only, ignoring drafted duals. - Pick value: a nonbasic land that taps for 2+ colors now scores `fixing_land` (~card-draw value — above filler, below playables/removal), so bots take duals mid-pack instead of wheeling them. Mono/colorless lands stay at 0. - Deck build: suggest_deck now admits on-color drafted fixing lands into the manabase, each replacing one basic so the 40-card total never drifts. Gated to the standard-basics fill policy (a custom addable policy supplies its own lands) and to drafted copies the player actually owns. New shared `mana_colors::land_produced_color_types` (parts-based core over subtypes + abilities) is the single "what colors does this land make" signal for both the pick value and the manabase, unioning intrinsic basic-land-type mana with Effect::Mana abilities (incl. filter-land ChoiceAmongCombinations). Dedups the mulligan keepables' private copy of the same logic. Tests: draft_eval (subtype dual, ability dual, mono, colorless-only) and suggest (on-color admitted, off-color rejected, total conserved, no-db noop). Reported via Discord #ai-suggestions ("Nonbasic Lands in Draft"). * fix(test): type-correct optional-target check after MultiTargetSpec.min became QuantityExpr MultiTargetSpec.min was parameterized from i32 to QuantityExpr on main (CR 115.1d up-to-N targeting). The Omo dual-target parser test compared s.min == 0 against the old integer type, which no longer compiles against the new QuantityExpr min (E0308). Use a variant match for the Fixed(0) case, matching how the rest of the engine constructs/handles QuantityExpr.
2 tasks
matthewevans
added a commit
that referenced
this pull request
Jun 10, 2026
* refactor(engine): route single-authority bypasses through canonical entries
- explore.rs: replace hand-rolled library->hand zone mutation with
zones::move_to_zone (emits ZoneChanged + runs zone-exit cleanup)
- layers: drop the unused evaluate_layers re-export; migrate the two
production direct callers (become_copy, engine_replacement) to
flush_layers / mark_layers_full + flush_layers; doc-reserve direct
evaluate_layers calls for tests
- ability_utils: funnel validate_selected_targets{,_for_ability} count
validation through one shared inner body (targeting.rs pattern)
- engine_resolution_choices: route the two synthetic-ability resolver
bypasses through resolve_ability_chain; doc resolve_effect's contract
- keywords: document the object-scoped vs state-scoped query tiers
* feat(parser): single authority for expressing unparsed text
Effect::unimplemented(name, fragment) is the one way to construct the
Unimplemented effect; name is a stable pattern-class key consumed by
coverage gap categorization. Delete the dead parse_or_unimplemented /
option_to_nom boundary fns (zero callers) and fix the stale CLAUDE.md
oracle_nom/error.rs description.
* ci: extend parser gate + add engine-authority and skill-doc gates
- check-parser-combinators.sh: close .rfind/.split/.splitn blind spots;
new family (E) verbatim-sentence equality (== "25+ chars"); new
family (F) hand-constructed Effect::Unimplemented literals
- check-engine-authorities.sh (new): diff gate forbidding raw
.keywords.contains/.iter queries outside the keyword authorities
- check-skill-doc.sh (new): asserts oracle-parser SKILL.md matches the
parser tree (paths, symbols, priority-table row parity); SKILL.md
regenerated against the code (38-slot priority table, IR layer,
clause_shell, oracle_static/ split, single-authority doctrine)
* refactor(parser): make condition bridges exhaustive over their source enums
static_condition_to_trigger_condition, static_condition_to_ability_condition,
and ability_condition_to_static_condition no longer hide unbridged variants
behind wildcards: adding a StaticCondition/AbilityCondition variant is now a
compile error at each bridge, forcing a conscious bridging decision. All
expanded arms map to None exactly as the wildcards did (no behavior change).
* refactor(engine): cost-payment Phase 0 — sorcery-speed delegation + pay_from_pool rename
Phase 0 of the cost-payment unification plan (.planning/cost-payment-unification):
- casting.rs: CastingProhibitionCondition::NotSorcerySpeed now delegates to
restrictions::is_sorcery_speed_window instead of re-deriving the CR 307.1
timing predicate inline — restrictions.rs is the single timing authority.
- mana_payment.rs: pay_cost → pay_from_pool (pool-level arithmetic only),
freeing the pay_cost name for the future game/costs.rs ability-cost
payment authority. Callers + re-export updated.
* refactor(parser): consolidate three parallel duration grammars into oracle_nom/duration.rs
Single authority for Oracle duration phrases (CR 611.2 / CR 611.2b /
CR 514.2): the prefix-nested nom grammar in oracle_nom/duration.rs now owns
every phrase→Duration mapping. strip_leading_duration /
strip_trailing_duration in oracle_effect/lower.rs are positional-only
wrappers (word-boundary scan + quantity-clause guard; the two phrase tables
are deleted), and the three inline re-encodings in oracle_effect/subject.rs
delegate to the grammar.
Conflict between the old grammars resolved in the strip table's (test-pinned)
favor: 'for as long as you control ~' maps to UntilHostLeavesPlay. That is a
pre-existing imprecision vs CR 611.2b (control loss without leaving the
battlefield should end it) — now visible in exactly one place.
* refactor(parser): replace verbatim sentence clusters with nom single authorities
- 'Your speed can increase beyond 4' (CR 702.179d-e) was string-equality
encoded at three sites (two oracle.rs routing predicates + the semantic
parse in oracle_static/dispatch.rs); now one is_speed_unlock_sentence
combinator in dispatch.rs, called by all three.
- The ChooseFromZone dig continuation 'put the rest on the bottom of your
library [in a random order | in any order]' (CR 401.4) was three full-line
equalities in sequence.rs; now one all_consuming combinator with the order
suffix as an opt(alt(...)) axis.
* refactor(engine): zone-change pipeline Phase A — carve out game/zone_pipeline.rs
Zero-behavior-change carve-out per .planning/zone-change-pipeline/PLAN.md:
execute_zone_move / deliver_replaced_zone_change / apply_zone_delivery_tail
and friends move verbatim from effects/change_zone.rs into the new
game/zone_pipeline.rs module (one mechanical super::→crate:: path fix); a
pub(crate) use shim keeps every existing caller compiling unchanged.
Seeds the Phase B+ vocabulary (not yet consumed, scoped #[allow(dead_code)]):
ZoneMoveRequest + builders, ZoneChangeCause (CR-annotated exemption split),
EntryMods (EtbTapState, not bool), ExileLinkSpec, DeliveryCtx, and the
ApprovedZoneChange proof token (no Serialize/Deserialize/Clone/Default;
private event field; pub(crate) approve_post_replacement mint path).
Independently reviewed: APPROVED. Phase B watch-item: move_object must seed
EtbTapState onto the ProposedEvent directly instead of round-tripping
through execute_zone_move's legacy bool boundary.
* refactor(types): unify Effect::AddCounter into Effect::PutCounter (CR 122.1)
Effect::AddCounter and Effect::PutCounter expressed the same operation
(place counters on an object) through two variants, forcing every match
site to handle both — and several AI policy sites silently handled only
one (cast_facts, redundancy_avoidance had AddCounter-only legs with
PutCounter receiving different treatment). One variant, one meaning:
PutCounter is the single authority, with #[serde(alias = "AddCounter")]
accepting legacy serialized data.
All ~70 match/construction sites across engine, phase-ai, and
mtgish-import migrated; duplicate arms produced by the rename were
collapsed (compiler-verified via -D unreachable-patterns). Engine
inventory regenerated; the two remaining AddCounter entries are
unrelated enums (ReplacementEvent / game event).
* refactor(parser): rewrite parse_restriction_modes as negation x verb-phrase list grammar
Replace ~14 verbatim equality clusters ('can't attack or block',
'can't block or be blocked', ...) with a composed nom grammar:
alt(("can't", "cannot")) + separated_list1 over restriction atoms
(attack / block / be blocked / be sacrificed / be enchanted / be
equipped / be countered / transform / crew). Any combination of atoms
now parses — including orders no current card uses — instead of only
the enumerated permutations. Elided-'be' English ellipsis ('can't be
equipped or enchanted') handled via a bare-atom fallback inside the
list parser.
Also freezes oracle_quantity.rs for new grammar: new quantity
recognition belongs in oracle_nom/quantity.rs (module doc).
* refactor(parser): delete zero-caller parse_target_phrase wrapper
oracle_target::parse_target is the single authority for 'target <type
phrase>' extraction; the unused nom wrapper was a second grammar waiting
to drift. Its tests retargeted to parse_type_phrase, the building block
production actually calls.
* ci: make coverage ratchet informational on push-to-main
On push-to-main the regression check can only do harm: the merge has
already happened, and a failing check blocks the R2 baseline republish
that runs later in the same job — wedging main red until someone
manually uploads a baseline (the recurring PR #2339 / PR #2802
swallowed-clause ratchet deadlock). continue-on-error on main pushes
lets the baseline self-heal after an intentional ratchet absorption,
while PR and merge_group runs remain fully blocking.
* refactor(types): replace sorcery_speed bool with ActivationRestriction::AsSorcery single authority
CR 602.5d: 'Activate only as a sorcery' timing is now represented solely by
ActivationRestriction::AsSorcery in AbilityDefinition.activation_restrictions.
The parallel sorcery_speed display bool is deleted; is_sorcery_speed() queries
the restriction. A hand-written Deserialize (AbilityDefinitionDe mirror)
tolerates the legacy serialized field and migrates sorcery_speed:true into
AsSorcery (dedup). Serialized exports no longer emit the key; snapshots and
the integration card fixture follow.
* refactor(types): fold ActivationCadence into ActivationRestriction (CR 602.5b)
CR 602.5b: once-each-turn activation cadence on keyword actions (Crew) is now
expressed with the existing ActivationRestriction::OnlyOnceEachTurn instead of
the parallel ActivationCadence enum, which is deleted. Keyword::Crew's
once_per_turn becomes Option<Box<ActivationRestriction>> (None = unrestricted;
boxed to break the Keyword -> ActivationRestriction -> ParsedCondition ->
Keyword size cycle). The custom Crew deserializer accepts both the legacy
ActivationCadence tagged shape ({"type":"Unlimited"}/{"type":"OncePerTurn"})
and the new Option<ActivationRestriction> shape. Regenerated integration card
fixture carries the new export shape (and drops the legacy sorcery_speed key).
* docs(engine): fix stale sorcery_speed display-flag comment (review follow-up)
* refactor(engine): seed three-state EtbTapState directly onto ZoneChange proposal (Phase B watch-item)
execute_zone_move took `effect_enter_tapped: bool` and could only ever set
EtbTapState::Tapped, collapsing the Unspecified-vs-Untapped distinction the
pipeline carrier (ProposedEvent::ZoneChange.enter_tapped: EtbTapState) exists
to preserve. move_object further round-tripped its req.mods.enter_tapped
(EtbTapState) through .is_tapped() before passing it in.
Thread EtbTapState end-to-end: execute_zone_move now accepts EtbTapState and
seeds it onto the proposal whenever it is not Unspecified (CR 614.1). All
callers that already hold an EtbTapState (change_zone resolver paths, seek)
pass it through unchanged; bool-literal callers pass EtbTapState::Unspecified.
move_object passes req.mods.enter_tapped directly. No bool round-trip remains.
This is the recorded Phase A review condition for proceeding with Phase B.
* refactor(engine): route destroy.rs delivery through zone pipeline proof token (Phase B)
apply_destroy_after_replacement delivered its inner (post-replacement)
ZoneChange with a bare zones::move_to_zone in both arms (the post-Destroy inner
move and the outer-replacement-redirected-to-ZoneChange move). A destruction
redirected to the battlefield (CR 614.6) therefore dropped the entire delivery
tail: CR 614.1c enters-with-additional-counter statics, enter_tapped /
enter_with_counters, exile-link tracking, and the post-replacement-continuation
drain.
Seal each already-replaced ZoneChange via ApprovedZoneChange::approve_post_replacement
(preserving its `applied` set and re-validating it is a ZoneChange) and deliver
through zone_pipeline::deliver — the single delivery tail. A NeedsChoice from
the tail propagates as `false` (state.waiting_for already set) so the caller
does not advance.
Adds a discriminating integration test
(destroy_redirect_to_battlefield_delivery_tail) that casts "Destroy target
creature" at a victim with a Moved->Battlefield redirect under a CR 614.1c
additional-+1/+1-counter static, and asserts the redirected creature receives
the counter — fails on the old raw move (0 counters), passes through the tail.
CR 701.8a (line 3315), CR 614 / 614.6 (line 3072), CR 614.1c (line 3056).
* refactor(engine): route sacrifice.rs delivery through zone pipeline proof token (Phase B)
apply_sacrifice_after_replacement delivered its inner (post-replacement)
graveyard ZoneChange — and the outer-redirect ZoneChange — with a bare
zones::move_to_zone. A sacrifice redirected to the battlefield (CR 614.6)
therefore dropped the delivery tail (CR 614.1c enters-with-additional-counter
statics, enter_tapped / enter_with_counters, post-replacement-continuation).
Seal each already-replaced ZoneChange via ApprovedZoneChange::approve_post_replacement
and deliver through zone_pipeline::deliver. The Sacrifice proposal carries no
source, so no exile-link context is attributed. A NeedsChoice from the tail
returns SacrificeApply::NeedsChoice (state.waiting_for already set) so the
caller pauses.
Adds a discriminating test (sacrifice_redirected_to_battlefield_applies_enters_with_counters_tail)
driving the real sacrifice_permanent pipeline with a Moved->Battlefield redirect
under a CR 614.1c additional-+1/+1-counter static — fails on the old raw move
(0 counters), passes through the tail.
CR 701.21a (line 3445), CR 614 / 614.6 (line 3072), CR 614.1c (line 3056).
* refactor(engine): route sba.rs lethal-damage delivery through zone pipeline proof token (Phase B)
The lethal-damage state-based-action destruction loop (check_lethal_damage)
delivered its inner (post-replacement) ZoneChange with a bare
zones::move_to_zone. A lethal-damage death redirected to the battlefield
(CR 614.6, Rest in Peace / "would die -> return" class) therefore dropped the
delivery tail (CR 614.1c enters-with-additional-counter statics, enter_tapped /
enter_with_counters, post-replacement-continuation).
Seal the already-replaced ZoneChange via ApprovedZoneChange::approve_post_replacement
(source carried from the Destroy event) and deliver through zone_pipeline::deliver.
Per CR 704.3, completing all SBAs may require a replacement choice surfaced by
the delivery tail (CR 614.12a Devour as-enters); the NeedsChoice arm pauses
exactly as the existing regeneration NeedsChoice arm does (state.waiting_for
already set by the tail).
Adds a discriminating test (sba_lethal_damage_redirected_to_battlefield_applies_enters_with_counters_tail)
driving check_lethal_damage directly with a Moved->Battlefield redirect under a
CR 614.1c additional-+1/+1-counter static — fails on the old raw move
(0 counters), passes through the tail (exactly 1).
CR 701.19b (line 3426), CR 704.3 (line 5457), CR 704.5g (line 5476),
CR 614 / 614.6 (line 3072), CR 614.1c (line 3056), CR 614.12a (line 3099).
* docs(engine): correct SBA delivery CR annotation to 704.5g + 614.6 (review follow-up)
701.19b is regeneration-via-static; the delivered event here is a
lethal-damage destruction (CR 704.5g) whose redirect is a replacement
(CR 614.6).
* refactor(engine): hoist token + B->B no-op guards into move_object preflight (Phase C0a, Risk #11)
The unified pipeline runs replace_event ahead of zones::move_to_zone's
delivery-time guards. A one-shot replacement could therefore be consumed
(last_effect_count, CR 616.1 choices) on a move the primitive then rejects as
a no-op. Hoist the two cheap, side-effect-free object-level guards
(CR 111.8 token-outside-battlefield cease-to-exist, CR 603.2g + CR 603.6a
Battlefield->Battlefield no-op) into move_object's preflight, before the
execute_zone_move replacement consult.
Behavior-neutral in Phase C0a (move_object has no production callers yet);
the guards take effect as the bucket-B effect sites migrate in C1+.
CR 111.8 (line 663), CR 603.2g (line 2573), CR 603.6a (line 2595).
* fix(engine): clear damage on lethal-damage death redirected to battlefield (Phase C0b, CR 614.5)
A lethal-damage SBA destruction redirected by a Moved replacement back to the
battlefield delivers a Battlefield->Battlefield ZoneChange. zones::move_to_zone's
CR 603.2g no-op guard returns before reset_for_battlefield_entry, so the
creature keeps its marked damage. The SBA fixpoint then re-derives lethal damage
and re-fires the destruction-replacement on every iteration (counter / event
stacking, capped at MAX_SBA_ITERATIONS=9) — a pre-existing CR 614.5 violation
(a replacement gets only one opportunity), surfaced visibly by the Phase B
delivery-tail routing because the enters-with-additional-counter static now
applies on each re-entry.
Root cause: the no-op guard is correct for a spurious self-loop (Coiling Oracle)
but the redirected death is a genuine leave-and-re-enter — a new object per
CR 400.7 whose damage is gone. After delivering the redirect, clear the marked
damage the no-op delivery left behind so the fixpoint sees no lethal damage and
the one-shot replacement is not re-applied.
Adds a discriminating test (sba_lethal_damage_redirect_to_battlefield_applies_counter_exactly_once)
driving check_state_based_actions with a Moved->Battlefield redirect under a
CR 614.1c additional-+1/+1-counter static: pre-fix the creature ends with
Some(9) counters (9 re-fires); post-fix exactly Some(1).
CR 614.5 (line 3069), CR 400.7 (line 1948), CR 603.2g (line 2573), CR 704.5g.
* fix(engine): route mill delivery through zone pipeline so Moved redirects fire (Phase C1)
mill::apply_mill_after_replacement delivered each milled card with a bare
zones::move_to_zone, which never proposed a per-card ZoneChange. Moved-level
redirects ("if a card would be put into a graveyard from anywhere, exile it
instead" — Rest in Peace / Leyline of the Void class) were therefore silently
dropped for milled cards: a milled card reached the graveyard even with Rest in
Peace in play (PLAN §8 Risk #1; confirmed bug).
Route each milled card through zone_pipeline::move_object, which proposes the
inner ZoneChange and consults the Moved replacements before delivery. The milled
card itself anchors the Effect cause (mill to a graveyard creates no exile-link
and a Moved replacement's valid_card is evaluated against the moved card, so
this matches the pre-pipeline raw attribution while enabling the consult).
A per-card NeedsChoice (CR 616.1 multi-replacement race on one milled card) is
parked (state.waiting_for already set) and stops the batch; no real card
produces this on a library->graveyard mill, so the resumable batch-continuation
(PLAN §5a / Risk #10) is deferred. Discriminating test asserts non-interactivity
via the mandatory RIP redirect.
Adds mill_honors_rest_in_peace_graveyard_to_exile_redirect: drives the real Mill
pipeline with a global graveyard->exile Moved replacement and asserts milled
cards land in EXILE (graveyard empty) — fails on the old raw move (graveyard).
Also removes the now-live #[allow(dead_code)] on zone_pipeline::move_object.
CR 701.17a (line 3402), CR 614.6 (line 3072), CR 616.1 (line 3169).
* fix(engine): resume mill batch across per-card replacement choices instead of stranding (Phase C1 review fix)
The C1 mill migration bailed with 'return Ok(())' when a per-card Moved
replacement surfaced a choice, on the false claim that no real card produces
one: the CR 616.1 materiality classifier treats ANY destination-redirecting
Effect::ChangeZone as Unconditional-material, so two simultaneously-applicable
graveyard->exile redirects (Rest in Peace + Leyline of the Void — a real,
common combination) prompt for ordering on EVERY milled card. Pre-fix the
first card's prompt never even surfaced (the pause set pending_replacement but
no caller parked waiting_for) and cards 2..N stranded in the library.
Implement the PLAN §5a batch continuation (option a):
- mill's per-card delivery loop now parks the prompt via
replacement::park_waiting_for and stashes the undelivered tail in the new
state.pending_mill_deliveries (PendingMillDeliveries { remaining,
destination }).
- handle_replacement_choice drains the parked tail after the chosen event
delivers (Execute arm, following the established pending-queue drain
pattern; Prevented arm analogous) via
mill::drain_pending_mill_deliveries, re-parking when the next card surfaces
its own prompt.
Discriminating test mill_under_two_graveyard_redirects_delivers_every_card_through_ordering_choices:
mills 3 cards under RIP + Leyline, answers each CR 616.1 ordering prompt, and
asserts ALL milled cards leave the library and end in exile with the tail
fully drained. Pre-fix it fails (no prompt surfaced; cards stranded).
CR 701.17a (line 3402), CR 616.1 (line 3169), CR 614.6 (line 3072).
* docs(engine): scope C0b damage scrub as degenerate self-redirect guard, not CR 400.7 re-entry (review fix)
The C0b comment justified clearing damage_marked via the CR 400.7 new-object
rule, contradicting the chosen delivery: the Battlefield->Battlefield no-op
means reset_for_battlefield_entry never ran, so incarnation epoch, summoning
sickness, counters, and entered_battlefield_turn are all stale — claiming a
new object while delivering a stale one is inconsistent.
Reword to the narrow reading: a 'remains on the battlefield instead of dying'
replacement is regeneration-shaped — CR 701.19a/b replaces destruction with
'remove all damage marked on it' while the permanent STAYS the same object —
so the two-field damage scrub matches that semantics without claiming a
new-object re-entry. Add a TODO at the site recording the staleness (and the
delivery tail's CR 614.1c counter re-application) as the open question for
when a real would-die->battlefield redirect card class appears: no card
currently parses to one (parser builds die->exile / shuffle-back;
Persist/Undying are dies-triggers), so the semantics decision is deliberately
not baked in on zero real cards. Test doc header updated to match.
Comment-only change; behavior unchanged (test still passes: exactly one
CR 614.1c counter per SBA fixpoint).
CR 701.19a/b (lines 3424/3426), CR 603.2g (line 2573).
* docs(engine): correct mill NeedsAuraAttachmentChoice stash comment (round-2 review P2)
The stash comment claimed reaching the aura-attachment-choice arm 'fails
loudly (undrained tail)'. It does not: an aura-host choice surfaces as
WaitingFor::ReturnAsAuraTarget, not the replacement-choice path, so
drain_pending_mill_deliveries (only invoked from handle_replacement_choice)
never fires for it. A stale pending_mill_deliveries would instead be silently
drained by the NEXT unrelated replacement-choice resume. The arm is dead code
for every parsed Mill today (Battlefield is not a Mill destination); document
the real behavior so a future battlefield-mill variant is understood as a bug
to surface rather than a self-healing path.
CR 303.4f (line 822).
* fix(engine): propagate mill per-card pause through apply_mill_after_replacement (round-2 review P1)
The nested Mill-event resume path clobbered a per-card mill park. When a
Mill-event replacement (e.g. a mill-doubler) resolves through a CR 616.1
ordering choice, handle_replacement_choice's Mill arm applied the accepted
event with 'let _ = apply_mill_after_replacement(...)', discarding the helper's
pause signal, then unconditionally reset state.waiting_for to Priority (~:392).
If deliver_mill_cards parked a per-card prompt inside that arm (two
simultaneously-applicable graveyard->exile redirects make every milled card
prompt for CR 616.1 ordering), the reset stranded the first paused card.
Plumb deliver_mill_cards's existing pause bool out through
apply_mill_after_replacement (now returns Result<bool, EffectError>; true =
fully delivered, false = parked) and early-return from the Mill arm on a pause,
mirroring the apply_etb_counters early-return precedent. EffectError has no
EngineError conversion at that arm, so the error is mapped to 'delivered'
(preserving the prior let _ swallow) and only the pause is acted on. mill::resolve
also bails before EffectResolved on a per-card pause so it doesn't emit a
resolution event over a parked prompt.
Reachable only with two simultaneous Mill-event replacements that both prompt,
which no parsed card produces, so a full runtime repro is impossible. The unit
test apply_mill_after_replacement_reports_per_card_pause_to_caller drives the
shared seam directly: under two graveyard->exile redirects the first milled
card surfaces a CR 616.1 prompt, asserting the helper returns false, leaves
waiting_for set to that prompt, and parks the tail — the exact contract the
Mill arm's early-return now depends on.
CR 701.17a (line 3402), CR 616.1 (line 3169), CR 614.6 (line 3072).
* docs(engine): confirm rad-counter mill read is park-safe (round-2 review P3)
rad_counters assesses CR 728.1 life loss from library_before (a pre-mill
snapshot) immediately after apply_mill_after_replacement. Document that this
read remains correct even if the mill parks mid-batch on a per-card CR 616.1
ordering choice: milled_ids is fixed by the post-replacement count against the
snapshot (identifying the correct top-N cards regardless of delivery routing),
and the per-card life-loss loop reads card type from state.objects, which is
zone-independent. The parked tail is delivered by the resume path; the snapshot
predates any delivery.
CR 728.1 (line 6239).
* fix(engine): route countered spell to graveyard/exile through zone pipeline (Phase C3)
counter::resolve and resolve_all moved a countered spell off the stack with a
raw zones::move_to_zone, which never proposed a per-card ZoneChange. Moved-level
graveyard redirects (Rest in Peace / Leyline of the Void: 'if a card would be
put into a graveyard from anywhere, exile it instead') were therefore silently
dropped for countered spells: a countered spell reached the graveyard even with
Rest in Peace in play (PLAN §8 Risk #3 — graveyard-redirect class, confirmed
bug).
Route the stack -> graveyard/exile move through zone_pipeline::move_object so
the inner ZoneChange is proposed and Moved replacements are consulted before
delivery. The exile-on-counter destination (CR 702.34a/127a/180a Flashback /
Aftermath / Harmonize) is a static destination rule, not a replacement, so it
is still selected before the consult. SpellCountered now fires immediately on
stack removal (the counter itself) rather than after the consequent move, so a
CR 616.1 ordering pause during delivery does not drop it. A single applicable
redirect never prompts; only two simultaneous redirects produce a CR 616.1
choice — that mass multi-card continuation is the Phase C4 class (bail on pause;
no parsed card combines mass counter with a double graveyard redirect today).
Discriminating test countered_spell_honors_rest_in_peace_graveyard_to_exile_redirect:
counters a spell with a global graveyard->exile Moved redirect on the
battlefield and asserts the spell ends in EXILE (graveyard empty). FAILS on the
pre-C3 raw move (spell reaches the graveyard, redirect dropped).
CR 608.2b (line 2807), CR 614.6 (line 3072), CR 616.1 (line 3169), CR 701.6a (line 3309).
* fix(engine): route mass bounce through zone pipeline + generalize batch continuation (Phase C4)
bounce::resolve_all delivered each mass-bounced permanent with a raw
zones::move_to_zone under a comment claiming 'no replacement-pipeline detour is
needed because mass-bounce events are not destruction events (CR 614.6 doesn't
apply here)'. That justification was wrong by citation: CR 614.6 governs
replacement semantics generally, and CR 614.1 replacements watch zone-change
*events*, not only destruction. The raw move never proposed a per-object
ZoneChange, so Moved redirects watching the bounce destination ('if a permanent
would be returned to a hand, exile it instead' class) silently never fired
(PLAN §8 Risk #4). The single-target bounce raw moves (battlefield/graveyard/
stack -> destination) had the same gap.
Route every bounce move through the pipeline. mass bounce uses a new shared
batch entry zone_pipeline::move_objects_simultaneously; the single-target and
non-targeted-single paths use move_object. The stale 614.6 comment is deleted.
Generalize the Phase C1 mill continuation rather than add a second parallel
struct (CLAUDE.md parameterize-don't-proliferate applies to state types too):
- PendingMillDeliveries -> PendingBatchDeliveries (identical { remaining,
destination } shape; serialized as a plain struct so the type rename is
wire-transparent; the GameState field pending_mill_deliveries ->
pending_batch_deliveries carries a serde field-name alias for save compat).
- New zone_pipeline::move_objects_simultaneously runs each request through
move_object, parks + stashes the undelivered tail on a CR 616.1 pause, and
stamps CR 603.10a co-departure over the departed subset on completion (a no-op
for non-battlefield origins, so mill reuses it unchanged).
- zone_pipeline::drain_pending_batch_deliveries replaces
mill::drain_pending_mill_deliveries; mill::apply_mill_after_replacement now
delegates to the shared batch entry, deleting its bespoke deliver_mill_cards.
- engine_replacement.rs drains pending_batch_deliveries from both the Execute
and Prevented resume arms.
A single applicable redirect never prompts (the realistic path), so the common
mass bounce never pauses. Only two simultaneous redirects on one object split a
batch; the co-departure stamp is then per delivered segment rather than threaded
across the pause boundary (no parsed card hits this — documented).
Two discriminating tests (bounce_destination_redirect.rs):
- mass_bounce_honors_to_hand_redirect: a single to-hand -> exile redirect sends
every mass-bounced creature to EXILE, not the hand. FAILS on the old raw move.
- mass_bounce_under_two_redirects_delivers_every_permanent_through_choices: two
simultaneous redirects make every bounced creature prompt a CR 616.1 ordering
choice; answering each delivers ALL of them (none stranded) and fully drains
the parked batch tail.
CR 614.6 (line 3072), CR 616.1 (line 3169), CR 603.10a (line 2634), CR 400.7 (line 1948).
* fix(engine): centralize replacement-choice park in move_object so paused single moves can't ghost (round-3 review Fix 1+2)
zone_pipeline::move_object did not park state.waiting_for on NeedsChoice:
replace_event sets only pending_replacement, and the wait-state was each
caller's to set. The C3/C4 single-move migrations (counter.rs x2, bounce.rs x3)
bailed with 'return Ok(())' under comments falsely claiming the pipeline parks.
Under two simultaneously-applicable graveyard->exile redirects (Rest in Peace +
Leyline of the Void, or two RIP copies — RIP is not legendary), a countered
spell was: removed from the stack, SpellCountered emitted, its move parked in
pending_replacement, and the prompt NEVER surfaced (the engine gates
ChooseReplacement on the wait state) — permanently ghosting the spell (off
state.stack but obj.zone == Stack).
Centralize the park at the single unparked origin: execute_zone_move's
replace_event NeedsChoice arm now calls replacement::park_waiting_for before
returning, making counter.rs, bounce.rs, seek.rs (latent same-class ghost — it
discards the result entirely), and every future C5-C9 single-move migration
safe by construction. Idempotence verified:
- park_waiting_for keeps the CR 614.12a devour guard (never clobbers an
already-surfaced EffectZoneChoice) and recomputes the identical
ReplacementChoice from the same pending_replacement, so callers that still
self-park (change_zone's park_waiting_for arms; end_phase /
exile_from_top_until's replacement_choice_waiting_for) double-set the same
value.
- sba.rs's self-parks are on its OWN replace_event calls (Destroy / inner
ZoneChange proposals), not downstream of move_object — unaffected today;
redundant-but-idempotent when C7 migrates it.
- deliver_batch's explicit park is removed (now redundant); the delivery-tail
NeedsChoice path is deliberately NOT parked here — its wait state is already
set by the counter-pause/devour machinery.
The false 'parks via the pipeline' comments are now true and updated to credit
the centralized park; two stale CR 608.2b citations corrected to CR 701.6a.
Discriminating test countered_spell_under_two_redirects_surfaces_prompt_and_exiles
(fail-first verified): counters a spell under RIP + Leyline, asserts the
CR 616.1 ordering prompt SURFACES, answers it via a real
GameAction::ChooseReplacement dispatch, and asserts the spell ends in EXILE with
no ghost (zone == Exile, exile container holds it, graveyard empty,
pending_replacement clear). Pre-fix run: FAIL with 'waiting_for = Priority {
player: PlayerId(0) }, spell zone = Stack' — the exact unparked-pause ghost.
CR 616.1 (line 3169), CR 614.6 (line 3072), CR 701.6a (line 3301).
* fix(engine): batch-continuation comment accuracy + rad-counter park bail + discard delivery re-bucket (round-3 review Fix 3)
(a) deliver_batch's aura-arm comment claimed a stale tail 'surfaces a bug'
(undrained). It does not: the engine_replacement drain gate keys on
pending_batch_deliveries.is_some(), not provenance, so a stale tail would be
silently drained by the NEXT unrelated replacement-choice resume — the same
mischaracterization the P2 review fix corrected in mill.rs. State the real
behavior.
(b) The mark_simultaneous_departures no-op-for-mill reasoning was wrong:
departed_subset (zones.rs:609) DOES include milled cards — it filters on
current zone != Battlefield, and a card now in a graveyard passes. The actual
no-op comes from the EVENT gate: mark_simultaneous_departures (zones.rs:580)
only stamps ZoneChanged events with from: Some(Zone::Battlefield), and a
library-origin move emits none. Both comments corrected.
(c) rad_counters now bails on a parked mill (apply_mill_after_replacement ->
false) like mill::resolve does: continuing into the CR 728.1 life-loss loop
would propose LifeLoss replacement events while the parked CR 616.1 choice is
pending and could overwrite pending_replacement, ghosting the paused milled
card. The life-loss/rad-removal tail is dropped on that parked path (reachable
only with two simultaneous graveyard redirects active while rad counters
trigger) — accepted and documented rather than threaded through the batch
resume.
(d) discard.rs complete_discard_to_graveyard re-bucketed with a comment: it is
a Bucket A post-replacement DELIVERY site (eventual Phase E shape:
approve_post_replacement + deliver on the lowered ZoneChange carrying applied),
not a move_object site — re-proposing would double-apply Discard-level
definitions. Deferred from this round because deliver's tail drains
post_replacement_continuation at delivery time with Moved context, and timing
equivalence for the Discard-execute chain class (madness / Abundance) needs its
own discriminating tests. Audit correction recorded at the site: the
discard_applier Discard->ZoneChange lowering runs only when a Discard-level
definition (madness class) applies — moved_matcher accepts only ZoneChange
proposals — so a PLAIN discard never consults Moved redirects and Rest in Peace
does not exile it today. That graveyard-redirect gap (same class as mill C1 /
counter C3 / bounce C4) needs the inner-ZoneChange-with-applied lowering plus a
discriminating RIP-on-discard test as its own commit.
CR 616.1 (line 3169), CR 728.1 (line 6239), CR 603.10a (line 2634), CR 701.9a (line 3334 area — see docs), CR 303.4f (line 1652).
* fix(engine): plain discard consults Moved redirects (CR 614.6 / 701.9a)
A plain discard moved hand -> graveyard via raw `move_to_zone`, never
consulting `Moved` replacements, so Rest in Peace / Leyline of the Void
did not exile a discarded card. Lower the accepted Discard into an inner
hand -> graveyard `ZoneChange` carrying the outer pass's `applied` set and
run it through the replacement pipeline, mirroring `discard_applier`'s
lowering. The `applied` set guards against re-running Discard-level
(madness) definitions; the lowered ZoneChange already re-loops through the
pipeline (CR 616.1f) for the madness path, so only the unmodified
Execute(Discard) arm needed the fix.
- complete_discard_to_graveyard now takes (source_id, applied), re-proposes
through replace_event, and returns DiscardOutcome so a Moved-redirect
CR 616.1 choice can propagate.
- Mayhem marker (CR 702.187b, record_card_discarded) stamped only when the
card actually reaches the graveyard (CR 701.9c) — redirects leave it
elsewhere, matching the Madness -> exile path.
- All four callers (resolve specific/non-specific, discard_as_cost,
handle_replacement_choice resume, ward-discard) updated to surface the
pause.
CR grep (docs/MagicCompRules.txt):
701.9a To discard a card, move it from its owner's hand to that player's graveyard.
701.9c If a card is discarded, but an effect causes it to be put into a hidden zone instead ...
614.6. If an event is replaced, it never happens. A modified event occurs instead ...
Discriminating tests (effects/discard.rs):
plain_discard_consults_rest_in_peace_and_exiles — RIP on board, plain
discard ends in exile not graveyard (failed on the old raw-move path).
madness_class_discard_still_works_without_double_consult — madness
redirect to exile fires exactly once (no double-consult).
* fix(engine): seek bail-on-pause + route non-battlefield move through pipeline (CR 616.1 / 614.6)
Seek ignored `execute_zone_move`'s result and always pushed
`EffectResolved`, so a per-card replacement pause was clobbered, and a
multi-card seek with consecutive pausing replacements overwrote
`pending_replacement` — ghosting earlier picks. It also moved
non-battlefield destinations via raw `zones::move_to_zone`, never proposing
a per-card ZoneChange (C9 entry), so `Moved` redirects never fired.
Replace the per-card loop with the shared batch entry
`zone_pipeline::move_objects_simultaneously` (mill::resolve pattern): both
destinations now route through the pipeline, attribution stays
`ability.source_id` (so battlefield entries record
`entered_via_ability_source` and exile-link tracking keys off the seek
source), and a mid-batch CR 616.1 pause parks `state.waiting_for` + stashes
the tail in `pending_batch_deliveries`. Bail before `EffectResolved` on
NeedsChoice so the prompt is not clobbered.
CR grep (docs/MagicCompRules.txt):
614.6. If an event is replaced, it never happens. A modified event occurs instead ...
(616.1 ordering among applicable replacements — pipeline_loop authority)
Discriminating test (effects/seek.rs):
seek_parks_on_per_card_replacement_choice_and_stashes_tail — two
simultaneous graveyard->exile redirects make the first seeked-to-graveyard
card prompt for CR 616.1 ordering; the batch parks, stashes the tail, and
EffectResolved is NOT emitted (old loop ran to completion over the parked
prompt).
* fix(engine): reveal_until battlefield entry routes through zone pipeline (CR 614.1c / 306.5b)
The kept-card battlefield entry used a raw `zones::move_to_zone`, skipping
the CR 614.1c delivery tail — so a revealed planeswalker/battle entered
with 0 loyalty/defense counters and was immediately put into the graveyard
by CR 704.5i. Route the entry through `zone_pipeline::move_object`: the
delivery tail seeds intrinsic enters-with counters, enters-with-counters
statics, and applies the CR 614.1 tap-state from the seeded EntryMods. The
previous manual `obj.tapped = true` is dropped (the tail does it — double-
application check). The `enters_attacking` combat placement (CR 508.4)
stays after delivery (not part of the zone tail). Bail on NeedsChoice /
NeedsAuraAttachmentChoice (centralized park in move_object).
The `move_to_library_position` rest-pile site (shuffle_to_bottom) is a
library-placement SIBLING raw mover, DEFERRED to Phase D with a comment:
move_object's placement arm is still a Phase-A stub that skips the
replacement consult, and a library→bottom reposition has no Moved-redirect
class to consult, so routing through it gains nothing now.
CR grep (docs/MagicCompRules.txt):
306.5b A planeswalker has the intrinsic ability "This permanent enters with a ..."
310.4b A battle has the intrinsic ability "This permanent enters with a number ..."
614.1c Effects that read "[This permanent] enters with . . . ," ...
704.5i If a planeswalker has loyalty 0, it's put into its owner's graveyard.
508.4. If a creature is put onto the battlefield attacking, its controller ch...
303.4f If an Aura is entering the battlefield under a player's control by any...
616.1. If two or more replacement and/or prevention effects are attempting to...
Discriminating test (effects/reveal_until.rs):
reveal_until_planeswalker_enters_with_intrinsic_loyalty — a loyalty-4
planeswalker revealed to the battlefield enters with 4 loyalty counters
(old raw path: 0 counters, dead by CR 704.5i).
* fix(engine): resolution-choice battlefield entries route through zone pipeline (CR 614.1c)
The RevealUntilKeptChoice-accept and DigChoice-kept handlers moved
battlefield-entry cards via raw `zones::move_to_zone` + a manual
`obj.tapped`, skipping the CR 614.1c delivery tail — so a kept/dug
planeswalker or battle entered with 0 loyalty/defense and died to
CR 704.5i. Route the battlefield branches through
`zone_pipeline::move_object` (consistent with the synchronous
reveal_until path migrated in C5): the tail seeds intrinsic enters-with
counters and applies the CR 614.1 tap-state from the seeded EntryMods, so
the manual tap is dropped. Bail on NeedsChoice / NeedsAuraAttachmentChoice
(centralized park; realistically unreachable for the dig classes).
`enters_attacking` (CR 508.4) combat placement stays post-delivery.
DigChoice now binds `source_id` for CR 400.7 attribution (falls back to the
moved object when None, matching the pre-pipeline raw move).
Non-battlefield resolution-choice moves (manifest/surveil/dig rest to
graveyard, route_rest_partition) are left raw this round — they sit inside
multi-card loops with post-loop cleanup (revealed-marker clearing,
continuation drain) where a per-card Moved-redirect pause cannot simply
`return` without stranding the rest; migrating them needs the batch-entry +
continuation restructure and is deferred. Library-placement sibling sites
(move_to_library_position / move_to_library_at_index) stay deferred to
Phase D.
CR grep (docs/MagicCompRules.txt):
306.5b A planeswalker has the intrinsic ability "This permanent enters with a ..."
310.4b A battle has the intrinsic ability "This permanent enters with a number ..."
614.1c Effects that read "[This permanent] enters with . . . ," ...
704.5i If a planeswalker has loyalty 0, it's put into its owner's graveyard.
508.4. If a creature is put onto the battlefield attacking, its controller ch...
400.7. An object that moves from one zone to another becomes a new object wit...
616.1. If two or more replacement and/or prevention effects are attempting to...
303.4f If an Aura is entering the battlefield under a player's control by any...
Discriminating test (effects/reveal_until.rs):
reveal_until_kept_choice_planeswalker_enters_with_loyalty — drives the
RevealUntilKeptChoice accept handler; a loyalty-5 planeswalker enters with
5 loyalty counters (old raw handler: 0, dead by CR 704.5i).
* fix(engine): non-destroy SBA deaths consult Moved redirects (CR 704.5 / 614.6)
The non-destroy state-based-action graveyard moves (zero toughness, zero
loyalty, zero defense, legend-rule loser, unattached aura, battle without a
protector, final-chapter Saga sacrifice) used a bare `zones::move_to_zone`,
skipping the CR 614.6 replacement consult. These are "leaves the
battlefield" / "dies" events (CR 603.6c + CR 700.4), so a `Moved`
graveyard->exile redirect (Rest in Peace / Leyline of the Void) must apply
— it did not.
Add a shared `move_to_graveyard_via_pipeline` helper that routes each
SBA-departing permanent through `zone_pipeline::move_object` with
`ZoneChangeCause::StateBasedAction`. It returns `true` (and the caller
bails) on a CR 616.1 ordering pause, mirroring the established
`check_lethal_damage` regeneration-pause arm; the CR 704.3 fixpoint re-runs
after the choice resolves and re-derives any undelivered SBA deaths, so
bailing strands nothing. The self-parks at the destroy loop remain
redundant-but-idempotent with the centralized park in move_object.
CR grep (docs/MagicCompRules.txt):
704.5f If a creature has toughness 0 or less, it's put into its owner's graveyard.
704.5i If a planeswalker has loyalty 0, it's put into its owner's graveyard.
704.5j If two or more legendary permanents with the same name are controlled ...
704.5m If an Aura is attached to an illegal object or player, or is not attac...
704.5s If the number of lore counters on a Saga permanent ...
704.5v If a battle has defense 0 ...
704.5w If a battle has no player in the game designated as its protector ...
603.6c Leaves-the-battlefield abilities trigger when a permanent moves from t...
700.4. The term dies means "is put into a graveyard from the battlefield."
614.6. If an event is replaced, it never happens. A modified event occurs instead ...
616.1. If two or more replacement and/or prevention effects are attempting to...
Discriminating test (sba.rs):
sba_zero_toughness_death_consults_rest_in_peace_and_exiles — a
zero-toughness creature with RIP on the battlefield is exiled, not put into
the graveyard (old bare-move path: graveyard).
* docs(engine): document C8/C9 zone-pipeline deferral — no Moved class targets Hand/Exile/Stack (PLAN Risk #5/#8)
C8 (casting_costs/engine.rs cost sites) and C9 (Hand/Exile 1-site tail) are
analyzed and deferred: the parser and synthesis only ever emit `Moved`
redirects with `destination_zone` Graveyard (Rest in Peace / Leyline class)
or Battlefield (ETB modifiers) — verified by grep of
oracle_replacement.rs `destination_zone(Zone::...)`. There is NO `Moved`
redirect class targeting a Hand, Exile, or Stack destination, so routing
those moves through `move_object` would consult nothing and only add an
unresumed-pause path to cost-payment / draw / dig flows that do not model a
mid-flow replacement-choice resume. Draw-level replacements already apply at
`ReplacementEvent::Draw` upstream.
Records the finding inline at draw.rs:209 (the PLAN Risk #5 audit anchor),
covering the whole Hand/Exile C9 tail (gift_delivery, connive, explore,
turns return-to-hand; haunt, discover, exile_top, cascade, collect_evidence,
ripple). seek.rs:97 and mill.rs:108 were migrated in earlier rounds (D2 / C1);
discard.rs:38 stays Bucket A (Phase E).
CR grep (docs/MagicCompRules.txt):
614.6. If an event is replaced, it never happens. A modified event occurs instead ...
No behavioral change; comment-only. No discriminating test (the deferral is
the absence of a behavioral change to test).
* fix(engine): scope self-ETB Moved replacements to battlefield entry (CR 614.1c)
Self-scoped as-enters replacements ("~ enters with N counters", enters
tapped/prepared, as-enters choices, enter-as-copy, Karoo/shock/reveal lands;
plus the Fading/Vanishing, Modular, Sunburst, Graft, Bloodthirst, Devour,
Amplify keyword synthesizers) parsed/synthesized as
ReplacementEvent::Moved with valid_card(SelfRef) and NO destination_zone.
moved_matcher skips the destination gate when destination_zone is None, and
a battlefield permanent is in-scan for its OWN departure — so the def
matched the permanent's own battlefield EXIT: phantom counters +
CounterAdded events on corpses (SBA deaths, bounce, destroy/sacrifice), a
spurious CR 616.1 ordering prompt under a single Rest in Peace, and
Optional clone defs forcing an "enter as a copy?" prompt on death.
Fix is data-shape, not matcher special-casing: stamp
.destination_zone(Zone::Battlefield) at construction on every self-ETB
Moved def — CR 614.1c defs ("[This permanent] enters with...") are
definitionally battlefield-entry-scoped. Parser sites: enters-tapped
(unconditional/unless/if-controls), Karoo pay-cost, enters-prepared,
reveal-land, shock land, as-enters-choose, clone, counter-choice, and the
enters-with-counters builder (previously stamped only the is_external
ChangeZone branch). Synthesis sites: Fading/Vanishing, Modular, Sunburst,
Graft, Bloodthirst, Devour, Amplify (Riot/Unleash/Siege/Tribute/Saga were
already stamped). The unearth / "would leave the battlefield" departure
watchers stay destination-agnostic by design.
CR grep (docs/MagicCompRules.txt):
614.1c Effects that read "[This permanent] enters with . . . ," "As [this per...
614.6. If an event is replaced, it never happens. A modified event occurs ins...
616.1. If two or more replacement and/or prevention effects are attempting to...
Discriminating tests (fail-first evidence captured pre-fix):
sba.rs::sba_death_does_not_apply_own_enters_with_counter_replacement
— FAILED pre-fix: phantom counter on corpse (left: 1, right: 0)
sba.rs::sba_death_under_single_rip_exiles_directly_no_prompt_no_counters
— FAILED pre-fix: spurious CR 616.1 ordering prompt
bounce.rs::bounce_does_not_apply_own_enters_with_counter_replacement
— FAILED pre-fix: phantom counter on bounced card (left: 1, right: 0)
All three drive parse_replacement_line (real parser output), then the SBA /
bounce pipeline.
* docs(engine): correct C8/C9 deferral rationale at draw.rs (destination-None Moved class)
The recorded rationale ("no Moved class targets Hand/Exile") was incomplete:
self-ETB Moved constructors carried NO destination_zone, and None matches
every destination — so pre-Fix-1 such defs DID match Hand/Exile deliveries.
The deferral conclusion stands (and was reinforced: migrating C8/C9 before
the destination stamps landed would have widened the phantom-application
defect). Post-stamp, explicit destinations are Graveyard/Battlefield only
and the remaining destination-None defs are deliberate battlefield-departure
watchers (unearth class), so the original claim is now true. Comment-only.
* fix(engine): prevented discard records nothing; document paused-path bookkeeping gap (CR 614.6 / 701.9a)
Prevented arm: a prevented inner ZoneChange means the card never left the
hand — per CR 701.9a (to discard = move hand -> graveyard) NO discard
occurred, so skip record_discard / the CR 702.187b Mayhem stamp / the
Discarded event. Previously the arm fell through and recorded+emitted a
discard that never happened, incoherent with the NeedsChoice early-return.
Distinct from a REDIRECTED discard (CR 701.9c: still discarded — the
Execute and madness arms keep recording+emitting).
NeedsChoice arm: documented gap (counter.rs resolve_all style) instead of a
resume-side continuation — a CR 616.1 ordering pause on the inner move (TWO
materially-different Moved redirects on one discard, e.g. RIP + Wheel of
Sun and Moon) delivers the card via the generic ZoneChange resume with no
discard context, skipping "whenever you discard" bookkeeping for that card
and abandoning a multi-card remainder. A proper fix needs a new serialized
state slot + resume wiring; not built for a board no parsed deck assembles.
Single-redirect boards (RIP alone) never prompt and are fully correct.
CR grep (docs/MagicCompRules.txt):
614.6. If an event is replaced, it never happens. A modified event occurs ins...
701.9a To discard a card, move it from its owner's hand to that player's grav...
701.9c If a card is discarded, but an effect causes it to be put into a hidde...
* fix(engine): batch-tail re-stash preserves request mods + attribution (CR 400.7 / 614.1c)
PendingBatchDeliveries rebuilt paused-batch tails as
ZoneMoveRequest::effect(obj, dest, obj), dropping the seek flow's
enter_tapped mod and ability-source attribution across the pause boundary
(seek is the first batch caller passing non-default EntryMods + a shared
source). Extend the stash with serde-default fields (source_id,
enter_tapped, exile_tracking) carrying the batch-uniform request context:
captured from the first tail request in the new stash_batch_tail helper
(source equal to the request's own object_id is the mill self-anchor idiom
and stashes None), re-applied per request in drain_pending_batch_deliveries.
Batch-uniform rather than per-request, mirroring the single-destination
batch design; per-card heterogeneity remains a flagged design extension.
Test: seek_parks_on_per_card_replacement_choice_and_stashes_tail extended to
assert the stashed tail preserves the seek's ability-source attribution.
* feat(engine): scan stack-resident object's own Moved redirect on stack exit (CR 608.2n / 614.12)
find_applicable_replacements scanned only [Battlefield, Command] plus the
entering-object (to:Battlefield) and discard exceptions, so a spell's own
self-scoped `Moved` replacement was never discovered for its stack ->
graveyard move. Add a stack-self-move exception mirroring the entering-
object exception: when the proposed event is a ZoneChange whose `from` is
Stack, include the moving object itself as a candidate source so its own
SelfRef-scoped Moved def can fire as it leaves the stack.
Hot-path cost: a single per-event Option match on the event's `object_id`
when `from == Stack` (no extra zone sweep); the loop iterates the same
`active_replacements` set as before, this only lets that one object pass
the zone gate, and the `is_stack_self_move && !in_scanned_zone` SelfRef
guard keeps it scoped to that object's own definitions.
Inert until a stack -> graveyard self-redirect def is installed (next
commit wires the Invoke Calamity rider through it): no parsed self-scoped
Moved def today carries destination_zone: Graveyard, and the existing
enters-with-counters self-defs are scoped to destination_zone: Battlefield.
CR grep (docs/MagicCompRules.txt):
608.2n As the final part of an instant or sorcery spell's resolution, the spell is put into its owner's graveyard.
614.12. Some replacement effects modify how a permanent enters the battlefield.
* fix(engine): route stack resolution defaults through pipeline; replace exile-rider flag with synthetic Moved def (CR 608.2n / 614.6)
The stack resolution-default moves (resolved instant/sorcery → graveyard,
fizzled/countered-on-resolution spell, prevented permanent → graveyard)
delivered via raw `move_to_zone`, never proposing the inner ZoneChange, so
board-wide `Moved` graveyard→exile redirects (Rest in Peace / Leyline of
the Void) silently dropped on resolved/countered/prevented spells (PLAN §8
Risk #2 — confirmed bug). Route all three through `zone_pipeline::move_object`
(new `ZoneMoveRequest::spell_resolution_default`, Cause::SpellResolutionDefault).
The Invoke Calamity free-cast "if this spell would be put into your
graveyard, exile it instead" rider was a bespoke per-object boolean
(`exile_from_stack_instead_of_graveyard`) read by hand at the two dest
computations. Replace it with a synthetic self-scoped `Moved`
replacement (valid_card: SelfRef, destination_zone: Graveyard, execute:
ChangeZone → Exile) installed at the same attach point — the same class as
RIP/Leyline, just scoped to one spell — so the pipeline applies it like
any other Moved redirect. Delete the flag, its game_object field, and the
`object_exiles_instead_of_graveyard` reader. The flag may appear in saved
games; GameObject has no deny_unknown_fields, so serde ignores the legacy
field on deserialize (an old mid-cast save lacks the synthetic def — the
def travels with the object via replacement_definitions; acceptable).
Flashback/aftermath/harmonize exile-on-stack-exit stays a STATIC
destination rule selected pre-pipeline (dest = Exile), so its proposed
move is Stack→Exile; the Graveyard-scoped redirect class never matches it
— no double-apply. CR 616.1 ordering pauses (two simultaneous redirects)
are parked by move_object; each site emits the standard StackResolved +
trigger-context-clear epilogue and bails for the replacement-choice resume.
CR grep (docs/MagicCompRules.txt):
608.2n As the final part of an instant or sorcery spell's resolution, the spell is put into its owner's graveyard.
608.3e If a permanent spell resolves but its controller can't put it onto the battlefield, that player puts it into its owner's graveyard.
614.1a Effects that use the word "instead" are replacement effects.
614.6. If an event is replaced, it never happens. A modified event occurs instead.
Discriminating tests:
stack.rs::rest_in_peace_exiles_resolved_instant — RIP redirects a
resolved instant's graveyard move to exile (fails on old raw delivery).
stack.rs::flashback_spell_exiles_once_with_rest_in_peace_present —
flashback exiles exactly once via its static rule; RIP (graveyard-scoped)
does not double-apply on the stack→exile move.
casting_costs.rs::invoke_calamity_rider_exiles_free_cast_spell_on_resolution —
the rider's synthetic Moved def redirects a free-cast spell to exile on
resolution through the deleted-flag's replacement path.
* fix(engine): paused fizzle runs the resolution epilogue before bailing (CR 608.2b / 616.1)
The fizzle/countered-on-resolution arm bailed on a replacement-ordering
pause with a bare `return`, skipping the epilogue directly below it
(StackResolved emission + current_trigger_event / current_trigger_events /
current_trigger_match_count / die_result_this_resolution clears) — leaking
stale cross-resolution context across the pause and never emitting
StackResolved. The other two C2 sites (instant→graveyard else-branch,
prevented-permanent arm) inline that epilogue before their pause-returns;
the fizzle arm now falls through to its shared epilogue instead (the
pause needs nothing else from the fizzle path — `move_object` parked the
prompt and the move, and the resume path delivers it).
Reachable: an Invoke Calamity free-cast spell fizzling under a single
Rest in Peace = rider + RIP = two simultaneous graveyard→exile redirect
candidates = CR 616.1 ordering prompt = this arm.
Also documents the rider's known scope gap at the install site: the
synthetic def carries no "this turn" duration (ReplacementDefinition has
no duration field; revert_layered_characteristics_to_base only runs for
battlefield exits), behavior-preserving vs the deleted flag — a Duration
field on ReplacementDefinition is the eventual fix.
CR grep (docs/MagicCompRules.txt):
608.2b If the spell or ability specifies targets, it checks whether the targets are still legal ...
616.1. If two or more replacement and/or prevention effects are attempting to modify the way an event affects an object or player ...
Discriminating test (fail-first verified):
casting_costs.rs::invoke_calamity_rider_fizzle_under_rip_parks_choice_with_clean_epilogue —
free-cast spell with the rider fizzles under one RIP → CR 616.1 prompt
parks → StackResolved emitted + all four context fields cleared on the
paused path → GameAction::ChooseReplacement resumes → spell exiled.
Pre-fix failure: panicked at "paused fizzle must still emit
StackResolved" (prompt assertion passed, proving reachability).
* fix(engine): route surveil + manifest-dread rest piles through zone pipeline (Phase C6)
The non-battlefield rest-pile loops in engine_resolution_choices.rs delivered
every unkept card with a bare zones::move_to_zone(.., Zone::Graveyard, ..),
proposing no per-card ZoneChange — so each card's own Moved redirects (Rest in
Peace / Leyline of the Void: 'would be put into a graveyard from anywhere ->
exile instead') silently never fired (PLAN Risk #1 class). The post-loop
cleanup (surveil kept-on-top reorder; manifest-dread reveal-marker removal) ran
inline at the end of the loop, which is why C6 was deferred twice: a per-card
CR 616.1 pause mid-pile would run that cleanup before the paused tail finished,
then never again.
Route both piles through a new zone_pipeline::move_objects_simultaneously_then,
which carries a typed BatchCompletion (NOT a closure) describing the post-loop
work. The batch runs the cleanup exactly once on true completion: inline on the
synchronous (single-redirect) path, and via drain_pending_batch_deliveries when
a CR 616.1 ordering choice pauses the pile. BatchCompletion rides on the parked
PendingBatchDeliveries tail (mirroring PendingCounterPostAction), and the drain
re-attaches it across each re-park so it can never run early or twice. The
paused-on-last-card empty-tail case is handled by ensure_batch_record so the
completion still fires after the final card's redirect resolves.
CR grep (docs/MagicCompRules.txt):
701.25a To 'surveil N' means to look at the top N cards of your library, then put any number of them into your graveyard and the rest on top of your library in any order.
614.6. If an event is replaced, it never happens. A modified event occurs instead ...
616.1. If two or more replacement and/or prevention effects are attempting to modify ...
603.10a Some zone-change triggers look back in time. These are leaves-the-battlefield abilities ...
Discriminating tests (surveil_rest_pile_redirect_continuation.rs):
surveil_rest_pile_honors_graveyard_exile_redirect — single redirect: unkept
cards land in EXILE (not graveyard), kept card stays on top once. FAILS on
the old raw move (cards reached the graveyard).
surveil_rest_pile_under_two_redirects_runs_keep_on_top_cleanup_once — two
simultaneous redirects pause every unkept card on a CR 616.1 prompt;
answering each delivers ALL to exile (none stranded), drains the parked
tail, and runs the kept-on-top reorder EXACTLY once (kept card on top,
present once — not duplicated, not missing).
* refactor(engine): route exempt-cause moves through zone pipeline (Phase D)
Migrate the four CR-exempt zone-change classes off raw zones::move_to_zone onto
the single pipeline entry under explicit exempt ZoneChangeCause variants, so
Phase E can flip enforcement knowing every production caller goes through the
pipeline. The exempt path seals the proposed event directly and delivers it
WITHOUT the replace_event consult (the 'would'-semantics layer); the
unconditional primitive guards (CR 111.8 token, CR 614.1d ETB block, CR 400.7
cleanup) still run in zones.rs delivery for every cause (PLAN section 2/3).
Sites:
- mulligan.rs (CR 103.5 PregameProcedure): Serum Powder hand->exile, mulligan
hand->library shuffle, pregame draw, and the two opening-hand/mulligan
bottoming loops (via the library-placement arm).
- elimination.rs (CR 800.4a PlayerLeftGame): owner's objects -> exile. 'This is
not a state-based action' and no replacement applies to a player leaving.
- casting_costs.rs::finalize_cast (CR 601.2a CastingToStack): the committed
Hand/GY/Exile/Command -> Stack transition. Part of the casting process, not a
replaceable event.
- engine_debug.rs (DebugCommand): MoveToZone (incl. library top/bottom/Nth via
placement), Mill, and the no-ETB battlefield staging path. Operator intent is
'force the state'; no intrinsic enters-with-counter seeding, matching the
prior raw placement.
ZoneChangeCause::is_exempt() is the single authority for the consult skip; each
exempt arm carries its CR citation. New ZoneMoveRequest constructors
(casting_to_stack / pregame / player_left_game / debug) keep call sites short.
Removed now-unused 'use super::zones' in mulligan.rs.
CR grep (docs/MagicCompRules.txt):
103.5. Each player draws a number of cards equal to their starting hand size ... A player who is dissatisfied with their initial hand may take a mulligan ... shuffles the cards in their hand back into their library ... puts a number of those cards ... on the bottom of their library
601.2a To propose the casting of a spell, a player first moves that card (or that copy of a card) from where it is to the stack.
800.4a When a player leaves the game, all objects (see rule 109) owned by that player leave the game ... This is not a state-based action.
No behavior change: exempt causes already bypassed the consult via raw moves;
this routes them through the pipeline with identical delivery. Existing mulligan
/ elimination / debug / casting tests stay green (test-engine ok).
* docs(engine): document W3 library-placement consult deferral with evidence (Phase D)
The placement-aware pipeline arm exists as a stub (move_object delivers a
Some(placement) request directly via move_to_library_at_index, skipping the
replacement consult). Phase D's exempt pregame/debug library callers already
route through it. Completing the stub so it RUNS the consult on a library
placement (PLAN section 3.5 'the consult should still run for future-proofing')
is DEFERRED with evidence:
- Zero value today: no Moved replacement in the card pool targets
destination_zone(Library). Verified destination distribution:
25 destination_zone(Zone::Battlefield)
17 destination_zone(Zone::Graveyard)
2 destination_zone(Zone::Exile)
0 Library
so the consult is a guaranteed no-op on every library placement.
- Real risk for that no value: a correct completion must gate the CR 701.24a
delivery-tail auto-shuffle on placement-absence across the shared deliver /
deliver_replaced_zone_change / DeliveryCtx signatures (a library *placement*
must NOT shuffle; a plain library-destination ZoneChange MUST). That is a
cross-cutting change to every bucket-A/B/C delivery caller with a
silent-randomization landmine (a wrong gate randomizes put-on-top / scry /
cascade placements with no crash or test failure).
The raw move_to_library_position / _at_index sibling production callers
(put_on_top, cascade, discover, reveal_until, drawn_this_turn_choice,
engine_resolution_choices) stay on the raw movers until that completion: a
library reposition is not 'put into a graveyard/exile/hand', so nothing is
skipped by staying raw. Migrating them onto the stub arm ahead of the consult
completion is value-neutral churn that does not advance Phase E (which can only
demote the library movers once ALL callers AND the consult are in place) and
carries per-site top-order-reversal preservat…
gabrielUpON
pushed a commit
to gabrielUpON/phase
that referenced
this pull request
Jun 11, 2026
…ct (CR 121.1) (phase-rs#2996) Migrates the per-card draw delivery from the raw zones::move_to_zone bypass to the unified pipeline (zone_pipeline::move_object via ZoneMoveRequest::draw) with the inner Moved consult ENABLED + a CR 614.5 dedup guard that seeds the outer ReplacementEvent::Draw pass's applied set into the inner consult so no def fires at both levels. New non-exempt sourceless ZoneChangeCause::Draw. Audit (PLAN Risk phase-rs#5): zero production Moved defs match a Library->Hand draw today — every destination-unconstrained Moved def is valid_card: SelfRef-bound to a battlefield host; the only valid_card: None class (RIP/Leyline) is destination-gated to Graveyard. So the migration is behavior-preserving and the guard is defensive/future-proofing, per the maintainer's OQ#2 decision. Incorporates Fable review (round 2): - MED-1: add draw_consult_runs_for_unseeded_moved_redirect — the positive discriminator that fails under the old raw bypass / an exempt-Draw regression (asserts an always-match redirect sends the drawn card to the graveyard). The prior two tests passed under the bypass and did not pin the migration; module doc corrected. - MED-2: replace the wrong-condition NeedsChoice debug_assert with a post-condition tripwire (card must land in Hand) that fires on any future redirect OR stranded pause; comment flags the open CardDrawn/counter semantics question and points a future migrator at the shared BatchCompletion machinery (OQ#1). - MED-3: move seed_applied from the shared ZoneMoveRequest struct onto the ZoneChangeCause::Draw variant payload (Draw is the only producer) — deletes 7 empty initializers and the sba.rs edit; type-enforces the invariant. - LOW-1: dedup guard cites CR 614.5 (one-opportunity rule), not 614.6. - LOW-2: de-wildcard ZoneChangeCause::source() — explicit arms, no silent inheritance (matches the is_exempt mandate). - LOW-3: reword the dedup test's provenance comment (synthetic/forward-looking, not production-reachable). - Drop CR 120.1 (Damage) for CR 121.1 (Drawing a Card) throughout.
8 tasks
matthewevans
pushed a commit
to rykerwilliams/phase
that referenced
this pull request
Jul 11, 2026
Fireball ({X}{R}, Alpha 1993): "This spell costs {1} more to cast for
each target beyond the first." parses as a genuine CR 601.2f cost
increase, but parse_strive_cost_line silently dropped it — it
unconditionally required an em-dash "Strive — " ability-word label,
which Fireball's clause predates by 21 years. The clause vanished with
zero Unimplemented signal (a separate catch-all for "this spell costs "
lines suppresses the warning), exactly the "looks done, is wrong"
misparse-backlog class.
Officious Interrogation (Murders at Karlov Manor, 2024) has the
identical bare, unlabeled shape — printed nine years after Strive
existed, WotC just didn't apply the label — confirming this is a real,
recurring grammatical variant, not a one-off pre-2014 artifact.
Split parse_strive_cost_line into a labeled-first entry point (~17
existing Strive cards, unchanged) plus a shared parse_strive_cost_body
nom pipeline, falling back to the bare form when no label is present.
Every downstream consumer (IR lowering, CardFace/GameObject field
copy, apply_target_dependent_cost_modifiers/concrete_cost_for_x in
game/casting.rs) already generically consumes the resulting
strive_cost regardless of population path, so the parser is the sole
fix point.
Fireball's own runtime gameplay cost is NOT fixed by this change — its
specific casting route ({X} cost + "divided evenly among any number of
targets", which pauses at a pre-payment DistributeAmong step) bypasses
the cost-surcharge mechanism entirely via a separate, independent
engine bug, unrelated to this parser gap. That's scoped to its own
follow-up (tracked in WORKLIST.md) since it touches shared
casting-pipeline code affecting ~15 other cards' payment-mode/convoke/
assist access, not just Fireball's surcharge. The runtime discriminating
test here uses Officious Interrogation instead, whose casting route
does reach the surcharge seam, proving the parser fix reaches real
production payment.
List hygiene: removes Fireball, Officious Interrogation, and Ajani's
Presence (confirmed via direct test execution to already parse
correctly today — a stale entry, not touched by this fix) from
docs/parser-misparse-backlog.md's Category phase-rs#5.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbgwGxbU9NHN9kou9isp8K
andriypolanski
pushed a commit
to andriypolanski/phase
that referenced
this pull request
Jul 11, 2026
…hase-rs#5545) Fireball ({X}{R}, Alpha 1993): "This spell costs {1} more to cast for each target beyond the first." parses as a genuine CR 601.2f cost increase, but parse_strive_cost_line silently dropped it — it unconditionally required an em-dash "Strive — " ability-word label, which Fireball's clause predates by 21 years. The clause vanished with zero Unimplemented signal (a separate catch-all for "this spell costs " lines suppresses the warning), exactly the "looks done, is wrong" misparse-backlog class. Officious Interrogation (Murders at Karlov Manor, 2024) has the identical bare, unlabeled shape — printed nine years after Strive existed, WotC just didn't apply the label — confirming this is a real, recurring grammatical variant, not a one-off pre-2014 artifact. Split parse_strive_cost_line into a labeled-first entry point (~17 existing Strive cards, unchanged) plus a shared parse_strive_cost_body nom pipeline, falling back to the bare form when no label is present. Every downstream consumer (IR lowering, CardFace/GameObject field copy, apply_target_dependent_cost_modifiers/concrete_cost_for_x in game/casting.rs) already generically consumes the resulting strive_cost regardless of population path, so the parser is the sole fix point. Fireball's own runtime gameplay cost is NOT fixed by this change — its specific casting route ({X} cost + "divided evenly among any number of targets", which pauses at a pre-payment DistributeAmong step) bypasses the cost-surcharge mechanism entirely via a separate, independent engine bug, unrelated to this parser gap. That's scoped to its own follow-up (tracked in WORKLIST.md) since it touches shared casting-pipeline code affecting ~15 other cards' payment-mode/convoke/ assist access, not just Fireball's surcharge. The runtime discriminating test here uses Officious Interrogation instead, whose casting route does reach the surcharge seam, proving the parser fix reaches real production payment. List hygiene: removes Fireball, Officious Interrogation, and Ajani's Presence (confirmed via direct test execution to already parse correctly today — a stale entry, not touched by this fix) from docs/parser-misparse-backlog.md's Category phase-rs#5. Claude-Session: https://claude.ai/code/session_01XbgwGxbU9NHN9kou9isp8K Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
lgray
added a commit
to lgray/phase
that referenced
this pull request
Jul 12, 2026
… loop-cover predicate
Cover the infinite charge-counter-growth proliferate loop (Pentad Prism +
Kilo/Freed/Relic) and offer the CR 732.2a resource shortcut. New sibling predicate
`loop_states_cover_modulo_counter_growth` (analysis/resource.rs): strict-growth-only
over the PRESERVED `Generic` object-counter axis, fail-closed.
- `CounterGrowthDisposition {StrictGrowth, Stable, Consumed}` (typed, not bool).
`classify_generic_counter_growth` is a wildcard-free `CounterType` match — the
per-type classification table itself (a new variant won't compile until
classified), kept in lockstep with `is_monotone_loop_resource`. `Consumed` (any
`Generic` counter fell) takes precedence over `StrictGrowth` (mixed grow+consume
rejects) — the infinite-consume soundness trap.
- `equalize_generic_counters` overwrites only `Generic` counts with `prior`'s, then
rides the existing `loop_states_equal_modulo_resources`, so any non-`Generic`
drift still rejects via the untouched equality.
Wired at exactly two non-GameOver seams (the firewall): `detect_loop` gate-1
(offline `Advantage` certification) and `interactive_loop_bridge` Path-C (live
revocable-unbounded capability mark). Deliberately NOT wired into
`live_mandatory_loop_winner` (GameOver-capable) — a conservative fail-closed
boundary. A charge/burden growth loop classifies `WinKind::Advantage` (CR 104.4b:
an optional loop is not a draw), so an over-claim is a declinable offer / revocable
mark, never a wrongful game-end — the revocability soundness bound (the equalize
step introduces a new projected `Generic`-counter axis, sound by this bound, not by
firewall parity).
GENERAL over preserved-`Generic` growth: Pentad Prism (charge) and The One Ring
(burden) are the SAME cover, so One-Ring's growth cover is discharged here — its
later pass is offer-layer + card-verify only.
Two-path coverage (matches the existing architecture): the real proliferate loop is
offline-certified (`drive_offline_pentad_prism`, real Kilo/Freed/Relic + Pentad
seeded >=1 charge via Sunburst, CR 702.44a); the live Path-C disjunct is exercised
by a sampler-visible self-refilling charge-growth trigger — the live equality
sampler records only non-shrinking-stack cascades, and a `ProliferateChoice` beat
hits the pre-existing ring-clear arm.
8 discriminating tests (4 unit + phase-rs#5/phase-rs#8 offline + phase-rs#6/phase-rs#7 live), all non-vacuous: the
consume control (phase-rs#2) is a same-`Generic("charge")` decrease that flips only under a
direction-blind revert; phase-rs#8 asserts `Some(Advantage, Counter(Other,Other))`; phase-rs#6
marks the counter axis without any GameOver; phase-rs#7 proves phase-rs#4603-OFF byte-identity.
Verify: clippy -p engine --all-targets 0/0; analysis lib 173 + loop_shortcut 28 +
the 8 new tests green. Plan-reviewed + impl-reviewed clean.
Assisted-by: ClaudeCode:claude-opus-4.8
lgray
added a commit
to lgray/phase
that referenced
this pull request
Jul 12, 2026
… loop-cover predicate
Cover the infinite charge-counter-growth proliferate loop (Pentad Prism +
Kilo/Freed/Relic) and offer the CR 732.2a resource shortcut. New sibling predicate
`loop_states_cover_modulo_counter_growth` (analysis/resource.rs): strict-growth-only
over the PRESERVED `Generic` object-counter axis, fail-closed.
- `CounterGrowthDisposition {StrictGrowth, Stable, Consumed}` (typed, not bool).
`classify_generic_counter_growth` is a wildcard-free `CounterType` match — the
per-type classification table itself (a new variant won't compile until
classified), kept in lockstep with `is_monotone_loop_resource`. `Consumed` (any
`Generic` counter fell) takes precedence over `StrictGrowth` (mixed grow+consume
rejects) — the infinite-consume soundness trap.
- `equalize_generic_counters` overwrites only `Generic` counts with `prior`'s, then
rides the existing `loop_states_equal_modulo_resources`, so any non-`Generic`
drift still rejects via the untouched equality.
Wired at exactly two non-GameOver seams (the firewall): `detect_loop` gate-1
(offline `Advantage` certification) and `interactive_loop_bridge` Path-C (live
revocable-unbounded capability mark). Deliberately NOT wired into
`live_mandatory_loop_winner` (GameOver-capable) — a conservative fail-closed
boundary. A charge/burden growth loop classifies `WinKind::Advantage` (CR 104.4b:
an optional loop is not a draw), so an over-claim is a declinable offer / revocable
mark, never a wrongful game-end — the revocability soundness bound (the equalize
step introduces a new projected `Generic`-counter axis, sound by this bound, not by
firewall parity).
GENERAL over preserved-`Generic` growth: Pentad Prism (charge) and The One Ring
(burden) are the SAME cover, so One-Ring's growth cover is discharged here — its
later pass is offer-layer + card-verify only.
Two-path coverage (matches the existing architecture): the real proliferate loop is
offline-certified (`drive_offline_pentad_prism`, real Kilo/Freed/Relic + Pentad
seeded >=1 charge via Sunburst, CR 702.44a); the live Path-C disjunct is exercised
by a sampler-visible self-refilling charge-growth trigger — the live equality
sampler records only non-shrinking-stack cascades, and a `ProliferateChoice` beat
hits the pre-existing ring-clear arm.
8 discriminating tests (4 unit + phase-rs#5/phase-rs#8 offline + phase-rs#6/phase-rs#7 live), all non-vacuous: the
consume control (phase-rs#2) is a same-`Generic("charge")` decrease that flips only under a
direction-blind revert; phase-rs#8 asserts `Some(Advantage, Counter(Other,Other))`; phase-rs#6
marks the counter axis without any GameOver; phase-rs#7 proves phase-rs#4603-OFF byte-identity.
Verify: clippy -p engine --all-targets 0/0; analysis lib 173 + loop_shortcut 28 +
the 8 new tests green. Plan-reviewed + impl-reviewed clean.
Assisted-by: ClaudeCode:claude-opus-4.8
lgray
added a commit
to lgray/phase
that referenced
this pull request
Jul 12, 2026
… loop-cover predicate
Cover the infinite charge-counter-growth proliferate loop (Pentad Prism +
Kilo/Freed/Relic) and offer the CR 732.2a resource shortcut. New sibling predicate
`loop_states_cover_modulo_counter_growth` (analysis/resource.rs): strict-growth-only
over the PRESERVED `Generic` object-counter axis, fail-closed.
- `CounterGrowthDisposition {StrictGrowth, Stable, Consumed}` (typed, not bool).
`classify_generic_counter_growth` is a wildcard-free `CounterType` match — the
per-type classification table itself (a new variant won't compile until
classified), kept in lockstep with `is_monotone_loop_resource`. `Consumed` (any
`Generic` counter fell) takes precedence over `StrictGrowth` (mixed grow+consume
rejects) — the infinite-consume soundness trap.
- `equalize_generic_counters` overwrites only `Generic` counts with `prior`'s, then
rides the existing `loop_states_equal_modulo_resources`, so any non-`Generic`
drift still rejects via the untouched equality.
Wired at exactly two non-GameOver seams (the firewall): `detect_loop` gate-1
(offline `Advantage` certification) and `interactive_loop_bridge` Path-C (live
revocable-unbounded capability mark). Deliberately NOT wired into
`live_mandatory_loop_winner` (GameOver-capable) — a conservative fail-closed
boundary. A charge/burden growth loop classifies `WinKind::Advantage` (CR 104.4b:
an optional loop is not a draw), so an over-claim is a declinable offer / revocable
mark, never a wrongful game-end — the revocability soundness bound (the equalize
step introduces a new projected `Generic`-counter axis, sound by this bound, not by
firewall parity).
GENERAL over preserved-`Generic` growth: Pentad Prism (charge) and The One Ring
(burden) are the SAME cover, so One-Ring's growth cover is discharged here — its
later pass is offer-layer + card-verify only.
Two-path coverage (matches the existing architecture): the real proliferate loop is
offline-certified (`drive_offline_pentad_prism`, real Kilo/Freed/Relic + Pentad
seeded >=1 charge via Sunburst, CR 702.44a); the live Path-C disjunct is exercised
by a sampler-visible self-refilling charge-growth trigger — the live equality
sampler records only non-shrinking-stack cascades, and a `ProliferateChoice` beat
hits the pre-existing ring-clear arm.
8 discriminating tests (4 unit + phase-rs#5/phase-rs#8 offline + phase-rs#6/phase-rs#7 live), all non-vacuous: the
consume control (phase-rs#2) is a same-`Generic("charge")` decrease that flips only under a
direction-blind revert; phase-rs#8 asserts `Some(Advantage, Counter(Other,Other))`; phase-rs#6
marks the counter axis without any GameOver; phase-rs#7 proves phase-rs#4603-OFF byte-identity.
Verify: clippy -p engine --all-targets 0/0; analysis lib 173 + loop_shortcut 28 +
the 8 new tests green. Plan-reviewed + impl-reviewed clean.
Assisted-by: ClaudeCode:claude-opus-4.8
matthewevans
added a commit
that referenced
this pull request
Jul 12, 2026
…ow + combo-declaration UI (CR 732.2a–c) (#5672) * feat(engine): DecisionTemplate replay + residual board delta (PR-7 phase 1) Phase 1 (foundations) of combo-detector PR-7: pure/offline analysis-layer primitives, zero live engine wiring, no gate yet. B1: DecisionTemplate {owner, decisions, replay} + resolve() replay engine + predictability_gate() (CR 732.2a firewall). DecisionSource reuses YieldTarget (CR 117.3d provenance + CR 400.7 identity). ReplayFailure is selected per pin kind: absent Order source -> MissingSource (CR 400.7); illegal/absent target -> IllegalTarget (CR 608.2b). IterationCount::Fixed only; TargetSchedule = {Constant, RoundRobin, Piecewise}. B4: BoardDelta/ResidualPermanent (CR 110.1) + pure board_delta() producer + structurally-empty LoopCertificate.residual_board_delta. The field is empty by construction for every certificate detect_loop can currently produce (loop_states_equal_modulo_resources requires an identical battlefield); board_delta() is the single population seam that lights up once a future object-growth detection path relaxes that gate -- not a silent stub. Verification: cargo fmt; clippy --all-targets -D warnings (clean); cargo test -p engine (18543 passed, 0 failed); 12 discriminating inline unit tests. coverage/semantic-audit skipped -- PR-7 touches zero parser/card-data surface so they carry no signal; coverage runs once at final pre-push as a no-regression check. Deferred: DecisionGroupKey/DecisionKind/key + Ord-on-newtypes -> Phase 2/B2 (first consumer); IndexedClass schedule -> Phase 4/B3 (needs live FilterContext); non-empty residual materialization + board_delta output-order determinism -> object-growth detection phase. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): trigger-order resolver + intra-batch re-prompt fix (PR-7 phase 2) Phase 2 (B2) of combo-detector PR-7: one resolver at the begin_trigger_ordering auto-order gate, two tiers over a shared DecisionTemplate, plus the deferred B1 key apparatus. Ephemeral tier (CORRECTNESS FIX, CR 603.3b): a simultaneous trigger batch is ordered ONCE. Previously, when a targeted trigger paused for target selection, the already-ordered deferred tail lost its 'ordered' flag and drain_deferred_trigger_queue_unchecked re-prompted after every target. Now handle_order_triggers registers an ephemeral ThisObject-keyed coverage-only template; the gate's 3rd arm verifies sub-multiset coverage and keeps the tail's chosen order without re-prompting. Cleared at the batch resolution boundary. Persistent tier (UX): opt-in save/reapply keyed by AllCopies/oracle identity, via GameAction::SetTriggerOrderTemplate{Save,Remove,ClearAll} mirroring the merged session bypass + payload guard + manabrew-compat + per-viewer redaction). Permutes the fresh batch once, then registers an ephemeral marker so all parked-tail re-drains are coverage-only for both tiers. Frontend list deferred to phase 5. B1 key apparatus (deferred from phase 1, first consumer here): DecisionGroupKey/ DecisionKind + DecisionTemplate.key + Ord on ObjectId/CardId + decision_templates Vec on GameState with get/set/remove/clear accessors. Excluded from loop_fingerprint, kept in PartialEq (safe direction), per-viewer redacted in filter_state_for_viewer. Gate OFF byte-identical: empty decision_templates no-ops the 3rd arm; neither tier rides LoopDetectionMode. Verification: cargo fmt; clippy --all-targets -D warnings (clean); cargo test -p engine (18597 passed, 0 failed, post-rebase); 8 discriminating tests incl. the headline single-OrderTriggers-prompt on a paused multi-targeted batch. FORGE ordering_parity_sweep (full-DB, corpus regenerated at the rebased tip): B2's delta vs main is ZERO. The sweep's decision inputs -- profiles_conflict/ ability_rw_profile (ability_rw.rs), build_resolved_from_def (ability_utils.rs), and the allowlist (triggers_ordering_parity_tests.rs) -- are byte-identical to main and the full-DB path never calls the (unmodified-body) group_is_order_independent, so the run equals main's own full-DB result. It reports 20 PRE-EXISTING over-prompt divergences (the Urza's-era hidden/veiled/opal/lurking enchantment cycle) that predate this change; all are the safe auto->prompt direction (zero under-prompts => zero CR 603.3b violations). Default CI runs the fixture path (unexplained=0). These are a pre-existing full-DB-only allowlist gap, out of PR-7 scope, tracked as a follow-up. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): live loop-shortcut protocol + APNAP response window (PR-7 phase 3) Phase 3 (Part A) of combo-detector PR-7: at a CR 704.3 priority point an opt-in LoopDetectionMode::Interactive routes a confirmed live loop certificate through a shortcut-offer protocol instead of the pre-feature halt. Default (Off) and On stay byte-identical (samples() == is_on() at both live gates; the On reconcile arm is byte-verbatim inside the mode match). Bridge (interactive_loop_bridge): on a determinate lethal single-winner drain, mandatory loops auto-win (identical to the On path); optional loops OFFER WaitingFor::LoopShortcut to the determinate winner (CR 732.2a), then an APNAP WaitingFor::RespondToShortcut accept-or-shorten window over all living opponents (CR 732.2b/c) reusing the OpponentMayChoice remaining_players drain-one-advance fan-out. GameAction::DeclareShortcut{count,template} + RespondToShortcut{response} drive it. CR 732.4 all-mandatory net-progress no-loss loops end in a draw (CR 104.4b); any loss axis or optional loop falls through to the pre-feature halt. Multiplayer (>=3p APNAP) from the start. Winner authority: the crown is the sole non-faller from live_mandatory_loop_winner (nonfallers.len() == 1 requires every OTHER living player to fall, CR 104.2a), not the mechanical loop controller. Subset-lethal pods (an opponent survives) return None and never crown; a >=2-faller simultaneity floor requires equal per-cycle life deltas so all fallers cross lethal in one CR 704.3 SBA batch. Soundness (CR 608.2b): the offer latches the determinate winner; the dispatch firewall admits only DeclareShortcut/RespondToShortcut between offer and accept (a live ring re-scan is unsound -- intervening finalize/SBA/layer steps drift the paused state). Concede and Debug bypass that firewall, so apply_confirmed_shortcut re-validates the latched controller's liveness at consumption: CR 104.3a (a player who conceded has lost and cannot be crowned), CR 104.2a (the winner must still be in the game), CR 800.4a (the departed proposer's loop objects have left, so the loop dissolved). On a departed proposer it hands priority to the next LIVING player (is_alive(active_player) ? active_player : next_player_in_turn_order) rather than crowning a departed player or leaving priority on a departed holder; the APNAP remaining_players advance likewise filters out opponents who left mid-window. New serialized surface (LoopDetectionMode::Interactive, WaitingFor::LoopShortcut/ RespondToShortcut, GameAction::DeclareShortcut/RespondToShortcut, IterationCount::UntilLethal, ShortcutProposal/ShortcutResponse, LoopCertificate serde derives) is additive; Off/On configs serialize byte-identically. Frontend modal UI, smart-Shorten AI, and Fixed-count materialization are Phase 4/5 (the WaitingFor types are registered so UnhandledWaitingForModal does not fire; the AI answers RespondToShortcut with Accept and DeclareShortcut with UntilLethal via explicit non-wildcard arms). Verification: cargo fmt; clippy --all-targets -D warnings (clean); cargo test -p engine (18616 passed, 0 failed); loop_shortcut 10/10 -- On+Off byte-identity goldens, 3p APNAP cascade accept-win, CR 732.4 draw, Shorten priority window, authorization routing, and revert-failing guards: controller-concede-not-crowned, queued-opponent-concede-no-deadlock, and 3p-subset-lethal-no-crown. FORGE ordering_parity_sweep not run -- Phase 3 touches zero trigger-ordering surface; the three decision-input recipe files (ability_rw.rs, ability_utils.rs, triggers_ordering_parity_tests.rs) are byte-identical to the branch base and no parser/card-data is touched (delta=0 by construction). Independent review-impl clean after catching + fixing one soundness blocker (the Concede/Debug firewall bypass and its dead-priority remedy path). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): object-growth loop detection — offline detector (PR-7 phase 4a-core) Phase 4a-core of combo-detector PR-7: the OFFLINE object-growth loop detector — the object-axis analog of the existing stack-axis omega-coverability. Certifies (or fail-closed rejects) loops whose battlefield GROWS by inert tokens each iteration, which the strict board-equality gate + MAX_OBJECT_GROWTH ceiling could not detect. Offline only: no reducer/sampler/bridge change, so Off/On/Interactive runtime stays byte-identical to HEAD; the live empty-stack sampler (4a-live) is deferred until a certifiable live object-growth loop exists (plan ruling). Detector (analysis/resource.rs) loop_states_cover_modulo_object_growth reuses the Karp-Miller discipline on the object axis: - board_covers: absolute-ObjectId embed + inert-class confine; grown_ids = the battlefield after-before absolute-id set. - eq_except_growable: strip grown objects + clear battlefield/stack, then reuse the GameState PartialEq wholesale so EVERY non-object field strict-compares (incl. delayed_triggers / deferred_triggers / pending_trigger* / epic_effects accumulators) — the excepted set is derived, not hand-listed. - grown_objects_are_inert: no static/trigger/replacement/activated ability, non-CDA P/T, non-legendary/world, no counters (CR 704.5). Wired into the OFFLINE detect_loop classifier; the fail-loud residual_board_delta seam lights up on object growth. Fail-closed firewalls (CR 732.2a predictable-results; false-negative = grind-to-cap = today's behavior, false-positive is not acceptable): - Observation (fire_time_conditions_read_growing_class): scans the CONDITION and full EFFECT-BODY AST of every trigger/activated/static/TCE/granted-keyword ability on every battlefield object + the delayed/deferred/pending/epic store bodies, on the projected||sibling axis via the recursive scan_effect (opaque => CONSERVATIVE). - Cost (cost_surface_references_growing_class): ONE predicate over the whole cost surface — an EXHAUSTIVE no-`_` match over all 117 StaticMode variants (ModifyCost / ReduceAbilityCost dynamic_count for Reduce AND Raise; the AbilityCost-bearing Impose/Alternative/CastWith variants + the three cast-permission variants; CastWithKeyword) and an EXHAUSTIVE no-`_` match over all 198 Keyword variants (Affinity/Convoke/Improvise/Delve/Emerge/Offering/Bargain/Assist/Crew/Saddle/ Station/Teamwork/Conspire/Waterbend/Harmonize/Craft/Casualty reject on grown-class overlap; Undaunted is opponent-count SAFE), plus nested sub_ability/else_ability cost recursion. A per-creature cost INCREASE (ModifyCost Raise + ObjectCount) is the false-positive-infinite direction and rejects. Both matches are compile-break tripwires — a future StaticMode/Keyword variant cannot silently fail open. Totality guards: compile-time _gameobject_partition_is_total (all 136 GameObject fields) + _gamestate_partition_is_total (all 259 GameState fields), no `..`, so a future field is a build break until classified. objects_content_eq is extended with the 14 mutable per-object accumulators the hand-list had drifted behind (chosen_attributes / intensity / stickers / perpetual_mods / class_level / modal_back_face / goaded_by / detained_by / casting_permissions / saddled_by / ...), each classified by its write sites, not its doc-string. Stricter equality on the shared 2p CR 104.4b path is fail-safe (only suppresses a wrongful draw where an accumulator differed = not a true fixed-point); T-ON-golden stays byte-identical. Human rulings (plan section 14): keystone Witherbloom + Sprout Swarm => FAIL-CLOSED REJECT (cast-affordability preservation is unprovable from resolution deltas — affinity monotonicity is necessary but insufficient without a convoke-supply invariant the ResourceVector does not capture); object growth certifies nothing LIVE (4a-live deferred; keystone ships as the offline structural K-offline REJECT); B5 defuse tracks every enabler (phase 4c). Verification: cargo fmt; clippy --all-targets -D warnings (clean); cargo test -p engine (18642 passed, 0 failed); 20 offline object-growth predicate tests incl. K-offline keystone REJECT + R-e2 cost-increase-infinite + previously-fail-open cost keywords + one REJECT per observation/cost axis, each with a paired COVER control; T-ON-golden (Heliod+Ballista live 2p CR 104.4b) green — the fail-safe proof the objects_content_eq ADD did not over-suppress a legitimate draw loop; full loop suites green (loop_check/resource/loop_shortcut/corpus). Both firewall matches exhaustive-no-`_`. FORGE ordering-parity recipe files byte-identical (zero trigger-ordering/parser/card-data surface). Independent review: a 6-round soundness plan-review (S1-S6, each a fail-open-allowlist / equality-loosening class) + an implementation review that caught + structurally closed two cost-firewall gaps the green tests masked (the StaticMode type-misread and the cost-keyword fail-open allowlist). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): finite Fixed(N) loop-shortcut materialization (PR-7 phase 4b) Materialize a confirmed Fixed(N) loop shortcut (CR 732.2a) by driving N whole cycles of the constant-depth loop, committing atomically per cycle. Three-way per-beat drive-outcome split: cross-lethal (CR 704.5a) commits the GameOver already applied to `work` and stops; a recurred settle beat commits the cycle; any other prompt or engine error aborts to manual play (last complete cycle + priority to the living seat, CR 800.4a). Per-iteration `resolve` re-check enforces target legality (CR 608.2b) and object incarnation (CR 400.7) against the last committed board; stale/absent source aborts. Threads an Option<DecisionTemplate> onto ShortcutProposal (serde default, skip-if-none, so On/Off serialized streams are unchanged). The Fixed arm is reachable only under LoopDetectionMode::Interactive; Off/On paths stay byte-identical (candidates.rs still emits only UntilLethal). Per-beat fresh event buffers avoid re-scanning prior beats' events, matching run_auto_pass_loop. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(combo-detect): PR-7 Phase 4c — revocable-∞ (B5) + LOW-2 self-preserving shortcut CR 104.4b: an OPTIONAL beneficial (non-winning) loop is neither crowned (Path A: no faller) nor drawn (Path B: !mandatory) — mark it as a revocable-∞ capability (Path C) with its battlefield enabler set, so an enabler's departure (zones.rs apply_zone_exit_cleanup) REVOKES it. LOW-2: the AI's RespondToShortcut self-preserves via the shared smart_shortcut_response authority. Documents has_no_loss_axis's two-gate asymmetry (measured): REDUNDANT at Path C (poison caught by ==Advantage/PoisonLoss, life by is_net_progress, library by recurrence — discriminator unsatisfiable, waived) but LOAD-BEARING at Path B (sole loss-axis veto, no ==Advantage backstop — kept; a poison loop reaching the gate would be wrongly drawn without it). The Path-B runtime discriminator is waived as measured-unsatisfiable: no constructible fixture carries poison>0 to the gate — the 2-trigger form clears the loop-detect ring on OrderTriggers beats, and the single-compound-trigger form drops the poison conjunct via a parser gap. Ships the Path-B draw-gate behavioral test (control draws / variant poisons out). Follow-up (LOW, 0 live cards, synthetic-only): silent-clause-swallow parser gap — a bare-"and" cross-subject second conjunct ("...and each opponent gets a poison counter") is dropped at parse (kept only in description, not Unimplemented); fixing it unblocks a genuine Path-B has_no_loss_axis runtime discriminator. Assisted-by: ClaudeCode:claude-opus-4.8 * refactor(engine): PR-7 rebase-adaptation — classify post-rebase GameState fields for the loop cover gate Rebase of PR-7 phases 1–4c onto upstream/main d1a1e995e (#5546) surfaced two new GameState fields that the object-growth cover gate must account for. The `_gamestate_partition_is_total` totality guard (exhaustive no-`..` destructure) forces an explicit classification of every field; ONE-SIDED-SAFETY governs it (COMPARED is fail-safe, EXCLUSION is the fail-dangerous direction — exclude a field only when comparing it would break legitimate loop detection): - pending_player_scope_sacrifice_choice: COMPARED (already in upstream's `impl PartialEq`) — a differing paused-sacrifice state is correctly not a fixed-point repeat. - post_replacement_token_substitution_count (CR 614.1a copy-token count): COMPARED via one contained conjunct in `eq_except_growable` — upstream's PartialEq deliberately excludes it, but excluding a count from the cover gate is the fail-dangerous direction. It is None at every sample beat (cleared whenever waiting_for == Priority) or a constant direct-assigned count across a real copy-token loop, so comparing never suppresses a legitimate loop. Upstream's PartialEq is left untouched. - resolution_source_relatch (CR 400.7j self-move re-latch): EXCLUDED-required (measured by ordering trace, not doc-trust). The clear at stack.rs fires at the START of the next resolution; record_loop_detect_sample fires at the Priority window AFTER this resolution's self-move set it, so at the sample beat it holds this iteration's current_incarnation, which bumps every iteration. Comparing it would make every self-moving loop compare unequal (a false negative). Object growth lives in `objects` (stripped and compared by eq_except_growable), so excluding this single-object identity field cannot hide growth. Gate: fmt, clippy --workspace --all-targets -D warnings, test -p engine -p phase-ai (0 failed / 22 binaries), FORGE byte-id (ability_utils/ability_rw byte-identical) all green; 0-behind upstream/main. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(combo-detect): PR-7 Phase 4d-i — offline tapped-fodder cover + BLOCKER-2 structural sign-check Offline soundness core for object/token-growth loop detection (Witherbloom, the Balancer + Sprout Swarm class). NO live caller — the fodder predicate is exercised only by unit tests + the T-B1i discriminator; 4d-ii wires the live clone-drive hook + materializer. LoopDetectionMode stays OFF ⇒ byte-identical to pre-PR-7. New (analysis/resource.rs): - loop_states_cover_modulo_fodder_growth (pub(crate)) + board_covers_modulo_fodder + fodder_content_eq + is_fodder — tapped-split multiset cover for inert fodder growth (untapped(cur) >= untapped(prior) + strict total growth); stable-engine content via objects_content_eq (sole authority — GameState PartialEq is objects.len()-only). Drops the abstract cost-surface firewall (driven-path-only); detect_loop STAYS on loop_states_cover_modulo_object_growth (pinned by T-B1i). - driving_resources_non_decreasing + projected_player_axes + project_out_player_consumables refactor (no-`..` compiler-total destructure shared with project_out_resources) + _projected_player_axes_is_total — BLOCKER-2 structural sign-check: blanket fail-closed veto on any controller-side decrease of a projected consumable (energy/poison/per-kind player_counters + per-kind monotone object counters), closing the project_out_resources sign-unchecked hole. CR 106.1/119/122.1/122.1a/ 306.5c/310.4c/606.3/613.4c (all grep-verified). Tests: 15 inline (fodder cover + siblings; the 8-fixture sign-check family incl the structural per-kind player_counter discriminator; _projected_player_axes_is_total) + T-B1i (detect_loop returns None on a convoke-fodder pair; asserts both None AND fodder-cover==true). Reject-preservation regressions object_growth_k_offline_* / object_growth_r_a2_* stay green. Gate (direct cargo, worktree): fmt clean; check; clippy --all-targets -D warnings 0 warn; test -p engine 16175 passed / 0 failed. Plan and impl each reviewed clean by independent agents. 4d-ii carries (flagged, unreachable in 4d-i — no live caller): (1) tie the map-consumable sign-veto to the projected destructure (a projected_player_maps helper from the same no-`..` site) before wiring the live caller, so a future 2nd map consumable cannot be projected-out-and-sign-unchecked (BLOCKER-2 one field over); (2) veto controller-side damage_marked INCREASE (CR 704.5g) once object_resource_axes_match is dropped on the driven path. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(combo-detect): PR-7 Phase 4d-ii — live token-growth loop detection + CR 732.2a offer Detect infinite token-growth loops live end-to-end and offer the CR 732.2a shortcut. Acceptance bar (the 51st): Witherbloom, the Balancer + Sprout Swarm parses, casts (convoke + affinity + buyback), detects, OFFERS, and materializes N real untapped Saprolings on Accept. Foundation (injector-independent): - RecastContext + GameState.last_recast_context. EXCLUDED from impl PartialEq so the live CR 104.4b 2p-draw comparison is byte-identical to pre-PR-7; compared only via explicit cover conjuncts (eq_except_growable + loop_states_equal_modulo_resources), keeping the N7 discriminator non-vacuous. - projected_player_maps: no-`..` structural tie for map-typed player consumables (a future map consumable build-breaks); damage_marked-INCREASE veto (CR 704.5g). - triggers.rs trigger-order filter_map made exhaustive (future PinnedDecision variant build-breaks). Live-drive: - PinnedDecision::ConvokeTaps (CR 601.2h + CR 702.51a/b) with select_convoke_taps as the single convoke cost-selection authority (the ConvokeTaps replay routes through it; shares is_convoke_eligible/object_cant_tap; no parallel path). - drive_recast_iteration injector: exhaustive on WaitingFor, fail-closed (Err(RecastAbort)) on every unpinned prompt; the ManaPayment decision loop is an exhaustive non-`_` match so a future ConcreteDecision variant build-breaks. - try_offer_object_growth_shortcut hook: read-only &GameState (live write type-impossible); OFFERS via WaitingFor::LoopShortcut, never auto-resolves (CR 732.2a); SimulationProbeGuard spans both drives (no hook recursion). - Materializer third disjunct (loop_states_cover_modulo_fodder_growth) + Fixed(N) object-growth cycle; leaves a valid state (right controller, ring cleared, last_recast_context cleared, priority to next living seat CR 800.4a). - RecastContext capture gated on loop_detection.samples() — #4603 opt-in gate: in default LoopDetectionMode::Off nothing is written, so the serialized surface is byte-identical to pre-PR-7. Tests (real Witherbloom + Sprout Swarm, no synthetic on the positive): - P1 offers live (LoopShortcut, TokensCreated cert, exactly one real Saproling from the single real cast); P2 materializes N on Accept. - N1/N3 reject (no affinity / no buyback); N6 live no-offer control (damage-drain recast, CR 704.5g branch d) with a positive reach-guard, revert-probed. - off-mode #4603 gate (OFF is_none + ON reach-guard, revert-probed); select_convoke_taps x4 + convoke_pin. - The 51st loop body has NO auto-resolved randomness (vanilla Saproling, no ETB, deterministic convoke) — runtime-confirmed by P1. N4 (energy) / N5 (player-counter) no-offer controls are covered at the unit + structural-wiring level, not live fixtures: a live per-recast drain on these axes is genuinely infeasible in today's buyback-recast mechanism (an energy cost breaks buyback recurrence — the naive live test was proven vacuous by revert-probe and removed rather than shipped; no engine effect drains Experience/Ticket counters). The shared driving_resources_non_decreasing seam (branches a/b/d) is live-verified via N6. The branch-(a)/(b) sign-checks are fail-closed defensive guards, live-unreachable today but not dead code. The offer path is fail-closed-modulo-auto-randomness until the A2 determinism gate (separate follow-up pass). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(combo-detect): PR-7 Phase 4d A2 determinism gate — reject randomness-bearing recast loops (CR 732.2a) A CR 732.2a shortcut "can't include conditional actions, where the outcome of a game event determines the next action" — so an object-growth loop whose recast cycle draws game randomness must NOT be offered as a fixed-count shortcut. This wires a two-layer fail-closed determinism gate into the live offer path (`try_offer_object_growth_shortcut`), discharging the b132ad9f8 "fail-closed-modulo-auto-randomness" carry. (a) Static, compile-time-exhaustive scan of the recast spell body before driving: `effect_is_randomness_bearing` (a wildcard-free match over `Effect` — a future random-bearing variant BUILD-BREAKS rather than being silently offered) + `spell_ability_bears_randomness`; the `collect_effects` walker gains the two nested `replacement_effect` arms it was missing. Covers auto-resolved coin (CR 705.1) / die (CR 706.1a) and the field-level "game selects at random" selections (CR 701.9b) via `is_random()`. (b) Post-drive RNG stream-position backstop: the driven clone starts as an equal `state.clone()` (ChaCha20 derives `Clone`, preserving word position), so `s_n2.rng.get_word_pos() != state.rng.get_word_pos()` iff any randomness was consumed during the deterministic detection drive — from ANY source, including external triggered/replacement randomness the static scan can't see. Strictly-more-conservative (only turns OFFERs into NO-OFFERs). Soundness (measured): external coin/die/`Choose` are already rejected by the fodder cover (`scan_effect => Axes::CONSERVATIVE` reads the growing sibling axis); the recast spell body is NOT scanned by the cover, so (a) is the gate there. (b) is the universal runtime backstop. The custom-classified random card-selections (`Discard`/`RevealHand`/`ChooseFromZone`) can classify `sibling=false` and pass the cover, but each draws RNG inline inseparably from an RNG-outcome-dependent object move/mark — breaking byte-identical loop reproduction — so no reachable input makes (b) the SOLE gate today; (b) is a complete future-proof backstop rather than a currently-isolable path. Verified by an independent /review-impl pass (CLEAN-FOR-COMMIT). Tests: `object_growth_random_recast_body_does_not_offer` (recast-body coin → no offer; coin-free twin shell still OFFERs, proving the input reaches the offer path and isolating the coin as the sole disqualifier) + leaf tests discriminating `is_random()` on both selection-mode types. The paired positive `object_growth_51st_sprout_swarm_covers_and_offers` is unchanged. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(combo-detect): PR-7 Phase G2 — loop-detect ring survives a multi-trigger OrderTriggers beat (CR 603.3b/732.2a) A self-refilling mandatory loop whose cycle emits 2+ simultaneous distinguishable triggers parks at `WaitingFor::OrderTriggers` each iteration (CR 603.3b). The loop-detection ring was WIPED on that beat by two clears, so it never accrued the >=2 samples CR 732.2a detection needs — multi-trigger loops were undetectable while single-trigger loops (which settle at Priority each cycle) worked. Ordering simultaneous triggers is a forced step of putting them on the stack, not a deliberate cascade-break, so the ring must survive it. Two match-arm edits in game/engine.rs (no new variant/field): - SEAM1 (pass_priority_once_with_pipeline): guard the ring-clear `else` with `!matches!(wf, WaitingFor::OrderTriggers { .. })` — settling into the mandatory ordering window leaves the ring intact (the record path stays gated on Priority{active}; the triggers are staged off-stack in pending_trigger_order, so the stack is momentarily shrunk here). Every other settle (drain-to-empty, shrinking stack, interactive non-Priority window) still clears. - SEAM2 (apply_action): exempt `GameAction::OrderTriggers` from the deliberate- action clear (`!matches!(action, PassPriority | OrderTriggers { .. })`) — the ordering response continues a mandatory cascade. Every other non-Pass action (cast/activate/play-land) still invalidates the ring. SetTriggerOrderTemplate early-returns before this clear, so it is unaffected. Both edits are required: the ring is wiped at two moments of one cycle — SEAM1 when the resolution settles into the window, SEAM2 when the window is answered. Test (mechanism-scoped): drive_multi_trigger_ring_survives_order_triggers_beat asserts an OrderTriggers beat occurs (reach-guard) + max_ring>=2 (ring survival). Revert-probe: reverting EITHER seam alone drops max_ring 2->1 (and the measured end-to-end GameOver Some(P0) -> None), proving both seams load-bearing. Adds a drive_with_trigger_ordering helper, an idx18 single-trigger no-order-beat non-regression, and a no-refiller false-positive control. The fixture also reaches end-to-end GameOver at beat 10 (measured, documented as a NOTE not asserted — max_ring==2 sits at the 2-sample detection edge; the robust E2E multi-trigger witness is the 52nd/G1). Assisted-by: ClaudeCode:claude-opus-4.8 * refactor(engine): PR-7 rebase-adaptation — classify StaticMode::CantBeBlockedUnlessAllBlock in the loop cover gate Rebase onto upstream/main (5efd9db32) surfaced a new `StaticMode` variant (`CantBeBlockedUnlessAllBlock`) via the exhaustive no-`_` match in `static_mode_references_growing_class` (analysis/resource.rs) — the cover-gate cost-surface scanner a new variant must be classified in before it compiles. It is a combat blocking-restriction static with no cost surface, so it reads no growing-class resource → classified read-free (`=> false`), alongside its siblings CantBeBlocked / CantBeBlockedExceptBy / CantBeBlockedByMoreThan / MustBeBlockedByAll. The 11 PR-7 commits rebased patch-identical (0 conflicts). Full verify green: check + clippy --all-targets (0 warn); test -p engine lib 16237 + integration 2713, 0 failed (partition-lock=53, loop suites, on_shortcut_byte_identical_to_pre_pr7_golden). FORGE + coverage N/A (delta touches neither ability_rw nor the parser). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(combo-detect): PR-7 52nd/G1 — live poison-loss loop-winner + per-victim ResourceAxis::Poison re-key (CR 704.5c) G1 — generalize the live mandatory-loop-winner to the poison axis. The faller partition now recognizes a per-cycle poison-accruing player (delta.poison[p] > 0) alongside life-loss (CR 704.5a / 704.5c); the aggregate-poison firewall is removed; classify_win_kind reads the per-victim field AND the static .counters path (dual authority for the live + candidate-graph callers); the gate accepts LethalDamage | PoisonLoss; a poison-faller simultaneity guard (equal per-cycle delta AND equal absolute poison at cycle_end among fallers) closes a >=3p staggered-poison fail-open (CR 704.3). Option A re-key (mandated by the resource.rs / derived_views.rs guard-notes) — the analysis poison axis is re-keyed by victim PlayerId: new ResourceVector.poison: BTreeMap<PlayerId, i64> + ResourceAxis::Poison(PlayerId) (a per-victim parameterization mirroring Life(PlayerId), not a proliferated sibling); attribution_player victim-routes Poison(p) => p; has_no_loss_axis and the From<&ResourceAxis> for AxisKey drift-gate follow. Both guard-notes discharged. 3 display-only frontend edits (ResourceAxis union/tag + HUD counters family). Two-path witness architecture — the real Kilo/Freed/Relic activation combo is covered by the offline certification driver (drive_offline_kilo_freed_relic); the live equality-sampler cannot see a player-driven activation loop by construction (the stack drains between activations => the ring-sample CLEAR arm fires => the ring never accumulates a recurrence). A synthetic self-refilling drain-poison trigger (interactive_poison_axis_surfaces_in_offer_certificate) E2E-proves the re-keyed Poison(PlayerId) axis reaches a live offer certificate (real recurrence; G-5 revert-probe flips it). The win_kind==PoisonLoss full-drive and the Path-B runtime draw-veto discriminator are waived as measured-unreachable (proliferate's ProliferateChoice beat clears the ring) — G-15 kept load-bearing plus the in-code proof; the novel faller/classify logic is covered by loop_check.rs unit tests. Verification: cargo fmt; clippy -p engine --all-targets -D warnings clean; cargo test -p engine 16240 lib + 2714 integration, 0 failed; Off/On golden byte-identity green; frontend type-check + lint clean; coverage baseline unchanged; FORGE N/A (no ability_rw / same-event ordering surface). All CR annotations grep-verified against docs/MagicCompRules.txt. Assisted-by: ClaudeCode:claude-opus-4.8 * refactor(engine): PR-7 rebase-adaptation — Effect::ForEachCategory in the A2 randomness scan Main parameterized Effect::ForEachCategoryExile into Effect::ForEachCategory { action: ForEachCategoryAction } (a "parameterize, don't proliferate" refactor). Retarget the exhaustive Effect match arm in effect_is_randomness_bearing (ability_scan.rs) — ForEachCategory is deterministic category iteration, so it stays in the non-randomness group. The import union (AbilityCost / AbilityDefinition / ContinuousModification from PR-7 phase 4a-core + main's new ForEachCategoryAction) was resolved in-place during the rebase. Rebase of feat/combo-detect-pr7 (13 commits) onto upstream/main a61e7fdd9: one textual conflict (the ability_scan.rs import list) plus one semantic drift (this rename — the patch applied cleanly but referenced the removed variant, surfacing at verification not conflict). Full re-verify green: clippy -p engine --all-targets -D warnings clean; cargo test -p engine 16252 lib + 2718 integration, 0 failed; Off/On golden byte-identity preserved. Assisted-by: ClaudeCode:claude-opus-4.8 * fix(engine): PR-7 gate-relax item-4 — Typed-filter drains read projected axes The loop-cover gate's item-4 (`stack_entry_reads_projected_resource`) rejected every `TargetFilter::Typed`-targeted drain via a blanket `scan_target_filter(Typed) => Axes::CONSERVATIVE` (projected:true unconditionally). Combined with item-3's raw `targets.is_empty()` coarseness (COMMIT 2), this co-dominated the rejection of escalating targeted-drain loops (Vito, Thorn of the Dusk Rose), making them undetectable. Refine ONLY the `.projected` field of the `Typed` arm to `typed_filter_reads_projected(tf)` — the event/sibling axes are kept literal `true` (byte-preserved). New exhaustive `scan_filter_prop` classifier (no `_` wildcard, unknown => CONSERVATIVE): QuantityExpr/TargetFilter/PlayerFilter-bearing props recurse; ControllerRef-bearing recurse (every outcome projected:false); `CountersPutOnThisTurn`/`ControllerChoseLabel` fail-closed CONSERVATIVE. Ground truth: a prop is projected iff `project_out_resources` clears the field its runtime eval reads. `WasDealtDamageThisTurn` (eval reads `state.damage_dealt_this_turn`) and `ZoneChangedThisTurn` (eval reads `state.zone_changes_this_turn`) are both cleared by `project_out_resources` and NOT strict-compared by `object_resource_axes_match` (which compares only `damage_marked` + `counters`) — classified CONSERVATIVE (CR 120 / CR 400 / CR 603.6a). The variant doc's "damage_marked > 0" was stale; runtime eval reads the journal. The remaining "this-turn" leaves (`EnteredThisTurn`/`Attacked`/`Blocked`/`ControlledContinuously`) read non-cleared object/combat fields => NONE. cfg-test flagged-set shrinks {Dethrone,Increment,Soulbond,Training} => {Dethrone,Increment}: the refinement clears Soulbond/Training fail-closed false-positives (their Typed filters carry no projected prop). Verify: clippy -p engine --all-targets clean; lib 16260 + integration 2719, 0 failed; all 5 typed_filter_* classifier discriminators pass. Assisted-by: ClaudeCode:claude-opus-4.8 * fix(engine): PR-7 gate-relax item-3 — forced-unique targets clear the ordering-input gate The loop-cover gate's item-3 (`stack_entry_has_no_ordering_input`) read raw `ability.targets.is_empty()`, rejecting any non-empty-target stack entry as introducing ordering freedom. That is too coarse: a forced-unique target (the sole legal assignment) offers no ordering choice. Combined with item-4's Typed-filter projected-axis blind spot (COMMIT 1), the two conjuncts co-dominated the rejection of escalating targeted-drain loops. Widen `stack_entry_has_no_ordering_input`: a non-empty-target entry passes iff `forced_unique_targeting(state, ability)` holds — `build_target_slots` + `auto_select_targets_for_ability(...) == Ok(Some(_))` (exactly one legal assignment; fail-closed on Err / >=2 candidates / empty slots). CR 603.3d / CR 608.2b: a determinate single-target ability contributes no resolution-choice branching to the loop cover. Vito, Thorn of the Dusk Rose 2-player determinate-win now EMPIRICALLY detects and offers the CR 732.2a shortcut. Discriminator = `life(victim) > 0`: early omega-cover detection leaves the drained opponent positive, whereas losing detection grinds them to <= 0 via natural resolution (CR 704.5a), which also wins P0 — so `GameOver{P0}` alone is not discriminating. Negative controls: `n1_open_target_growing_still_rejected` (open/non-unique targets still reject) and `item6_still_vetoes_under_forced_unique_targets` (the resolution-choice axis, item-6, still vetoes independently under forced-unique targets). Verify: clippy clean; integration vito_bond_conqueror_2p_determinate_win + n1_forced_unique_targeted_cover_true + 2 negative controls pass; both per-conjunct revert-probes flip (item-3 revert => no-detect; item-4 revert => no-detect). Assisted-by: ClaudeCode:claude-opus-4.8 * refactor(engine): PR-7 rebase-adaptation — classify AbilityCost::UnattachFrom in the object-growth cost scan Rebase of feat/combo-detect-pr7 (16 commits) onto upstream/main 1c3eee9dd (12 new commits incl. Captain America, First Avenger #5552 + release v0.22.0). One drift: main's Captain America commit added `AbilityCost::UnattachFrom { .. }` (unattach a named permanent as an activation cost). The Phase-4d object-growth cost scan's `scan_ability_cost` (ability_scan.rs) is a no-wildcard exhaustive match, so the new variant surfaced at compile (E0004), not as a textual conflict. `UnattachFrom` is a fixed structural cost with no dynamic board read and no projected-resource drain — classify it `Axes::NONE` alongside the existing `AbilityCost::Unattach` (main categorizes both as `CostCategory::Unattaches`). Every other exhaustive AbilityCost match in the engine already covers the variant (main-added or pre-existing); only this Phase-4d scan needed the arm. Full re-verify green: clippy -p engine --all-targets clean; lib 16272 + integration 2750, 0 failed. The two commits the 3-way merge re-anchored (phase-4b `apply_confirmed_shortcut` context; phase-4d-ii recast capture following main's `finalize_cast_with_phyrexian_choices` -> `_inner` split) are semantic-guarded green by b3_materialize_* and object_growth_51st_*; Vito gate-relax E2E + poison-axis E2E pass. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): PR-7 53rd Pentad — shared strict-growth Generic-counter loop-cover predicate Cover the infinite charge-counter-growth proliferate loop (Pentad Prism + Kilo/Freed/Relic) and offer the CR 732.2a resource shortcut. New sibling predicate `loop_states_cover_modulo_counter_growth` (analysis/resource.rs): strict-growth-only over the PRESERVED `Generic` object-counter axis, fail-closed. - `CounterGrowthDisposition {StrictGrowth, Stable, Consumed}` (typed, not bool). `classify_generic_counter_growth` is a wildcard-free `CounterType` match — the per-type classification table itself (a new variant won't compile until classified), kept in lockstep with `is_monotone_loop_resource`. `Consumed` (any `Generic` counter fell) takes precedence over `StrictGrowth` (mixed grow+consume rejects) — the infinite-consume soundness trap. - `equalize_generic_counters` overwrites only `Generic` counts with `prior`'s, then rides the existing `loop_states_equal_modulo_resources`, so any non-`Generic` drift still rejects via the untouched equality. Wired at exactly two non-GameOver seams (the firewall): `detect_loop` gate-1 (offline `Advantage` certification) and `interactive_loop_bridge` Path-C (live revocable-unbounded capability mark). Deliberately NOT wired into `live_mandatory_loop_winner` (GameOver-capable) — a conservative fail-closed boundary. A charge/burden growth loop classifies `WinKind::Advantage` (CR 104.4b: an optional loop is not a draw), so an over-claim is a declinable offer / revocable mark, never a wrongful game-end — the revocability soundness bound (the equalize step introduces a new projected `Generic`-counter axis, sound by this bound, not by firewall parity). GENERAL over preserved-`Generic` growth: Pentad Prism (charge) and The One Ring (burden) are the SAME cover, so One-Ring's growth cover is discharged here — its later pass is offer-layer + card-verify only. Two-path coverage (matches the existing architecture): the real proliferate loop is offline-certified (`drive_offline_pentad_prism`, real Kilo/Freed/Relic + Pentad seeded >=1 charge via Sunburst, CR 702.44a); the live Path-C disjunct is exercised by a sampler-visible self-refilling charge-growth trigger — the live equality sampler records only non-shrinking-stack cascades, and a `ProliferateChoice` beat hits the pre-existing ring-clear arm. 8 discriminating tests (4 unit + #5/#8 offline + #6/#7 live), all non-vacuous: the consume control (#2) is a same-`Generic("charge")` decrease that flips only under a direction-blind revert; #8 asserts `Some(Advantage, Counter(Other,Other))`; #6 marks the counter axis without any GameOver; #7 proves #4603-OFF byte-identity. Verify: clippy -p engine --all-targets 0/0; analysis lib 173 + loop_shortcut 28 + the 8 new tests green. Plan-reviewed + impl-reviewed clean. Assisted-by: ClaudeCode:claude-opus-4.8 * test(engine): PR-7 54th Walking Ballista — offline LethalDamage acceptance + >2p win-authority controls Walking Ballista's infinite-damage combo (proliferate the +1/+1 counters on the Kilo/Freed/Relic mana-neutral engine, then remove-to-ping) is already covered by existing machinery — this pass adds only acceptance + guard tests, no production change. Rationale (measured, twice-reviewed): - The +1/+1 counters are MONOTONE, so `project_object_for_loop` strips them and the existing `loop_states_equal_modulo_resources` gate-1 already certifies the loop with the damage/life axes as the movers (unlike the 53rd Pentad's PRESERVED `Generic` charge, which needed the new counter-growth cover). - `classify_win_kind` already returns `LethalDamage` for opponent `damage_dealt > 0`. - The loop is offline-only: every cycle contains an `ActivateAbility` (Relic tap, Freed untap, ping) and `apply_action` clears `loop_detect_ring` on every non-PassPriority/OrderTriggers action, so the live GameOver-capable seams are never reached. Ballista's `LethalDamage` is an OFFLINE cert (same class as the 52nd Kilo offline PoisonLoss), never a live game-end. So NO live-lethal offer and NO >2p damage-distribution pin are built here (both are unreachable for the real card; the distribution pin belongs to the future combo-declaration UI pass, whose cert application must route through the all-opponents-fall win-authority). Tests (test/harness-only; the driver is `#[cfg(any(test, feature = "combo-verify"))]`, absent from `DRIVERS`/`CORPUS`, so the 53/12/4/37 partition is unchanged): - `drive_offline_kilo_freed_relic_ballista` — standalone offline driver mirroring `drive_offline_pentad_prism_seeded` (real cards, abilities selected by effect/cost). - N2 `drive_kilo_freed_relic_ballista_certificate` — seed 2 → `LethalDamage` + `covers([DamageDealt(P1)])` + `!mandatory` (a resolved opponent ping is the only way to populate the damage axis). - N3 `drive_kilo_freed_relic_ballista_x0_no_damage_axis` — seed 0 → dead 0/0, the ping activation is rejected (bool-returning `activate_and_resolve`, no panic), degrades to the pure-proliferate `Advantage` loop with no damage axis. - N5 `ballista_mp_single_opponent_ping_no_false_win` (→ None) + `ballista_mp_all_opponents_distributed_ping_wins` (→ Some(P0)) — CR 104.2a: a >2p win requires all opponents to fall; reverting the `nonfallers.len() != 1` guard flips the negative None→Some(P0). (Self-ping→Advantage is covered by the existing `classify_win_kind_controller_only_damage_is_not_lethal` unit control.) Verify: clippy -p engine --all-targets 0/0; the 4 new tests + partition/shape locks (53/12/4/37) green. Plan-reviewed + impl-reviewed clean. Assisted-by: ClaudeCode:claude-opus-4.8 * test(engine): PR-7 One-Ring — opponent-burden proliferate Advantage cert + downstream upkeep-lethal (reuse-only) Test-only pass. The One Ring's Generic("burden") counter grows on an OPPONENT's copy under the Kilo/Freed/Relic infinite-proliferate engine; the combo detector already certifies this via the 53rd Pentad loop_states_cover_modulo_counter_growth cover — ZERO production change. Part A (offline cert): drive_offline_kilo_freed_relic_one_ring — a cfg-gated standalone structural twin of drive_offline_pentad_prism_seeded with The One Ring installed on P1 (opponent). NOT added to DRIVERS/CORPUS (partition 53/12/4/37 held). - seed=1 -> detect_loop = Some(WinKind::Advantage), covers Counter(Other,Other); the burden Counter axis is player-unattributed, so an opponent's burden certifies identically to a controller's charge. - seed=0 control -> Some(Advantage) with NO Counter axis (the loop stays Some via Trigger(Proliferate)) — the runnable discriminator. Part B (downstream lethal): materialize N burden via the counter authority add_counter_with_replacement, advance to the opponent's upkeep, and let the printed .triggers[1] LoseLife(CountersOn(Source,"burden")) fire and resolve. N >= life -> CR 704.5a SBA -> GameOver{winner:Some(P0)} (CR 104.2a). Sub-lethal control (N = life-1) -> opponent survives, no GameOver. CR 701.34a (proliferate), 104.4b (optional loop != draw), 704.5a, 104.2a, 500.6/503.1a (upkeep triggers), 608.2, 122.1, 603.6a, 602.1 verified. Fixture: surgical single-key insert of "the one ring" from the export (one entry; no other fixture entry moved). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): PR-7 combo-declaration UI Stage 1 — ShortcutDecisionSchema on the loop-shortcut offer (engine READ-side) Stage 1 of 3 for the combo-declaration UI (engine schema exposure -> frontend render/collect -> forward-pointer routing). Engine-only, READ-side: exposes a per-viewer decision schema on WaitingFor::LoopShortcut so the frontend (Stage 3) can render an offered CR 732.2a loop's open per-iteration choices and collect pins, computing nothing. A clean parent commit that LOCKS the FE contract. New serde types (analysis/decision_template.rs), the 1:1 read-side dual of the 5 loop-declaration PinnedDecision variants (Order excluded — CR 603.3b trigger-order is not a loop-declaration choice): ShortcutDecisionSchema { iteration_count: IterationCount, points: Vec<DecisionPoint> } DecisionPointKind { Targets{legal_targets}, ConvokeTaps{tappable}, Mode, MayChoice, UnlessBreak } - schema field on WaitingFor::LoopShortcut (#[serde(default)]), populated at both offer sites. build_shortcut_schema CARRIES the detection decision list (build_recast_template output — single authority, no re-derivation); drain offers carry no pins -> empty schema. The only live Stage-1 decision-point is ConvokeTaps; the 4 other builder producers defer to Stage 2 (debug_assert!+None fail-safe — no producer emits them until the targeted-loop gate-relax). - iteration_count: UntilLethal for a determinate CR 704.5a/704.5c drain, else Fixed(1) (an FE-overridable display seed). - MP redaction inside filter_state_for_viewer (CR 732.2a): a non-controller viewer's schema drops hidden-info legal-targets, reusing the existing hand visibility composite keyed on each target object's owner+zone. A structural no-op for Stage-1's only live (public-battlefield ConvokeTaps) set, but the seam + a two-directional revert-probed test lock the security contract before Stage 2 produces hidden-info Targets sets. Tests: T1 live convoke-taps (board-derived, tapped-payer excluded), T2 drain empty+UntilLethal, T3 iteration_count exhaustive over WinKind, T4 redaction (controller keeps hidden target / non-controller drops only it — revert-probe leaks), T6 serde round-trip FE-consumable. cargo check --workspace --all-targets green (phase-ai/wasm/server compile with the new field). Assisted-by: ClaudeCode:claude-opus-4.8 * feat(engine): PR-7 combo-declaration UI Stage 2 — pin ingestion + drive-and-measure win derivation (engine WRITE-side) Builds the engine WRITE-side of the loop-shortcut combo-declaration UI on top of the Stage-1 schema: - validate_pins: fail-closed value-legality firewall (PinValidation), exhaustive over PinnedDecision, hooked at the top of handle_declare_shortcut before APNAP; skipped for choice-free (empty) schemas whose win derivation is pin-independent (preserves the Fixed(N) resolve firewall). CR 608.2b/700.2/732.6. - drive_one_shortcut_cycle + inject_pinned_answer: general mid-drive pin injector (CycleOutcome), extracted behavior-identical from materialize_fixed_shortcut. TriggerTargetSelection re-resolves pins per iteration; the OrderTriggers arm uses the internal reconcile-free apply_action(OrderTriggers) path (never drain_order_triggers_with_identity) so the drive cannot re-enter the offer/crown hook. CR 603.3b/608.2b/702.51a. - E1 crown (apply_until_lethal_shortcut): no longer an unconditional crown. Drives one pin-faithful cycle, measures ResourceVector::delta(snapshot(boundary), snapshot(work)), runs live_mandatory_loop_winner VERBATIM, and crowns GameOver{winner: Some(controller)} only when the measured winner is the proposer — else manual fallback (clearing last_recast_context to avoid an object-growth re-offer livelock). Inherently closes the latent Advantage-loop-declared-UntilLethal mis-crown. CR 704.5a/704.5c/104.2a/800.4a/732.2a/732.2c. - F2 hardening: the >=2-faller crown path also re-verifies fallers_lives_pairwise_equal on the boundary/pre-drive faller lives (the offer's own certification inputs), so a staggered-death unequal-absolute-life drain does not crown. CR 704.3. Tests A-G (loop_shortcut integration + inline stage2_injector) each carry a soundness discriminator with a measured revert-probe and a paired positive reach-guard. Full suite green; cargo check/clippy --workspace --all-targets RC=0. Assisted-by: ClaudeCode:claude-opus-4.8 * feat(client): PR-7 combo-declaration UI Stage 3 — loop-shortcut declare + respond modals (display-only) Frontend for the loop-shortcut combo-declaration UI (engine schema locked by Stage 1/2): - LoopShortcutModal: two display-only modals. DeclareShortcutModal (confirm-only — renders the offer summary + engine-proposed iteration_count + ConvokeTaps eligibility READ-ONLY; dispatches DeclareShortcut{count, template:null}) and RespondToShortcutModal (renders the viewer-filtered ShortcutProposal; Accept / Break-out dispatches RespondToShortcut{response}). Mirror ModeChoiceModal. CR 732.2a/2b/2c. - 5 new TS mirror types (ShortcutDecisionSchema, DecisionPoint, DecisionPointKind, DecisionSlot, DecisionSource) matching the engine serde shapes; the stale LoopShortcut WaitingFor type gains its required schema field. - 3-site actor-gate fix (CR 732.2a, mirrors engine acting_player()): usePlayerId.ts (human render), aiController.ts + p2p-adapter.ts (AI drivers) admit LoopShortcut{controller} into the engine-derived authorized-submitter path — pure routing, no logic. Closes the On-mode AI-controller hang. - Registry migration: LoopShortcut + RespondToShortcut moved PENDING_MODAL_PHASE5 -> HANDLED_WAITING_FOR_TYPES + dispatch-coverage literals; both mounted in GamePage. i18n comboShortcut.* in all 7 locales (parity-gate green). Display-only: every value from an engine schema field, template:null, ConvokeTaps read-only, zero React game-state computation. Pin-capture deferred (rides the >2p targeted lane). T1-T8 discriminating tests; targeted FE gate green (type-check, lint 0-errors, vitest). Assisted-by: ClaudeCode:claude-opus-4.8 * fix(client): mirror GameAction::SetTriggerOrderTemplate in the FE union (PR-7 phase-2 boundary sync) PR-7 phase 2 (commit 67113b225, trigger-order resolver) added engine GameAction::SetTriggerOrderTemplate (types/actions.rs:641) but never mirrored it in the frontend GameAction union, leaving boundary-guardrails.test.ts's engine<->FE lockstep red. Latent because Stages 1-2 were engine-only and the frontend gate never ran on the branch until Stage 3. Serde-faithful transcription mirroring the SetMayTriggerAutoChoice/MayTriggerAutoChoiceOp sibling: SetTriggerOrderTemplate -> TriggerOrderTemplateOp{Save{sources,order}|Remove{key}|ClearAll} -> DecisionGroupKey{sources,kind}/DecisionKind. Pure type mirror, zero logic (no dispatcher/handler — a SetTriggerOrderTemplate UI, if ever wanted, is the trigger-order-resolver feature's concern). boundary-guardrails now green; full FE suite green + type-check clean. CR 603.3b. Assisted-by: ClaudeCode:claude-opus-4.8 * refactor(engine): PR-7 rebase-adaptation — classify StaticMode::CountersCantBeRemoved in the loop cover gate Upstream #5663 (a21ac2493) added StaticMode::CountersCantBeRemoved (Fear of Sleep Paralysis). PR-7's exhaustive no-wildcard cost-surface scan static_mode_references_growing_class (analysis/resource.rs) must classify it: it is a counter-removal prohibition with no payment cost (counter_type is a filter, not a board read), so its cost surface is read-free -> false, grouped with CountersPersistAcrossZones. Rebase-adaptation only; no behavior change to PR-7's own commits. Assisted-by: ClaudeCode:claude-opus-4.8 * fix(engine): annotate analysis-time zone-clone mutations for the zone-authority census (PR-7 CI) The CI-only engine-authority ratchet (scripts/check-engine-authorities.sh -> zone_authority_census.py; not run by clippy/test, so the local gate was green) flagged two NEW raw zone-container mutations in PR-7 code: - analysis/resource.rs::eq_except_growable — clears battlefield on a DISCARDED comparison CLONE for loop-cover equality. Takes &GameState and mutates a local clone consumed by ==; no gameplay zone event can fire on it. - game/engine.rs::normalize_recast_frame — prunes hand/graveyard/library on a DISCARDED recast comparison-frame CLONE. Takes &GameState and returns a normalized clone; no gameplay zone event fires on it. Both are genuinely non-replaceable analysis-time normalizations (not live gameplay zone changes routed through zone_pipeline), so each site is annotated // allow-raw-zone: <reason> and the frozen baseline (scripts/zone-authority-baseline.txt) is regenerated via --write (2 exempt rows added; no baseline row dropped). Gate B PASS. Comment-only code change; no behavior change. Assisted-by: ClaudeCode:claude-opus-4.8 * fix(engine): address #5672 review — Clash randomness (CR 732.2a), R2 typed enums, FE dead code - Classify Effect::Clash as randomness-bearing: CR 701.30a reveals the top of a shuffled library (hidden info at pin time) and CR 701.30d decides the winner by revealed mana value, so a loop whose recast body contains a clash is not shortcut-eligible under CR 732.2a. Moved the false->true arm in effect_is_randomness_bearing + added a discriminating assertion to randomness_classifier_discriminates (revert-probe: moving it back flips the assertion). - R2 (no bool fields): PinnedDecision/ConcreteDecision take/pay bool -> MayChoiceOption{Take, Decline} / UnlessPaymentOption{Pay,Decline}; RecastContext.uses_buyback bool -> BuybackUsage {Used,NotUsed} with a pays() accessor for the DecideOptionalCost consumer. - Remove the dead-code '?? []' guard in LoopShortcutModal (schema.points is non-optional). Assisted-by: ClaudeCode:claude-opus-4.8 * fix(PR-5672): expose interactive shortcut UI * feat(engine): CR 732.2a LoopShortcut controller-decline + engine-owned convoke count (#5672 review) Addresses maintainer matthewevans' CHANGES_REQUESTED on #5672 (un-holds follow-up #24). BLOCKER (CR 732.2a): the loop winner was forced to propose a shortcut (only DeclareShortcut was accepted at WaitingFor::LoopShortcut). CR 732.2a makes proposing a shortcut a MAY. Add a unit GameAction::DeclineShortcut: the controller-only decline restores ordinary priority (living_priority_seat) and clears the object-growth routing context (last_recast_context) so the post-return reconcile does not re-offer. Seam-1 (interactive/loop_detect_ring) re-offer is already suppressed by apply_action's deliberate-action ring invalidation (a deliberate break clears the ring); the handler owns only the Seam-2 gap. A genuine re-recurrence re-arms the offer. Controller-only authorization is enforced upstream via check_actor_authorization. NON-BLOCKING: move the convoke tappable-count derivation out of React into the engine-owned ShortcutDecisionSchema (convoke_tappable_count, CR 702.51a); the modal renders it directly. Adds a FE Decline button (display-only) + comboShortcut.decline in all 7 locales. Two discriminating end-to-end decline tests (interactive dismissal + object-growth suppression, each with an independent revert-probe) + a wrong-actor auth-reject test. #4603 OFF stays byte-identical (no new GameState field; the offer is never installed when OFF). Assisted-by: ClaudeCode:claude-opus-4.8 * refactor(engine): PR-7 rebase-adaptation — #5686 legacy_ field renames + drain/draw_sequence adds in the partition guard Rebase onto upstream/main 6f7cee8e8 crosses #5686, which renamed six GameState fields to their legacy_ serde-migration names (post_replacement_{continuation,source,applied,event_source,event_target} -> legacy_*, pending_multi_draw -> legacy_pending_multi_draw) and added post_replacement_drains + draw_sequences. Update the exhaustive _gamestate_partition_is_total destructure (no '..', so a field-set mismatch is a hard E0027/E0559) to the new field names; the two new fields join the destructure so the cover-gate re-audit tripwire stays total. Soundness unchanged: draw_sequences is loop_equal-compared in GameState PartialEq; post_replacement_drains is skip_serializing_if=is_empty (settle-empty, loop-neutral). Assisted-by: ClaudeCode:claude-opus-4.8 * fix(PR-5672): separate shortcut proposer and winner * test(PR-5672): exercise split shortcut authority * fix(PR-5672): rebase game-state totality guard * fix(PR-5672): group shortcut offer inputs * fix(PR-5672): clarify shortcut authorities --------- Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Automated update of README coverage badges from latest card data.