From 3d0e8c883f7171a6657e54330b4de4b934a15aca Mon Sep 17 00:00:00 2001 From: lgray Date: Tue, 4 Aug 2026 15:34:59 -0500 Subject: [PATCH 1/9] =?UTF-8?q?fix(engine):=20show=20the=20=E2=88=9E=20bad?= =?UTF-8?q?ge=20while=20a=20loop=20collapse=20is=20merely=20scheduled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CR 732.2c hide-gate in `derive_views` suppressed every ∞ surface for the whole accept→CR-500.5-boundary window: the per-axis resource badge rows, the ∞ object pile and the ∞ counter pills each `continue`d when a pending materialization stash named their axis. That window covers every detected infinite loop in production, so `DerivedViews::unbounded_pile` was empty for every token loop and `unbounded_counters` for every counter loop. Measured on two real 4p dumps — Witherbloom/Sprout Swarm and Kilo/Freed from the Real/Pentad Prism — whose `derived` blocks carry none of the ∞ keys while their stores are populated and `waitingFor` is still `Priority`. Delete the three guards and project the same authority's axis set as an additive, omit-when-empty `DerivedViews::scheduled_collapse` TAG instead. The badge stays visible while the collapse is pending; the tag tells a surface that a finite N is already fixed, and is the hook a later ∞→N affordance can read. The `Mana(_)` retain is preserved and repurposed from hide-filter to tag-filter: mana is already materialized and spendable, so it renders ∞ untagged. The store is still never filtered, so `unbounded_resources` and `unbounded_loop_enablers` stay in CR 104.4b / CR 110.1 lockstep — which is what keeps `zones::apply_zone_exit_cleanup`'s defuse armed — and `clear_collapsed_materializations` still ends both the ∞ and the tag at the CR 500.5 boundary. Cross-seam coverage for both loop families: two engine-emitted wire goldens (token and counter) drive a new client suite through `groupByName`, `familyOf` and `useUnboundedCounterTypes`, pinning the real dump → `derive_views` → serde → client path instead of fabricating derived shapes on each side. Assisted-by: ClaudeCode:claude-opus-5 --- client/src/adapter/types.ts | 15 ++ .../test/fixtures/unbounded-counter-wire.json | 29 +++ .../test/fixtures/unbounded-token-wire.json | 20 ++ .../__tests__/unboundedWireSeam.test.ts | 122 ++++++++++++ crates/engine/src/game/derived_views.rs | 131 +++++++------ crates/engine/src/types/game_state.rs | 61 +++--- .../tests/integration/combo_infinite_pile.rs | 162 +++++++++------- .../kilo_live_offer_from_real_dump.rs | 104 ++++++---- .../engine/tests/integration/loop_shortcut.rs | 180 +++++++++++------- .../integration/loop_shortcut_mana_engine.rs | 103 ++++++---- 10 files changed, 637 insertions(+), 290 deletions(-) create mode 100644 client/src/test/fixtures/unbounded-counter-wire.json create mode 100644 client/src/test/fixtures/unbounded-token-wire.json create mode 100644 client/src/viewmodel/__tests__/unboundedWireSeam.test.ts diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 3ff8874280..e119ffd899 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -2867,6 +2867,21 @@ export interface DerivedViews { * `engine::game::derived_views::DerivedViews::unbounded_counters`. */ unbounded_counters?: Record; + /** + * CR 732.2c: the `(engine-attributed player, axis)` pairs whose DEFERRED unbounded growth has an + * accepted-but-not-yet-applied finite collapse waiting at the next CR 500.5 boundary. A TAG, not + * a filter — `unbounded_resources` / `unbounded_pile` / `unbounded_counters` stay populated while + * scheduled and still render `∞`. Join a row to its tag by exact `(player, axis)` equality. + * + * SCOPE LIMIT: `Mana` axes are deliberately absent (already spendable), so this UNDER-REPORTS + * "which ∞ rows will stop being ∞" — a mana ∞ ends at the CR 500.5 step/phase end rather than via + * a materialization. It answers the narrower question: which ∞ rows name DEFERRED growth a + * registered materialization will cash out at the boundary. + * + * Empty/omitted when nothing is scheduled. Mirrors + * `engine::game::derived_views::DerivedViews::scheduled_collapse`. + */ + scheduled_collapse?: UnboundedResourceView[]; } /** Mirrors `engine::types::game_state::NextSpellModifier` (serde tag="type"). */ diff --git a/client/src/test/fixtures/unbounded-counter-wire.json b/client/src/test/fixtures/unbounded-counter-wire.json new file mode 100644 index 0000000000..2ea310a90c --- /dev/null +++ b/client/src/test/fixtures/unbounded-counter-wire.json @@ -0,0 +1,29 @@ +{ + "scheduled_collapse": [ + { + "axis": { + "Counter": [ + "Other", + "Other" + ] + }, + "player": 0 + } + ], + "unbounded_counters": { + "405": [ + "charge" + ] + }, + "unbounded_resources": [ + { + "axis": { + "Counter": [ + "Other", + "Other" + ] + }, + "player": 0 + } + ] +} diff --git a/client/src/test/fixtures/unbounded-token-wire.json b/client/src/test/fixtures/unbounded-token-wire.json new file mode 100644 index 0000000000..0b1ecc5995 --- /dev/null +++ b/client/src/test/fixtures/unbounded-token-wire.json @@ -0,0 +1,20 @@ +{ + "scheduled_collapse": [ + { + "axis": "TokensCreated", + "player": 0 + } + ], + "unbounded_pile": [ + 402, + 403, + 404, + 407 + ], + "unbounded_resources": [ + { + "axis": "TokensCreated", + "player": 0 + } + ] +} diff --git a/client/src/viewmodel/__tests__/unboundedWireSeam.test.ts b/client/src/viewmodel/__tests__/unboundedWireSeam.test.ts new file mode 100644 index 0000000000..2aed531fa7 --- /dev/null +++ b/client/src/viewmodel/__tests__/unboundedWireSeam.test.ts @@ -0,0 +1,122 @@ +/** + * CR 732.2c cross-seam pin. Both JSON files are ENGINE-EMITTED by + * `combo_infinite_pile::real_4p_object_growth_accept_writes_infinite_pile` and + * `kilo_live_offer_from_real_dump::kilo_accept_marks_pentad_charge_as_unbounded_display_target`, + * each driving a REAL 4-player dump through the REAL APNAP accept. Regenerate with + * `UPDATE_WIRE_GOLDEN=1 cargo test -p phase-engine --test integration `. Never hand-edit them. + * Every existing client test that touches these channels hand-writes its own `derived` block, so + * this file is the only place the engine's wire shape and the client's readers meet. + * The `unbounded_pile → Set` hop is performed here rather than by `gameStateView.ts`, because + * driving that function would require committing a whole `GameState`; the ids, the field name and + * the value encoding — the parts that actually differ across the language boundary — are + * engine-authored. + */ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { DerivedViews, GameObject, ObjectId, ResourceAxis } from "../../adapter/types"; +import { familyOf } from "../../components/hud/HudBadges"; +import { useUnboundedCounterTypes } from "../../hooks/useUnboundedCounterTypes"; +import { buildGameObject } from "../../test/factories/gameObjectFactory"; +import { buildGameState } from "../../test/factories/gameStateFactory"; +import counterWire from "../../test/fixtures/unbounded-counter-wire.json"; +import tokenWire from "../../test/fixtures/unbounded-token-wire.json"; +import { setGameStoreForTest } from "../../test/helpers/gameStoreHelpers"; +import { groupByName } from "../battlefieldProps"; + +const saproling = (id: ObjectId, tapped: boolean): GameObject => + buildGameObject({ id, name: "Saproling", tapped, card_id: 0, controller: 0, owner: 0 }); + +describe("unbounded ∞ wire seam (engine-emitted goldens)", () => { + // RESIDUAL: this closes the ID/shape half only — the TS-side `GameObject`s are factory-built, so + // the test cannot see an engine/client group-PARTITION mismatch, and `isUnboundedPile`'s + // `members.every(...)` (`battlefieldProps.ts`) degrades such a mismatch silently to `×N` rather + // than failing, which is exactly the user's symptom class. + + it("emits populated ∞ channels and omits the empty ones", () => { + // (1) reach-guard: the engine emitted a populated pile, so the group assertions below are + // not run against an empty set. + expect(tokenWire.unbounded_pile).toEqual([402, 403, 404, 407]); + // (2) reach-guard + the two counter seam facts: the map key is a JSON STRING, and + // `CounterType` serializes FLAT ("charge", not {"Generic":"charge"}). A regressed Serialize + // would silently blank every ∞ pill. + expect(counterWire.unbounded_counters).toEqual({ "405": ["charge"] }); + // (3) omit-when-empty, engine-attested in BOTH directions. + expect("unbounded_pile" in counterWire).toBe(false); + expect("unbounded_counters" in tokenWire).toBe(false); + }); + + it("drives the real groupByName pile predicate off engine ids", () => { + const unboundedPileIds: ReadonlySet = new Set(tokenWire.unbounded_pile); + const objects: GameObject[] = [ + ...[402, 403, 404, 407].map((id) => saproling(id, true)), + ...[406, 408, 409, 410].map((id) => saproling(id, false)), + buildGameObject({ + id: 401, + name: "Witherbloom, the Balancer", + tapped: true, + card_id: 9001, + controller: 0, + owner: 0, + }), + ]; + + const groups = groupByName(objects, new Set(), unboundedPileIds); + const groupOf = (id: ObjectId) => { + const group = groups.find((g) => g.ids.includes(id)); + expect(group, `no group contains ${id}`).toBeDefined(); + return group!; + }; + + // NEGATIVES FIRST, POSITIVE LAST — deliberate. A failing `expect` throws and skips the rest of + // the `it`, and the regression class this file exists to catch (the engine stops emitting the + // pile) reds the POSITIVE. Asserting the negatives first keeps them observable as the paired + // control in that same run instead of being skipped by the positive's throw. + // + // (5) paired NEGATIVE from the SAME groupByName call: same name, differs only on `tapped`. + expect(groupOf(406).ids).toEqual([406, 408, 409, 410]); + expect(groupOf(406).isUnboundedPile).toBe(false); + // (6) free third negative: tapped, but not a pile member — so it is not "everything tapped". + expect(groupOf(401).isUnboundedPile).toBe(false); + // (4) paired POSITIVE: the tapped Saprolings the engine named. + expect(groupOf(402).ids).toEqual([402, 403, 404, 407]); + expect(groupOf(402).isUnboundedPile).toBe(true); + }); + + it("decodes both externally-tagged axis shapes through the real familyOf", () => { + // (7) unit variant — a bare string on the wire. + expect(familyOf(tokenWire.unbounded_resources[0].axis as ResourceAxis)).toBe("tokens"); + // (8) data variant — a single-key object on the wire. + expect( + familyOf(counterWire.unbounded_resources[0].axis as unknown as ResourceAxis), + ).toBe("counters"); + // (9) redundant reinforcement, kept as documentation of intent: it cannot fail unless (7) or + // (8) already has. + expect(familyOf(tokenWire.unbounded_resources[0].axis as ResourceAxis)).not.toBe( + familyOf(counterWire.unbounded_resources[0].axis as unknown as ResourceAxis), + ); + }); + + it("feeds the real useUnboundedCounterTypes hook from the engine wire", () => { + setGameStoreForTest({ + gameState: buildGameState({ derived: counterWire as unknown as DerivedViews }), + }); + // (10) paired POSITIVE through the real zustand selector. + expect(renderHook(() => useUnboundedCounterTypes(405)).result.current).toEqual(["charge"]); + // (11) paired NEGATIVE: 404 is on the same battlefield and carries no ∞ mark. + expect(renderHook(() => useUnboundedCounterTypes(404)).result.current).toEqual([]); + }); + + // 12 and 13 are separate `it`s so that a tag regression reds BOTH families independently — in + // one `it` the first `expect` throws and the second family is never measured. + it("carries the scheduled-collapse tag on the unit-variant family", () => { + // (12) THE DOCTRINE: the ∞ badge ships WHILE a finite collapse is merely scheduled, and the + // tag names which rows the CR 500.5 boundary will cash out. + expect(tokenWire.scheduled_collapse).toEqual([{ axis: "TokensCreated", player: 0 }]); + }); + + it("carries the scheduled-collapse tag on the data-variant family", () => { + // (13) …and the same tag on the counter family. + expect(counterWire.scheduled_collapse[0].axis).toEqual({ Counter: ["Other", "Other"] }); + }); +}); diff --git a/crates/engine/src/game/derived_views.rs b/crates/engine/src/game/derived_views.rs index 97e4c720b2..d4ea49b045 100644 --- a/crates/engine/src/game/derived_views.rs +++ b/crates/engine/src/game/derived_views.rs @@ -379,6 +379,25 @@ pub struct DerivedViews { /// omitted) when no counter-growth loop is active — the dominant case. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub unbounded_counters: HashMap>, + + /// CR 732.2c: the `(attributed player, axis)` pairs whose DEFERRED unbounded growth has an + /// accepted-but-not-yet-applied finite collapse waiting at the next CR 500.5 boundary. A TAG, + /// NOT a filter: `unbounded_resources` / `unbounded_pile` / `unbounded_counters` above stay + /// projected while scheduled, and this names which of them the boundary will cash out. Same + /// single authority (`GameState::scheduled_collapse_axes`) and the same `attribution_player` + /// keying as `unbounded_resources`, so a row joins its tag by exact `(player, axis)` equality. + /// + /// SCOPE LIMIT — read this before using it as "which ∞ rows stop being ∞". `Mana(_)` axes are + /// deliberately ABSENT: mana is already materialized and spendable (`refill_infinite_mana` + /// re-tops the pool off the store), so tagging it would mislabel a live pool. A mana ∞ *does* + /// end — at the CR 500.5 step/phase end, via `turns::drain_pending_phase_transition_progress`, + /// NOT via a materialization — so this field UNDER-REPORTS that case by design. It answers the + /// narrower question: which ∞ rows name growth that is DEFERRED and will be cashed out by a + /// registered materialization at the boundary. + /// + /// Empty (and omitted) whenever nothing is scheduled — the dominant case. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub scheduled_collapse: Vec, } /// Serialize-only wrapper: the WASM getter passes `&GameState` by reference @@ -793,40 +812,46 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews views.turn_order = turn_order; views.viewer_turn_number = viewer_turn_number; - // CR 732.2c: once every player accepted the shortcut it IS taken, at the finite N the - // proposal named — so an axis with a scheduled collapse is already BOUNDED and must not - // render `∞` anywhere beside the finite totals it is growing. ONE authority - // (`GameState::scheduled_collapse_axes`), THREE consumers below: the per-axis resource - // badge rows, the ∞ object pile, and the ∞ counter pills. The gate is computed here, - // once per controller, precisely so no surface can re-derive it and drift — a HUD that - // hides the resource badge while a card group still shows ∞ is internally inconsistent. + // CR 732.2c (docs/MagicCompRules.txt:6394) says that once the last player has accepted, the + // shortcut IS taken and the game advances to the ending point the proposal named. THIS ENGINE + // DEFERS that: the growth is not applied at accept, it is parked until the next CR 500.5 + // boundary (docs/MagicCompRules.txt:2122), where `turns.rs:540-591` asks the controller to + // CHOOSE N in `[0, recorded bound]`. `turns.rs:553-556` documents that deferral in its own + // words as an engine tolerance no CR licenses. // - // Filter the PROJECTION, never the store: `unbounded_resources` + - // `unbounded_loop_enablers` stay in CR 104.4b / CR 110.1 lockstep until the CR 500.5 - // boundary applies the growth, which is what keeps `zones::apply_zone_exit_cleanup`'s - // defuse armed in the meantime. + // So across accept -> boundary the game has NOT advanced, N is NOT determined, and no growth + // is on the board: what is there is still a certified-unbounded loop with no materialized + // bound. The projection therefore KEEPS the `∞` on every surface and TAGS it scheduled rather + // than hiding it. During that window the badge is load-bearing board-state information; hiding + // it erases the loop's identity from the display while the loop is still the truth of the + // board. // - // FAIL-CLOSED: only axes a registered materialization really collapses are hidden, so an - // unregistered ∞ axis (a mana engine registers none) still renders. + // ONE authority (`GameState::scheduled_collapse_axes`), now exactly ONE consumer here: the + // `scheduled_collapse` tag below. The per-axis resource badge rows, the ∞ object pile and the + // ∞ counter pills are UNFILTERED — they read their own stores and no longer consult this set + // at all. (`clear_collapsed_materializations` is the authority's other caller, unchanged.) // - // CLASS RULE for the hide-set: hide only axes whose growth is still DEFERRED; never hide an - // axis that is ALREADY MATERIALIZED and spendable right now. `Tokens` / `Counters` / `Life` - // are deferred by construction — the growth is not on the board until the boundary applies - // it — so an `∞` for them is exactly the lie this gate kills. A `DriveSequence` is the one - // item that does NOT name a deferral: its `collapsed_axes` is `proposal.unbounded`, i.e. - // EVERY axis of the whole loop, and a `Mana(_)` among them is live *now* — - // `mana_payment::refill_infinite_mana` tops that controller's pool back to + // The `Mana(_)` `retain` is preserved and REPURPOSED from hide-filter to tag-filter: it now + // means "tag only DEFERRED growth". `Tokens` / `Counters` / `Life` are deferred by + // construction — the growth is not on the board until the boundary applies it. A `Mana(_)` is + // not: `mana_payment::refill_infinite_mana` tops that controller's pool back to // `INFINITE_MANA_PER_TYPE` off the STORE (which this projection deliberately never touches) - // after every action. Hiding it would show no `∞` beside a pool that keeps refilling: the - // same internally-inconsistent HUD as an `∞ Life` badge on a finite life total, inverted. - // CR 500.5: `turns::drain_pending_phase_transition_progress` clears the mana axis when the - // step/phase ends, and THAT is what legitimately ends the badge — not this projection. + // after every action, so that pool is live and spendable right now and tagging it "scheduled + // collapse" would mislabel it. SCOPE LIMIT: a mana ∞ does end — at the CR 500.5 step/phase + // end, via `turns::drain_pending_phase_transition_progress`, NOT via a materialization — so + // this tag deliberately UNDER-REPORTS "which ∞ rows will stop being ∞". It answers the + // narrower question: which ∞ rows name growth that is DEFERRED and will be cashed out by a + // registered materialization at the boundary. Widen this `retain` only for an axis that gains + // the same already-materialized property. + // + // The store is still never filtered: `unbounded_resources` + `unbounded_loop_enablers` stay in + // CR 104.4b / CR 110.1 lockstep until the CR 500.5 boundary applies the growth, which is what + // keeps `zones::apply_zone_exit_cleanup`'s defuse armed in the meantime. // - // `Mana(_)` is today's only already-materialized axis: census of production readers of - // `GameState::unbounded_resources` (`refill_infinite_mana`, the CR 500.5 clear in `turns`, - // and this projection) shows it is the only axis any reader turns back into a spendable - // resource. Widen this `retain` only for an axis that gains the same property. - let scheduled_collapse: BTreeMap> = state + // `clear_collapsed_materializations` reads this SAME authority and is UNCHANGED: it still + // removes each collapsed axis at the boundary, and that is what legitimately ends both the ∞ + // rows and this tag. + let scheduled_axes: BTreeMap> = state .pending_unbounded_materialization .iter() .map(|(&controller, items)| { @@ -835,22 +860,28 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews (controller, axes) }) .collect(); - let collapse_scheduled = |controller: PlayerId, axis: &ResourceAxis| -> bool { - scheduled_collapse - .get(&controller) - .is_some_and(|axes| axes.contains(axis)) - }; + + // CR 732.2c: the tag itself — ONE straight projection of the authority above, keyed by the + // SAME `attribution_player` the ∞ rows use so a row and its tag join by exact equality. + for (&controller, axes) in &scheduled_axes { + for &axis in axes { + views.scheduled_collapse.push(UnboundedResourceView { + player: attribution_player(axis, controller), + axis, + }); + } + } // CR 732.2a: project every unbounded-resource loop into per-(player, axis) // `∞` HUD rows. Runs in every format (placed BEFORE the Commander // short-circuit below) and stays empty (field omitted) when no loop is // active — the dominant case. The engine owns attribution // (`attribution_player`); the frontend only formats each axis to a family. + // + // CR 732.2c: NOT filtered — see the doctrine block above; scheduled axes are TAGGED in + // `scheduled_collapse`. for (&controller, axes) in &state.unbounded_resources { for &axis in axes { - if collapse_scheduled(controller, &axis) { - continue; - } views.unbounded_resources.push(UnboundedResourceView { player: attribution_player(axis, controller), axis, @@ -863,14 +894,9 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews // left the battlefield (stale member). Public board state (no viewer filtering); // the frontend renders `∞` on any group whose members are all pile members. // - // CR 732.2c: same gate, same authority as the badge rows above. The pile IS the - // `TokensCreated` axis — `clear_collapsed_materializations` drops it on exactly that - // axis collapsing — so once that axis has a scheduled finite mint the group must stop - // rendering ∞ in lockstep with its resource badge. - for (&controller, ids) in &state.unbounded_loop_pile { - if collapse_scheduled(controller, &ResourceAxis::TokensCreated) { - continue; - } + // CR 732.2c: NOT filtered — see the doctrine block above; scheduled axes are TAGGED in + // `scheduled_collapse`. + for ids in state.unbounded_loop_pile.values() { for id in ids { if state.battlefield.contains(id) { views.unbounded_pile.push(*id); @@ -885,22 +911,13 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews // `unbounded_pile`; the frontend renders `∞` (not `×N`) on any counter pill whose // type is in this set. Runs in every format (BEFORE the Commander short-circuit). // - // CR 732.2c: same gate, same authority. A pill's axis is derived by the SHARED - // `(object, counter) -> ResourceAxis` mapping the collapse itself uses - // (`collapsed_counter_axis`), so a pill can never disagree with the badge it mirrors. - // The class lookup is LIVE by design here: this loop only emits pills for bearers still - // on the battlefield, so the object is present and its class is current. - for (&controller, targets) in &state.unbounded_counter_targets { + // CR 732.2c: NOT filtered — see the doctrine block above; scheduled axes are TAGGED in + // `scheduled_collapse`. + for targets in state.unbounded_counter_targets.values() { for (id, ct) in targets { if !state.battlefield.contains(id) { continue; } - if collapse_scheduled( - controller, - &crate::types::game_state::collapsed_counter_axis(state, *id, ct), - ) { - continue; - } views .unbounded_counters .entry(*id) diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index b39878c02d..3c937ca122 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -3526,17 +3526,19 @@ pub enum PersistentAxisMaterialization { /// CR 732.2a: the `unbounded_resources` axis a counter of `ct` on `obj_id` backs — mirrors /// `ResourceVector::snapshot`'s `(CounterClass, ObjectClass)` keying. SINGLE mapping from a -/// counter target to its axis, shared by `scheduled_collapse_axes`, -/// `clear_collapsed_materializations`' surviving-target guard, and the ∞ counter-pill -/// projection in `game::derived_views`. +/// counter target to its axis, with exactly three production call sites, all in this file: +/// `scheduled_collapse_axes`, and `clear_collapsed_materializations`' surviving-target guard +/// (twice). The ∞ counter-pill projection in `game::derived_views` is NOT one of them — it +/// projects the battlefield-surviving entries of `unbounded_counter_targets` directly and +/// never maps them to an axis. /// /// LIVE RE-DERIVATION — DELIBERATE DISPLAY-ONLY TOLERANCE. The class is read from the /// object as it stands NOW, not snapshotted at accept. `state.objects` retains an object /// across an ordinary zone change, so the `Other` fallback is reached only when the bearer /// truly stopped existing, or when its printed types changed under CR 400.7. In that window -/// the derived axis becomes `Counter(_, Other)`, which is not the axis `unbounded_resources` -/// holds ⇒ the pill/badge is NOT hidden and the `∞` renders for a collapse that is still -/// scheduled. +/// the derived axis becomes `Counter(_, Other)`, which joins no axis `unbounded_resources` +/// holds ⇒ the row keeps rendering `∞` but loses its `scheduled_collapse` tag, and the +/// boundary's removal finds nothing to take away. Nothing is ever hidden by the miss. /// /// CENSUS (`grep -rn 'objects.remove' crates/`, `#[cfg(test)]` bodies excluded) — FOUR /// production removes, not one. Cease-to-exist (`zones::…replay_resolved_object_cease`, @@ -3552,19 +3554,17 @@ pub enum PersistentAxisMaterialization { /// Two further removes operate on DISCARDED COMPARISON CLONES, never live state /// (`game::engine::normalize_recast_frame`, `analysis::resource`'s frame projection). /// -/// REACHABILITY BY CONSUMER (the fallback is not uniformly live): -/// • the ∞ counter-PILL projection in `game::derived_views` — UNREACHABLE. That loop -/// `continue`s on `!state.battlefield.contains(id)` before calling this, and every -/// production remove above deletes from the zone set before `objects` (or never touches -/// a battlefield permanent at all), so a battlefield id is present in `state.objects`. -/// • `scheduled_collapse_axes` (both its `derived_views` hide-set caller and its -/// `clear_collapsed_materializations` caller) and that function's surviving-target guard -/// — LIVE, and byte-identical to the pre-extraction nested `counter_axis` helper they -/// already used. The hide-set case is exactly the fail-open described above. +/// REACHABILITY BY CONSUMER — with the pill projection no longer mapping to an axis, both +/// remaining consumers are LIVE, and byte-identical to the pre-extraction nested +/// `counter_axis` helper they already used: +/// • `scheduled_collapse_axes` — read by `derive_views` to build the `scheduled_collapse` +/// TAG and by `clear_collapsed_materializations` to pick removals. Exactly the fail-open +/// described above: an untagged `∞` row, and a removal that finds nothing. +/// • `clear_collapsed_materializations`' own surviving-target guard. /// /// That fail-open polarity is the SAME one this phase mandates everywhere else (an axis -/// with no registration renders ∞): it can only ever show an extra ∞ for at most the -/// accept→CR-500.5-boundary window, never hide a real one. A snapshot would have to add a +/// with no registration renders ∞): it can only ever leave an ∞ standing — untagged, or one +/// boundary longer than it should — never hide a real one. A snapshot would have to add a /// serde field to `CounterGrowth` — a saved-game surface change across every construction /// site — to buy a strictly-display improvement, so it is not taken. Removing a non-present /// axis stays a harmless no-op, and `clear_collapsed_materializations`' surviving-target @@ -19821,24 +19821,31 @@ impl GameState { /// CR 732.2a: the exact `ResourceAxis` set a deferred materialization stash will /// collapse at the next CR 500.5 boundary. SINGLE AUTHORITY, with exactly two callers: /// - `clear_collapsed_materializations` REMOVES these axes once the growth was applied; - /// - `game::derived_views::derive_views` HIDES their `∞` HUD rows while the collapse is - /// merely SCHEDULED. CR 732.2c fixes the finite N at accept, so the axis is already - /// bounded — rendering `∞ Life` beside a finite, growing life total is a lie. + /// - `game::derived_views::derive_views` TAGS them into `DerivedViews::scheduled_collapse` + /// while the collapse is merely SCHEDULED. A TAG, NOT A FILTER: the `∞` HUD rows, the ∞ + /// object pile and the ∞ counter pills all stay projected. CR 732.2c says the shortcut is + /// taken at accept, but this engine DEFERS the growth to the next CR 500.5 boundary, so + /// across accept→boundary nothing has been applied and N is not chosen yet — what is on + /// the board is still a certified-unbounded loop, the `∞` is load-bearing board state, + /// and this set only names which of those badges the boundary will cash out. /// /// Returns the axes UNFILTERED, including any `Mana(_)` a `DriveSequence` names, because the /// `clear_collapsed_materializations` caller MUST remove that axis at the boundary. The - /// `derive_views` caller drops `Mana(_)` from its hide-set on the way out: mana is already - /// materialized in the pool (`mana_payment::refill_infinite_mana` re-tops it off this very - /// store until CR 500.5 empties it), so it is the one axis that must keep rendering `∞` - /// while a collapse is merely scheduled. See the class rule at that call site. + /// `derive_views` caller drops `Mana(_)` on the way out so its tag means "DEFERRED growth + /// only": mana is already materialized in the pool (`mana_payment::refill_infinite_mana` + /// re-tops it off this very store until CR 500.5 empties it), so tagging that live, + /// spendable pool "scheduled collapse" would mislabel it. The tag therefore deliberately + /// UNDER-REPORTS mana, whose `∞` does end — at the CR 500.5 step/phase end rather than by a + /// materialization. See the class rule at that call site. /// - /// Hiding in the PROJECTION rather than removing from the store is load-bearing: + /// Tagging in the PROJECTION rather than removing from the store is load-bearing: /// the store keeps `unbounded_resources` and `unbounded_loop_enablers` in CR 104.4b / /// CR 110.1 lockstep, which is what `zones::apply_zone_exit_cleanup` reads to defuse a /// capability whose enabler leaves between accept and boundary. /// /// FAIL-CLOSED: only an axis some REGISTERED item actually collapses is returned, so an - /// ∞ axis with no registration (a mana engine registers nothing) keeps its badge. + /// ∞ axis with no registration (a mana engine registers nothing) is neither tagged here nor + /// removed at the boundary — it keeps its badge. /// EXHAUSTIVE over `PersistentAxisMaterialization` (no wildcard) — a future variant /// build-breaks here instead of silently leaking a stale `∞`. pub fn scheduled_collapse_axes( @@ -19910,7 +19917,7 @@ impl GameState { ) { // --- Phase 1 (reads): what to remove --- // The axis set comes from the SHARED authority the ∞-row projection also reads, so - // "hidden while scheduled" and "removed once applied" can never disagree. + // "tagged while scheduled" and "removed once applied" can never disagree. let mut axes_to_remove = self.scheduled_collapse_axes(collapsed); // The token pile drops exactly when the token axis collapses — true for a batched // `Tokens` item and for a `DriveSequence` that names `TokensCreated`. diff --git a/crates/engine/tests/integration/combo_infinite_pile.rs b/crates/engine/tests/integration/combo_infinite_pile.rs index ee2cca96f8..fad7d8fd53 100644 --- a/crates/engine/tests/integration/combo_infinite_pile.rs +++ b/crates/engine/tests/integration/combo_infinite_pile.rs @@ -150,6 +150,8 @@ fn drive_all_accept_n(state: &mut GameState, n: u32) { #[test] fn real_4p_object_growth_accept_writes_infinite_pile() { + use engine::analysis::resource::ResourceAxis; + let mut state: GameState = serde_json::from_str(&OFFER_STATE) .expect("the real 4p offer dump must deserialize into the current GameState"); @@ -220,38 +222,85 @@ fn real_4p_object_growth_accept_writes_infinite_pile() { // (2) DERIVED — derive_views projects the pile (battlefield-filtered, public board state). // - // R6a (CR 732.2c): this accept ALSO scheduled a finite `TokensCreated` collapse, and a - // scheduled axis is already bounded — so the ∞ group must be hidden on the WIRE in - // lockstep with its resource badge. Filter the PROJECTION, never the store: the store - // assertions above and the round-trip below are unchanged and still pass. - assert!( - derive_views(&state, Some(P0)).unbounded_pile.is_empty(), - "CR 732.2c: a scheduled finite collapse hides the ∞ group (the store keeps it)" - ); - // Non-vacuity + the ORIGINAL claim, retained: with nothing scheduled the projection is - // still exactly the battlefield-filtered pile set. + // CR 732.2c (option B): this accept ALSO scheduled a finite `TokensCreated` collapse, but the + // growth is DEFERRED to the CR 500.5 boundary — no token has been minted and N is not chosen — + // so the ∞ pile stays PROJECTED while the collapse is merely scheduled, and is TAGGED in + // `scheduled_collapse` instead of being hidden. The scheduling does not change the pile set, + // which is why the same `oracle` comparison now runs directly on the real post-accept state. // - // NOT A SYNTHETIC STATE — "pile present, stash absent" is engine-reachable, and this is - // the production sequence that reaches it (cited so the next auditor need not re-derive - // it): accept an object-growth loop → the CR 500.5 boundary prompt → `SubmitPayAmount` → - // the handler's `take_pending_materialization` (`game::engine_resolution_choices`) empties - // the stash FIRST → the `Tokens` mint then PAUSES on an optional token-doubling - // replacement (CR 616.1) → the pause path calls `clear_collapsed_materializations(player, - // &collapsed)` with `collapsed` NOT containing the still-paused `Tokens` item ⇒ pile and - // ∞ axes preserved, stash already gone. NOT A CLAIM — ASSERTED, in - // `combo_infinite_pile::med_tokens_boundary_mint_pause_preserves_replacement_choice`, whose - // closing two assertions drive that exact sequence and check - // `pending_unbounded_materialization[P0]` ABSENT while `derive_views(..).unbounded_pile` is - // non-empty. That test is this arm's production-reachability evidence. - let mut unscheduled = state.clone(); - unscheduled.pending_unbounded_materialization.clear(); - let derived = derive_views(&unscheduled, Some(P0)); + // REVERT-PROBE (RP-1): restore the `collapse_scheduled(controller, &TokensCreated) { continue; }` + // guard in `derive_views`' pile loop ⇒ THIS `assert_eq!` fails with an empty `left` while the + // store assertions (1)/(i)/(ii)/(iii) above stay green. + let derived = derive_views(&state, Some(P0)); let derived_set: BTreeSet = derived.unbounded_pile.iter().copied().collect(); + + // CR 732.2c cross-seam pin, PART 1 — compute + (optionally) REGENERATE. Provenance: every + // key/value below is ENGINE-EMITTED (`serde_json::to_value(&derive_views(..))`). The four ∞ keys + // are lifted BY NAME from the real serialized DerivedViews so unrelated derived-view churn + // cannot move this golden, while the field names and value encodings — the part the TS mirror + // must match — stay engine-authored. + // + // The WRITE deliberately precedes every ∞ assertion in this fn, and the drift COMPARE + // deliberately follows them: a revert probe that reds one of those assertions must still be + // able to regenerate the client goldens with `UPDATE_WIRE_GOLDEN=1`, or the client-side half of + // that probe (RP-1b, RP-2) is unreachable. An assert panic aborts the test. + // + // DETERMINISM: `unbounded_counters` is a std `HashMap` (derived_views.rs), but + // `serde_json::Map` is BTreeMap-backed (serde_json has no `preserve_order` feature in this + // workspace — see Cargo.lock), so `to_value` re-sorts every map key. Measured byte-identical + // across independent test processes. No normalization needed. + let wire = serde_json::to_value(&derived).expect("derived views serialize"); + let golden: serde_json::Map = [ + "unbounded_pile", + "unbounded_resources", + "unbounded_counters", + "scheduled_collapse", + ] + .into_iter() + .filter_map(|k| wire.get(k).map(|v| (k.to_string(), v.clone()))) + .collect(); + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../client/src/test/fixtures/unbounded-token-wire.json" + ); + if std::env::var_os("UPDATE_WIRE_GOLDEN").is_some() { + // `client/src/test/fixtures/` may not exist yet; `fs::write` does not create parents. + std::fs::create_dir_all( + std::path::Path::new(path) + .parent() + .expect("golden has a parent"), + ) + .expect("create the client wire-golden directory"); + std::fs::write( + path, + format!("{}\n", serde_json::to_string_pretty(&golden).unwrap()), + ) + .expect("write the wire golden"); + } + assert_eq!( derived_set, oracle, "derive_views().unbounded_pile must equal the pile set (battlefield-filtered)" ); + // CR 732.2c cross-seam pin, PART 2 — the drift COMPARE (see PART 1 for why it sits here). + let committed: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path).expect("committed wire golden")) + .unwrap(); + assert_eq!( + serde_json::Value::Object(golden), + committed, + "the client's wire golden drifted from engine output — re-run with UPDATE_WIRE_GOLDEN=1" + ); + + assert!( + derived + .scheduled_collapse + .iter() + .any(|r| r.player == P0 && r.axis == ResourceAxis::TokensCreated), + "CR 732.2c: the pile is projected AND its TokensCreated axis is tagged scheduled" + ); + // (3) ROUND-TRIP — the pile survives serialize → deserialize (the "reloaded post-accept // shows no pile" fix for POST-FIX saves) AND derive_views re-exposes it. let json = serde_json::to_string(&state).expect("serialize the post-accept state"); @@ -261,20 +310,11 @@ fn real_4p_object_growth_accept_writes_infinite_pile() { Some(&oracle), "the ∞ pile survives a serde round-trip (post-fix saves reload it)" ); - // R6a (CR 732.2c): the stash round-trips too, so the reloaded state's collapse is still - // SCHEDULED and its ∞ group stays hidden on the WIRE — same gate, same authority. - assert!( - derive_views(&reloaded, Some(P0)).unbounded_pile.is_empty(), - "CR 732.2c: the reloaded scheduled collapse still hides the ∞ group" - ); - // Same engine-reachable "pile present, stash absent" shape as above — see the - // `SubmitPayAmount` → `take_pending_materialization` → CR 616.1 mint-pause → - // `clear_collapsed_materializations` sequence cited at (2). - let mut reloaded_unscheduled = reloaded.clone(); - reloaded_unscheduled - .pending_unbounded_materialization - .clear(); - let reloaded_set: BTreeSet = derive_views(&reloaded_unscheduled, Some(P0)) + // CR 732.2c (option B): the stash round-trips too, so the reloaded state's collapse is still + // SCHEDULED — and the ∞ pile stays PROJECTED while it is. The scheduling does not change the + // pile set, which is why the same `oracle` comparison now runs directly on the real reloaded + // post-accept state instead of on a stash-cleared clone. + let reloaded_set: BTreeSet = derive_views(&reloaded, Some(P0)) .unbounded_pile .iter() .copied() @@ -625,22 +665,12 @@ fn build_fresh_4p_cast_offer_accept_writes_infinite_pile() { // (2) DERIVED — derive_views projects the pile. // - // R6a (CR 732.2c): the accept also scheduled a finite `TokensCreated` collapse ⇒ the ∞ - // group is hidden on the WIRE while it is scheduled. Store unchanged (see the round-trip). - assert!( - derive_views(runner.state(), Some(P0)) - .unbounded_pile - .is_empty(), - "CR 732.2c: a scheduled finite collapse hides the ∞ group (the store keeps it)" - ); - // Non-vacuity + the ORIGINAL claim, retained. "Pile present, stash absent" is - // engine-reachable, not synthetic — the `SubmitPayAmount` → `take_pending_materialization` - // → CR 616.1 mint-pause → `clear_collapsed_materializations` sequence cited in - // `real_4p_object_growth_accept_writes_infinite_pile`, ASSERTED by - // `med_tokens_boundary_mint_pause_preserves_replacement_choice`'s closing two assertions. - let mut unscheduled = runner.state().clone(); - unscheduled.pending_unbounded_materialization.clear(); - let derived_set: BTreeSet = derive_views(&unscheduled, Some(P0)) + // CR 732.2c (option B): the accept also scheduled a finite `TokensCreated` collapse, but that + // growth is DEFERRED to the CR 500.5 boundary, so the ∞ pile stays PROJECTED while it is + // merely scheduled (and is TAGGED in `scheduled_collapse`). The scheduling does not change the + // pile set, which is why the same `oracle` comparison now runs directly on the real + // post-accept state. + let derived_set: BTreeSet = derive_views(runner.state(), Some(P0)) .unbounded_pile .iter() .copied() @@ -1448,22 +1478,12 @@ fn real_4p_one_shot_bootstrap_seeds_tapped_infinite_pile_and_w_plus_1_untapped() // derive_views projects the pile; it survives a serde round-trip. // - // R6a (CR 732.2c): the accept also scheduled a finite `TokensCreated` collapse ⇒ the ∞ - // group is hidden on the WIRE while it is scheduled. Store unchanged (round-trip below). - assert!( - derive_views(runner.state(), Some(P0)) - .unbounded_pile - .is_empty(), - "CR 732.2c: a scheduled finite collapse hides the ∞ group (the store keeps it)" - ); - // Non-vacuity + the ORIGINAL claim, retained. "Pile present, stash absent" is - // engine-reachable, not synthetic — the `SubmitPayAmount` → `take_pending_materialization` - // → CR 616.1 mint-pause → `clear_collapsed_materializations` sequence cited in - // `real_4p_object_growth_accept_writes_infinite_pile`, ASSERTED by - // `med_tokens_boundary_mint_pause_preserves_replacement_choice`'s closing two assertions. - let mut unscheduled = runner.state().clone(); - unscheduled.pending_unbounded_materialization.clear(); - let derived_set: BTreeSet = derive_views(&unscheduled, Some(P0)) + // CR 732.2c (option B): the accept also scheduled a finite `TokensCreated` collapse, but that + // growth is DEFERRED to the CR 500.5 boundary, so the ∞ pile stays PROJECTED while it is + // merely scheduled (and is TAGGED in `scheduled_collapse`). The scheduling does not change the + // pile set, which is why the same `oracle` comparison now runs directly on the real + // post-accept state. + let derived_set: BTreeSet = derive_views(runner.state(), Some(P0)) .unbounded_pile .iter() .copied() diff --git a/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs b/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs index 5a812e05e2..4d58ee000a 100644 --- a/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs +++ b/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs @@ -19,6 +19,7 @@ //! The `kilo_reinjected_pinless_history_suppresses_offer` test is the matched-pair proof that the //! migration is load-bearing (re-injecting the stale prefix flips the offer OFF). +use engine::analysis::resource::ResourceAxis; use engine::game::engine::apply; use engine::types::ability::TargetRef; use engine::types::actions::GameAction; @@ -434,44 +435,83 @@ fn kilo_accept_marks_pentad_charge_as_unbounded_display_target() { "display-only: Pentad's REAL charge count is unchanged by the ∞ mark (CR 701.34a)" ); - // (3a) R6a (CR 732.2c) — THE PER-SURFACE COUNTER-PILL GATE, on a REAL production fixture. - // This accept registers an observed-growth `DriveSequence` naming the charge-counter axis, - // so the collapse is already bounded at the accepted N and the pill must stop rendering ∞ - // in lockstep with its resource badge — a HUD that hides one and shows the other is - // internally inconsistent. Filter the PROJECTION, never the store: (2) above (the store - // write) is unchanged and still passes. + // (3) CR 732.2c — THE PER-SURFACE COUNTER-PILL ROW, on a REAL production fixture. The accept + // registers an observed-growth `DriveSequence` naming the charge-counter axis, but the growth + // is DEFERRED to the CR 500.5 boundary, so the pill stays ∞ and carries a scheduled TAG. + // Filter nothing: (2) above (the store write) is unchanged and still passes. // - // REVERT-PROBE: delete the `collapse_scheduled(..)` guard in `derive_views`' counter-pill - // loop ⇒ the pill re-renders ⇒ THIS assertion FAILS while the pile and badge gates stay - // green (so a one-surface regression is visible). - assert!( - derive_views(&state, None).unbounded_counters.is_empty(), - "CR 732.2c: a scheduled finite collapse hides the ∞ charge pill (the store keeps it)" + // REVERT-PROBE (RP-1c, RUN): restore the `collapse_scheduled(..)` guard in `derive_views`' + // counter-pill loop ⇒ THIS `assert_eq!` fails (`left: None`) while (2) above and the pile + // and row channels stay green. + let views = derive_views(&state, None); + + // CR 732.2c cross-seam pin, PART 1 — compute + (optionally) REGENERATE. Provenance: every + // key/value below is ENGINE-EMITTED (`serde_json::to_value(&derive_views(..))`). The four ∞ keys + // are lifted BY NAME from the real serialized DerivedViews so unrelated derived-view churn + // cannot move this golden, while the field names and value encodings — the part the TS mirror + // must match — stay engine-authored. + // + // The WRITE deliberately precedes every ∞ assertion in this fn, and the drift COMPARE + // deliberately follows them: a revert probe that reds one of those assertions must still be + // able to regenerate the client goldens with `UPDATE_WIRE_GOLDEN=1`, or the client-side half of + // that probe (RP-1b, RP-2) is unreachable. An assert panic aborts the test. + // + // DETERMINISM: `unbounded_counters` is a std `HashMap` (derived_views.rs), but + // `serde_json::Map` is BTreeMap-backed (serde_json has no `preserve_order` feature in this + // workspace — see Cargo.lock), so `to_value` re-sorts every map key. Measured byte-identical + // across independent test processes. No normalization needed. + let wire = serde_json::to_value(&views).expect("derived views serialize"); + let golden: serde_json::Map = [ + "unbounded_pile", + "unbounded_resources", + "unbounded_counters", + "scheduled_collapse", + ] + .into_iter() + .filter_map(|k| wire.get(k).map(|v| (k.to_string(), v.clone()))) + .collect(); + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../client/src/test/fixtures/unbounded-counter-wire.json" ); + if std::env::var_os("UPDATE_WIRE_GOLDEN").is_some() { + // `client/src/test/fixtures/` may not exist yet; `fs::write` does not create parents. + std::fs::create_dir_all( + std::path::Path::new(path) + .parent() + .expect("golden has a parent"), + ) + .expect("create the client wire-golden directory"); + std::fs::write( + path, + format!("{}\n", serde_json::to_string_pretty(&golden).unwrap()), + ) + .expect("write the wire golden"); + } - // (3b) DERIVED VIEW (FLIPS on revert): with nothing scheduled the projection surfaces - // Pentad's charge as ∞ for the FE, filtered to battlefield objects. - // - // NOT A SYNTHETIC STATE — "counter targets present, stash absent" is engine-reachable, - // and this is the production sequence (cited so the next auditor need not re-derive it): - // a CR 732.2b DECLINE of the batched counter axis. A counter OBSERVER drifts onto the - // board inside the accept→boundary window, so at `SubmitPayAmount` the handler's - // `take_pending_materialization` empties the stash FIRST, then the `Counters` arm hits - // `if counter_observed_now { continue; }` (`game::engine_resolution_choices`) and never - // pushes that item into `collapsed`. `clear_collapsed_materializations`' `surviving_ - // targets` filter (`types::game_state`) therefore filters nothing ⇒ `unbounded_counter_ - // targets` is preserved with the stash already gone. - // The shared "display channel survives a stash the submit already emptied" half is ASSERTED - // (not merely argued) by the sibling `combo_infinite_pile::med_tokens_boundary_mint_pause_ - // preserves_replacement_choice`, whose closing two assertions measure exactly that shape on - // the pile channel after a real `SubmitPayAmount`. - let mut unscheduled = state.clone(); - unscheduled.pending_unbounded_materialization.clear(); - let views = derive_views(&unscheduled, None); assert_eq!( views.unbounded_counters.get(&PENTAD), Some(&vec![charge.clone()]), - "derive_views projects (Pentad → [charge]) so the FE renders ∞ on the charge pill" + "CR 732.2c: the ∞ charge pill stays projected while the collapse is merely SCHEDULED" + ); + + // CR 732.2c cross-seam pin, PART 2 — the drift COMPARE (see PART 1 for why it sits here). + let committed: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path).expect("committed wire golden")) + .unwrap(); + assert_eq!( + serde_json::Value::Object(golden), + committed, + "the client's wire golden drifted from engine output — re-run with UPDATE_WIRE_GOLDEN=1" + ); + + assert!( + views + .scheduled_collapse + .iter() + .any(|r| r.player == P0 && matches!(r.axis, ResourceAxis::Counter(..))), + "…and the counter axis is TAGGED scheduled, got {:?}", + views.scheduled_collapse ); // (4) WIRE ROUND-TRIP (FLIPS on revert): the populated channel serializes, is present on diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index 1931072877..e318c4f032 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -7141,30 +7141,40 @@ fn r6a_drive_to_boundary(state: &mut GameState) { panic!("r6a_drive_to_boundary: no phase boundary within 64 passes"); } -/// R6a-1 (PRIMARY). MEASURED DEFECT: accepting the Witherbloom/Sprout loop writes -/// `unbounded_resources = {P0: [Life(0), TokensCreated]}` and that mark survives to the -/// CR 500.5 boundary, so the HUD renders an "∞ Life" badge beside P0's *finite*, growing -/// life total. CR 732.2c: once the last player accepted, the shortcut IS taken at the -/// named `Fixed(N)` — the growth is bounded, so no `∞` row may render for a scheduled axis. -/// -/// FILTER THE PROJECTION, NEVER THE STORE. The store must still carry the mark (it is what -/// CR 104.4b / CR 110.1 lockstep and `zones::apply_zone_exit_cleanup`'s defuse read until -/// the boundary applies the growth), so this row asserts BOTH halves. -/// -/// §15 NON-VACUITY: the emptiness assertion is paired with the store's NON-emptiness and a -/// non-empty `unbounded_loop_pile` — the same `state` the projection reads is measurably -/// populated, so the instrument demonstrably CAN report a row. Emptiness is asserted on the -/// WIRE (`derive_views`), never on `state`. -/// -/// REVERT-PROBES (both RUN, both observed to fail): -/// ⓐ delete the `if scheduled.contains(&axis) { continue; }` filter in `derive_views` -/// ⇒ the `Life(0)` and `TokensCreated` rows render ⇒ assertion (3) FAILS. -/// ⓑ make `GameState::scheduled_collapse_axes` return an empty set ⇒ (3) FAILS the same -/// way AND `clear_collapsed_materializations` stops removing at the boundary — which is -/// what proves the projection and the collapse share ONE authority rather than two -/// copies of the same match. +/// R6a-1 (PRIMARY), INVERTED to option (B). Accepting the Witherbloom/Sprout loop writes +/// `unbounded_resources = {P0: [Life(0), TokensCreated]}` plus a non-empty ∞ pile, and +/// registers a finite collapse. CR 732.2c says the shortcut IS taken at the accepted N — but +/// THIS ENGINE DEFERS the growth to the CR 500.5 boundary (`turns.rs:540-591`, self-documented +/// as an engine tolerance at `turns.rs:553-556`), where the controller still has to CHOOSE N. +/// Across accept → boundary nothing has been applied and N is not determined: the board still +/// holds a certified-unbounded loop, so the projection KEEPS every `∞` surface and TAGS the +/// scheduled axes in `DerivedViews::scheduled_collapse` instead of hiding them. +/// +/// TAG, NEVER HIDE — and never filter the store either. The store must still carry the mark +/// (CR 104.4b / CR 110.1 lockstep and `zones::apply_zone_exit_cleanup`'s defuse read it until +/// the boundary applies the growth), so this row asserts store AND wire. +/// +/// NON-VACUITY: every wire assertion here is a NON-emptiness paired with the store's own +/// non-emptiness at (1), so a projection that returned nothing fails immediately; and the +/// post-drop emptiness discriminator lives out-of-loop in +/// `unregistered_axis_still_renders_its_infinity_badge`, on the same rig. +/// +/// ASSERTION ORDER inside the viewer loop is PILE FIRST, then rows: RP-1 (pile guard) and +/// RP-1d (row guard) each panic on their own line, so whichever comes first hides the other. +/// Pile-first buys RP-1d an in-test rows→pile control; the rows control RP-1 loses here is +/// supplied out-of-loop by `unregistered_axis_still_renders_its_infinity_badge`, whose +/// pre-clear rows assertion is green under RP-1 and red under RP-1d. +/// +/// REVERT-PROBES (RUN): +/// ⓐ RP-1 — restore `if collapse_scheduled(controller, &TokensCreated) { continue; }` in +/// `derive_views`' pile loop ⇒ the PILE assertion below FAILS; the rows assertion is +/// unreached here and is controlled out-of-loop (above). +/// ⓑ RP-1d — restore `if collapse_scheduled(controller, &axis) { continue; }` in the resource +/// row loop ⇒ the ROWS assertion below FAILS while the PILE assertion above it passes. +/// ⓒ RP-2 — delete the `scheduled_collapse` tag loop in `derive_views` ⇒ the tag assertion +/// below FAILS (and both wire goldens drift). #[test] -fn scheduled_collapse_renders_no_unbounded_badge() { +fn scheduled_collapse_still_renders_the_unbounded_badge_tagged() { let mut state = r6a_offer_state(); // (0) reach-guard: the real cast reached the CR 732.2a offer. @@ -7220,25 +7230,30 @@ fn scheduled_collapse_renders_no_unbounded_badge() { "every marked axis on this board is scheduled; marked={marked:?} scheduled={scheduled:?}" ); - // (3) DISCRIMINATOR — on the WIRE, for EVERY viewer (and the spectator view), no ∞ row. - // ALL THREE ∞ surfaces share the one authority, so the HUD can never hide a resource badge - // while a card group still renders ∞. The PER-SURFACE positive rows live on their own real - // fixtures — `combo_infinite_pile::real_4p_object_growth_accept_writes_infinite_pile` (pile) - // and `kilo_live_offer_from_real_dump::kilo_accept_marks_pentad_charge_as_unbounded_display_ + // (3) DISCRIMINATOR — on the WIRE, for EVERY viewer (and the spectator view), the ∞ pile and + // both ∞ rows still project, and both scheduled axes carry the tag. ALL THREE ∞ surfaces read + // the one authority, so the HUD can never show a card group's ∞ while hiding its resource + // badge. The PER-SURFACE positive rows live on their own real fixtures — + // `combo_infinite_pile::real_4p_object_growth_accept_writes_infinite_pile` (pile) and + // `kilo_live_offer_from_real_dump::kilo_accept_marks_pentad_charge_as_unbounded_display_ // target` (counter pills) — so a regression on ONE surface stays visible even though this - // row flips on all of them at once. + // row covers pile + rows at once. for viewer in [None, Some(P0), Some(P1), Some(P2), Some(PlayerId(3))] { let views = engine::game::derived_views::derive_views(&state, viewer); assert!( - views.unbounded_resources.is_empty(), - "CR 732.2c: a scheduled finite collapse must render NO ∞ row (viewer {viewer:?}), \ - got {:?}", - views.unbounded_resources + !views.unbounded_pile.is_empty(), + "CR 732.2c: the scheduled collapse still projects the ∞ pile (viewer {viewer:?})" ); + let axes: Vec = views.unbounded_resources.iter().map(|r| r.axis).collect(); assert!( - views.unbounded_pile.is_empty(), - "CR 732.2c: ...and no ∞ card group beside it (viewer {viewer:?}), got {:?}", - views.unbounded_pile + axes.contains(&ResourceAxis::Life(P0)) && axes.contains(&ResourceAxis::TokensCreated), + "CR 732.2c: ...and both ∞ rows beside it (viewer {viewer:?}), got {axes:?}" + ); + let tagged: Vec = views.scheduled_collapse.iter().map(|r| r.axis).collect(); + assert!( + tagged.contains(&ResourceAxis::Life(P0)) + && tagged.contains(&ResourceAxis::TokensCreated), + "CR 732.2c: ...and both axes are TAGGED scheduled (viewer {viewer:?}), got {tagged:?}" ); } @@ -7246,23 +7261,22 @@ fn scheduled_collapse_renders_no_unbounded_badge() { // WASM `wrap_filtered` getter go through `derive_filtered_views`, which CALLS // `derive_views(filtered_state, viewer)` and then overrides only // `unique_authorized_submitter` and `blocker_assignment_pairs`. It WRAPS; it does not - // bypass. So gating in `derive_views` alone could not have leaked ∞ to the broadcast - // path — there is no other producer of these three fields, and this row costs zero - // production code. + // bypass. So `derive_views` alone decides what the broadcast path shows — there is no other + // producer of these three fields, and this row costs zero production code. // // What it DOES guard is the INPUT: `filter_state_for_viewer` is a clone-and-redact with - // ZERO `unbounded` references today, so it passes `pending_unbounded_materialization` - // through unredacted and the gate sees the same stash the hot-seat viewer does. If a - // future redaction ever drops that stash from the filtered clone, the gate goes silently - // INERT on the broadcast path only — filtered viewers get the ∞ rows back while the local - // viewer does not. That is the regression this row catches. + // ZERO `unbounded` references today, so a filtered viewer sees the same ∞ surfaces the + // hot-seat viewer does. If a future redaction ever dropped the ∞ stores or the stash from the + // filtered clone, the broadcast path alone would go dark — remote players would lose the ∞ + // pile and rows (and their scheduled tag) while the local viewer kept them. That asymmetry is + // the regression this row catches. for viewer in [P0, P1, P2, PlayerId(3)] { let filtered = engine::game::visibility::filter_state_for_viewer(&state, viewer); let views = engine::game::derived_views::derive_filtered_views(&state, &filtered, Some(viewer)); assert!( - views.unbounded_resources.is_empty() && views.unbounded_pile.is_empty(), - "CR 732.2c: the viewer-FILTERED broadcast path hides the same rows (viewer \ + !views.unbounded_resources.is_empty() && !views.unbounded_pile.is_empty(), + "CR 732.2c: the viewer-FILTERED broadcast path projects the same rows (viewer \ {viewer:?}), got {:?} / {:?}", views.unbounded_resources, views.unbounded_pile @@ -7282,19 +7296,38 @@ fn scheduled_collapse_renders_no_unbounded_badge() { ); } -/// R6a-3 (FAIL-CLOSED). An ∞ axis the accept marked but NO registered materialization -/// collapses must keep rendering its badge — the filter is keyed on what is actually -/// scheduled, never on what is merely *labellable*. -/// -/// This is the row that kills the lazy-but-unsound filter: `LoopCollapseAxis` -/// `from_resource_axis` maps `TokensCreated` / `Counter(..)` / `Life(..)` to a label, so -/// building the hide-set from "does this axis have a collapse label" is a one-liner that -/// passes R6a-1 and silently hides an axis nothing will ever collapse. -/// -/// REVERT-PROBE (RUN): build the projection filter from -/// `LoopCollapseAxis::from_resource_axis(axis).is_some()` instead of from -/// `scheduled_collapse_axes` ⇒ this row's `TokensCreated` badge vanishes ⇒ FAILS, while -/// R6a-1 still passes. +/// R6a-3 (FAIL-CLOSED), under option (B). Two jobs, on ONE rig: +/// +/// 1. POSITIVE CONTROL for the whole change — while the stash is present, a scheduled collapse +/// still PROJECTS its ∞ rows and the tag names exactly the axes the CR 500.5 boundary will +/// cash out. This is the load-bearing rows control: it is green under the pile-guard probe +/// (RP-1) and red under the row-guard probe (RP-1d), i.e. it discriminates in BOTH +/// directions, which is what lets the sibling test above assert pile-first. +/// 2. DISCRIMINATOR — after dropping the registrations, an ∞ axis that is merely *labellable* +/// must carry NO scheduled tag. `LoopCollapseAxis::from_resource_axis` maps `TokensCreated` / +/// `Counter(..)` / `Life(..)` to a label, so keying the tag on "does this axis have a collapse +/// label" is a one-liner that passes every other row in this file and mislabels an axis +/// nothing will ever collapse. +/// +/// REVERT-PROBE (RP-4, RUN): build the tag by iterating **`state.unbounded_resources`** (which +/// survives the `.clear()`) and keeping axes matching +/// `matches!(axis, ResourceAxis::TokensCreated | ResourceAxis::Counter(..) | ResourceAxis::Life(_))` +/// — a *transcription* of `LoopCollapseAxis::from_resource_axis`'s three `Some` arms +/// (`types/game_state.rs:11133/11136/11137`), inlined because that fn is a bare module-private +/// `fn` (`:11131`) and `game::derived_views` is a sibling module, so calling it is +/// `error[E0624]: associated function from_resource_axis is private`. Do not widen its +/// visibility. ⇒ the post-clear tag becomes 2-element ⇒ the emptiness assertion below FAILS while +/// the pre-clear positive control stays green. A probe that iterates the already-CLEARED stash +/// would be vacuous — this wording is load-bearing. +/// +/// REVERT-PROBE (RP-5, RUN): key the tag on `state.pending_materialization_count` +/// (`types/game_state.rs:13793`) instead of on the stash. That map is NOT cleared by clearing +/// `pending_unbounded_materialization`, and this is a stash-bearing `Fixed(200)` accept, so the +/// post-clear tag stays populated ⇒ the same emptiness assertion FAILS. +/// +/// The two closing `contains` assertions are a REACH-ECHO, not a discriminator: they pin that the +/// ∞ store survives the clear (which is what makes the tag-emptiness above meaningful). They no +/// longer discriminate any candidate implementation on their own. #[test] fn unregistered_axis_still_renders_its_infinity_badge() { let mut state = r6a_offer_state(); @@ -7316,15 +7349,34 @@ fn unregistered_axis_still_renders_its_infinity_badge() { marked.contains(&ResourceAxis::TokensCreated) && marked.contains(&ResourceAxis::Life(P0)), "reach-guard: both labellable axes are marked, got {marked:?}" ); - // Positive control on the SAME state, BEFORE the drop: with the stash present the rows - // are hidden, so the flip below is attributable to the missing registration alone. + // POSITIVE CONTROL on the SAME state, BEFORE the drop. CR 732.2c: while the collapse is + // SCHEDULED the ∞ rows stay projected (the user-facing doctrine) and the tag names exactly the + // axes the CR 500.5 boundary will cash out. This pair is what makes the post-drop TAG-EMPTINESS + // below attributable to the missing registration alone. + let scheduled_views = engine::game::derived_views::derive_views(&state, None); + let tagged: Vec = scheduled_views + .scheduled_collapse + .iter() + .map(|r| r.axis) + .collect(); + assert!( + !scheduled_views.unbounded_resources.is_empty(), + "CR 732.2c: a scheduled collapse still PROJECTS its ∞ rows (option B), got {:?}", + scheduled_views.unbounded_resources + ); + assert!( + tagged.contains(&ResourceAxis::TokensCreated) && tagged.contains(&ResourceAxis::Life(P0)), + "both registered axes are TAGGED scheduled while the stash is present, got {tagged:?}" + ); + state.pending_unbounded_materialization.clear(); + assert!( engine::game::derived_views::derive_views(&state, None) - .unbounded_resources + .scheduled_collapse .is_empty(), - "control: with the stash present the scheduled rows are hidden" + "FAIL-CLOSED: the tag is keyed on the REGISTRATION, not on labellability or on the accept — \ + with the stash dropped, two still-labellable ∞ axes must carry NO scheduled tag" ); - state.pending_unbounded_materialization.clear(); let rows = engine::game::derived_views::derive_views(&state, None).unbounded_resources; let axes: Vec = rows.iter().map(|r| r.axis).collect(); diff --git a/crates/engine/tests/integration/loop_shortcut_mana_engine.rs b/crates/engine/tests/integration/loop_shortcut_mana_engine.rs index 52c6b62253..6d95dbbe9e 100644 --- a/crates/engine/tests/integration/loop_shortcut_mana_engine.rs +++ b/crates/engine/tests/integration/loop_shortcut_mana_engine.rs @@ -806,22 +806,32 @@ fn cond_a_nontargeted_opponent_depletion_noops_at_exhaustion_not_abort() { ); } -/// R6a-2 (FAIL-CLOSED DISCRIMINATOR for the CR 732.2c ∞-badge filter). A mana engine -/// registers NO deferred materialization — `current_period_fodder` finds no fodder, -/// `current_period_counter_growth` / `current_period_life_growth` are empty — so nothing -/// will ever collapse its `Mana(_)` axis at the CR 500.5 boundary. It is therefore still -/// genuinely unbounded within the phase (`refill_infinite_mana` holds the pool at -/// `INFINITE_MANA_PER_TYPE`) and MUST keep rendering its `∞` row on the wire. +/// R6a-2, RECLASSIFIED under option (B): a CHANNEL-LIVENESS row, no longer a discriminator. +/// A mana engine registers NO deferred materialization — `current_period_fodder` finds no +/// fodder, `current_period_counter_growth` / `current_period_life_growth` are empty — so +/// nothing will ever collapse its `Mana(_)` axis at the CR 500.5 boundary. It is genuinely +/// unbounded within the phase (`refill_infinite_mana` holds the pool at +/// `INFINITE_MANA_PER_TYPE`) and MUST keep rendering its `∞` row on the wire. That claim is +/// true, user-visible and revert-detectable (RP-6 below) — it is simply no longer the thing +/// that distinguishes candidate implementations, because option (B) projects EVERY ∞ row. /// -/// The 12 shipped rows in this file are the must-NOT-flip control set; THIS is the new -/// discriminator. It is the fail-closed half of the R6a filter: R6a-1 proves a scheduled -/// axis hides, this proves an UNscheduled one does not. +/// WHY THE "TAG MUST BE EMPTY HERE" RE-ANCHOR IS NOT ADDED: it would be VACUOUS on this rig. +/// Reach-guard (2) below already asserts `pending_unbounded_materialization` is EMPTY, and an +/// empty stash makes the tag empty for the CORRECT implementation as well as for every wrong +/// one (labellability-keyed, count-keyed — `pending_materialization_count` is empty here too, +/// asserted by the sibling `mana_engine_accept_records_no_collapse_bound` — and +/// `from_resource_axis(Mana(_))` is `None` anyway). The tag discrimination therefore lives on +/// rigs with non-Mana axes: `loop_shortcut::unregistered_axis_still_renders_its_infinity_badge` +/// (post-clear tag emptiness) and `scheduled_drive_still_renders_the_already_spendable_mana_badge` +/// below, whose ONE stash names both a `Mana(_)` and a deferred `Life(P0)`. /// -/// REVERT-PROBE (RUN): key the `derive_views` filter on the ACCEPTED COUNT -/// (`state.pending_materialization_count.contains_key(&controller)`) instead of on the -/// axis set `scheduled_collapse_axes` returns — the count-keyed filter is written at every -/// `Fixed(n)` accept including this one, so the `Mana(_)` row vanishes ⇒ this row FAILS -/// while R6a-1 and the 12/12 control set stay green. +/// REVERT-PROBE (RP-6, RUN): append `views.unbounded_resources.clear();` at the END of +/// `derive_views` (re-kill the row channel unconditionally) ⇒ this row FAILS while the ∞ PILE +/// assertion in `loop_shortcut::scheduled_collapse_still_renders_the_unbounded_badge_tagged` +/// stays green — a different channel. +/// +/// The `shared_card_db()` guard below is DORMANT in a normal checkout: `integration_cards.json` +/// is tracked, so it only fires in a checkout without the card-data pipeline. #[test] fn mana_engine_accept_still_renders_its_infinity_badge() { let Some(db) = shared_card_db() else { return }; @@ -1000,21 +1010,23 @@ fn mana_engine_accept_records_no_collapse_bound() { ); } -/// R6a FIX-ROUND-3 (CR 732.2c + CR 500.5). MEASURED DEFECT in the ∞-badge hide-set: the -/// `PersistentAxisMaterialization::DriveSequence` arm of `scheduled_collapse_axes` extends the -/// hide-set with the loop's WHOLE axis set (`collapsed_axes` == `proposal.unbounded`), so a -/// scheduled drive covering a `Mana(_)` axis suppressed that axis' `∞` row on the wire. +/// R6a FIX-ROUND-3 (CR 732.2c + CR 500.5), now the MULTI-AUTHORITY row for the scheduled-collapse +/// TAG. The `PersistentAxisMaterialization::DriveSequence` arm of `scheduled_collapse_axes` +/// returns the loop's WHOLE axis set (`collapsed_axes` == `proposal.unbounded`), so ONE stash +/// here names TWO axes: an already-materialized `Mana(Colorless)` and a deferred `Life(P0)`. The +/// tag must attach to exactly one of them. /// -/// That is the wrong side of the class rule. `Tokens` / `Counters` / `Life` each name a -/// DEFERRED materialization — the growth is not on the board until the boundary applies it, so -/// rendering `∞` for them is the lie R6a exists to kill. Mana is ALREADY MATERIALIZED at accept: +/// That distinction is the class rule. `Tokens` / `Counters` / `Life` each name a DEFERRED +/// materialization — the growth is not on the board until the boundary applies it, so they are +/// exactly what "scheduled collapse" means. Mana is ALREADY MATERIALIZED at accept: /// `mana_payment::refill_infinite_mana` re-tops the flagged pool to `INFINITE_MANA_PER_TYPE` off /// `unbounded_resources` (the STORE, which the projection deliberately never filters) after every /// action, so throughout the accept→boundary window the player can really spend an unbounded -/// pool while the HUD showed no `∞` — an internally inconsistent HUD, the inverse of an "∞ Life" -/// badge beside a finite life total. CR 500.5 is what legitimately ends the badge: the step/phase -/// end drains the pool and `turns::drain_pending_phase_transition_progress` clears the axis -/// (covered by `combo_infinite_pile`'s E4 mana axis-clear row, not re-proved here). +/// pool. Tagging it "scheduled collapse" would mislabel a live pool. CR 500.5 is what ends the +/// mana badge: the step/phase end drains the pool and +/// `turns::drain_pending_phase_transition_progress` clears the axis (covered by +/// `combo_infinite_pile`'s E4 mana axis-clear row, not re-proved here) — NOT a materialization, +/// which is the documented under-report in `DerivedViews::scheduled_collapse`'s scope limit. /// /// HONEST SCOPE. Everything except one write is real: real cards through the real parser, a real /// two-beat Basalt+Power period, a real `DeclareShortcut`/`RespondToShortcut` accept that marks @@ -1028,11 +1040,12 @@ fn mana_engine_accept_records_no_collapse_bound() { /// `proposal.unbounded.clone()` that production writes. Same graft technique as /// `combo_infinite_pile::real_4p_observed_drive_sequence_replays_captured_period_n_times`. /// -/// REVERT-PROBE (RUN): delete the `axes.retain(|a| !matches!(a, ResourceAxis::Mana(_)))` in -/// `derive_views`' hide-set ⇒ (5) FAILS — the `Mana(Colorless)` row vanishes from the wire while -/// the pool is still being refilled. (6) is the paired positive control that keeps the probe -/// honest: the genuinely deferred `Life(P0)` axis in the SAME stash MUST stay hidden, so a -/// blanket "disable the filter" is not a passing alternative. +/// REVERT-PROBE (RP-3, RUN): delete the `axes.retain(|a| !matches!(a, ResourceAxis::Mana(_)))` +/// that builds `scheduled_axes` in `derive_views` ⇒ (6b)'s negative FAILS — the already-spendable +/// `Mana(Colorless)` axis gets tagged "scheduled collapse" while its pool is still being refilled. +/// (6b)'s positive (`Life(P0)` IS tagged) is the paired control that keeps the probe honest: a +/// blanket "tag nothing" is not a passing alternative. The retain is the SOLE cause of the +/// mana/deferred distinction. #[test] fn scheduled_drive_still_renders_the_already_spendable_mana_badge() { use engine::types::game_state::PersistentAxisMaterialization; @@ -1141,21 +1154,33 @@ fn scheduled_drive_still_renders_the_already_spendable_mana_badge() { ); for viewer in [None, Some(P0), Some(P1)] { - let rows = engine::game::derived_views::derive_views(state, viewer).unbounded_resources; - let axes: Vec = rows.iter().map(|r| r.axis).collect(); - // (5) DISCRIMINATOR — the already-materialized mana axis keeps its ∞ row on the WIRE. + let views = engine::game::derived_views::derive_views(state, viewer); + let axes: Vec = views.unbounded_resources.iter().map(|r| r.axis).collect(); + // (5) the already-materialized mana axis keeps its ∞ row on the WIRE. assert!( axes.contains(&ResourceAxis::Mana(ManaType::Colorless)), "CR 732.2c/CR 500.5: mana is already in the pool and still being refilled, so a \ merely-scheduled drive must NOT hide its ∞ row (viewer {viewer:?}), got {axes:?}" ); - // (6) POSITIVE CONTROL, SAME STATE — the genuinely deferred axis in the SAME - // `DriveSequence` is still hidden. Proves (5) is a targeted carve-out, not a disabled - // filter. + // (6) under option (B) the DEFERRED axis of the same `DriveSequence` also keeps its ∞ row + // — nothing has been applied yet, so both rows project. The mana/deferred distinction the + // `axes.retain(!Mana(_))` draws now lives in the TAG below, not in the row set. + assert!( + axes.contains(&ResourceAxis::Life(P0)), + "CR 732.2c: the deferred Life axis of the same scheduled drive still projects its ∞ \ + row while the collapse is merely scheduled (viewer {viewer:?}), got {axes:?}" + ); + // (6b) DISCRIMINATOR — the tag separates the two axes of the SAME stash: the deferred one + // is tagged, the already-materialized one is not. + let tagged: Vec = views.scheduled_collapse.iter().map(|r| r.axis).collect(); + assert!( + tagged.contains(&ResourceAxis::Life(P0)), + "the DEFERRED axis of the scheduled drive is TAGGED, got {tagged:?}" + ); assert!( - !axes.contains(&ResourceAxis::Life(P0)), - "CR 732.2c: the deferred Life axis of the same scheduled drive stays hidden (viewer \ - {viewer:?}), got {axes:?}" + !tagged.contains(&ResourceAxis::Mana(ManaType::Colorless)), + "…and the ALREADY-MATERIALIZED mana axis of the SAME stash is NOT — this is where the \ + `axes.retain(!Mana(_))` earns its keep, got {tagged:?}" ); } From 47151c7c13e979a6725151493414754117f7619b Mon Sep 17 00:00:00 2001 From: lgray Date: Tue, 4 Aug 2026 18:28:02 -0500 Subject: [PATCH 2/9] fix(engine): drop the unconsumed scheduled_collapse contract, correct the CR provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review response to #7002. Remove `DerivedViews::scheduled_collapse` entirely — the field, its wire mirror, its emission loop, both goldens' entries and every assertion. It had zero production client consumers: the only readers were the fixtures and the new seam test. It is the hook for the ∞→N affordance, so it belongs in the PR that builds that UI, arriving with the code that reads it rather than one PR ahead of it. The badge fix does not depend on it — verified by re-injecting all three original hide guards, which reds five badge tests, each on its own assertion across the pile, pill and row channels. Correct the rules provenance. The deferred accept→boundary window is an engine deviation: pre-existing, deliberate, and licensed by no CR. Earlier prose here presented it as behaviour CR 732.2c authorizes, which inverts what that rule says — CR 732.2c fixes the count at accept and advances the game to the proposed ending point. CR 732.2c is retained only where it genuinely governs: the accepted N is a ceiling the collapse may not exceed, which the reducer enforces. What it never licensed was *hiding* a mark the store still carries. The ∞ mark is defended on engine-state and display-coherence grounds rather than by citation. Through the window the loop's enablers remain on the battlefield and `unbounded_resources` / `unbounded_loop_enablers` are held in deliberate CR 104.4b / CR 110.1 lockstep, which `zones::apply_zone_exit_cleanup` reads to defuse a capability whose enabler leaves. Filtering the projection while the store still said ∞ gave a HUD that contradicted its own engine, and it suppressed an already-materialized `Mana(_)` axis that `refill_infinite_mana` keeps topping off — a hidden badge beside a visibly refilling pool. No behaviour change in this commit: filtering comment lines out of the engine diff leaves only the deleted guards. Assisted-by: ClaudeCode:claude-opus-5 --- client/src/adapter/types.ts | 21 +-- .../test/fixtures/unbounded-counter-wire.json | 11 -- .../test/fixtures/unbounded-token-wire.json | 6 - .../__tests__/unboundedWireSeam.test.ts | 20 +-- crates/engine/src/game/derived_views.rs | 118 +++++--------- crates/engine/src/types/game_state.rs | 54 +++---- .../tests/integration/combo_infinite_pile.rs | 57 +++---- .../kilo_live_offer_from_real_dump.rs | 30 ++-- .../engine/tests/integration/loop_shortcut.rs | 149 ++++++++---------- .../integration/loop_shortcut_mana_engine.rs | 92 +++++------ 10 files changed, 213 insertions(+), 345 deletions(-) diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index e119ffd899..c7c4290c6e 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -2846,6 +2846,12 @@ export interface DerivedViews { * of every unbounded-resource loop. Empty/omitted when no loop is active. The * FE maps each axis to a display family and never re-derives attribution. * Mirrors `engine::game::derived_views::DerivedViews::unbounded_resources`. + * + * This channel and its two siblings below stay POPULATED after all players accept a + * shortcut, until the engine applies the growth at the next CR 500.5 boundary. Deferring + * the application across that window is an engine deviation, pre-existing and deliberate. + * What matters to the FE is only that the mark and its enablers are still live there, so + * `∞` is current engine state, not a stale mark. Render it. */ unbounded_resources?: UnboundedResourceView[]; /** @@ -2867,21 +2873,6 @@ export interface DerivedViews { * `engine::game::derived_views::DerivedViews::unbounded_counters`. */ unbounded_counters?: Record; - /** - * CR 732.2c: the `(engine-attributed player, axis)` pairs whose DEFERRED unbounded growth has an - * accepted-but-not-yet-applied finite collapse waiting at the next CR 500.5 boundary. A TAG, not - * a filter — `unbounded_resources` / `unbounded_pile` / `unbounded_counters` stay populated while - * scheduled and still render `∞`. Join a row to its tag by exact `(player, axis)` equality. - * - * SCOPE LIMIT: `Mana` axes are deliberately absent (already spendable), so this UNDER-REPORTS - * "which ∞ rows will stop being ∞" — a mana ∞ ends at the CR 500.5 step/phase end rather than via - * a materialization. It answers the narrower question: which ∞ rows name DEFERRED growth a - * registered materialization will cash out at the boundary. - * - * Empty/omitted when nothing is scheduled. Mirrors - * `engine::game::derived_views::DerivedViews::scheduled_collapse`. - */ - scheduled_collapse?: UnboundedResourceView[]; } /** Mirrors `engine::types::game_state::NextSpellModifier` (serde tag="type"). */ diff --git a/client/src/test/fixtures/unbounded-counter-wire.json b/client/src/test/fixtures/unbounded-counter-wire.json index 2ea310a90c..0e043bc155 100644 --- a/client/src/test/fixtures/unbounded-counter-wire.json +++ b/client/src/test/fixtures/unbounded-counter-wire.json @@ -1,15 +1,4 @@ { - "scheduled_collapse": [ - { - "axis": { - "Counter": [ - "Other", - "Other" - ] - }, - "player": 0 - } - ], "unbounded_counters": { "405": [ "charge" diff --git a/client/src/test/fixtures/unbounded-token-wire.json b/client/src/test/fixtures/unbounded-token-wire.json index 0b1ecc5995..f57ef0a47f 100644 --- a/client/src/test/fixtures/unbounded-token-wire.json +++ b/client/src/test/fixtures/unbounded-token-wire.json @@ -1,10 +1,4 @@ { - "scheduled_collapse": [ - { - "axis": "TokensCreated", - "player": 0 - } - ], "unbounded_pile": [ 402, 403, diff --git a/client/src/viewmodel/__tests__/unboundedWireSeam.test.ts b/client/src/viewmodel/__tests__/unboundedWireSeam.test.ts index 2aed531fa7..33d47fde39 100644 --- a/client/src/viewmodel/__tests__/unboundedWireSeam.test.ts +++ b/client/src/viewmodel/__tests__/unboundedWireSeam.test.ts @@ -1,11 +1,16 @@ /** - * CR 732.2c cross-seam pin. Both JSON files are ENGINE-EMITTED by + * ∞-channel cross-seam pin. Both JSON files are ENGINE-EMITTED by * `combo_infinite_pile::real_4p_object_growth_accept_writes_infinite_pile` and * `kilo_live_offer_from_real_dump::kilo_accept_marks_pentad_charge_as_unbounded_display_target`, * each driving a REAL 4-player dump through the REAL APNAP accept. Regenerate with * `UPDATE_WIRE_GOLDEN=1 cargo test -p phase-engine --test integration `. Never hand-edit them. * Every existing client test that touches these channels hand-writes its own `derived` block, so * this file is the only place the engine's wire shape and the client's readers meet. + * Both goldens are captured AFTER the accept, while a finite collapse is merely SCHEDULED — the + * engine defers APPLYING the growth to the next CR 500.5 boundary (an engine deviation, + * pre-existing and deliberate), and the marks stay live through that window, so the ∞ channels are + * still populated. If the engine went back to hiding them there, both goldens would regenerate + * empty and every assertion below would red. * The `unbounded_pile → Set` hop is performed here rather than by `gameStateView.ts`, because * driving that function would require committing a whole `GameState`; the ids, the field name and * the value encoding — the parts that actually differ across the language boundary — are @@ -106,17 +111,4 @@ describe("unbounded ∞ wire seam (engine-emitted goldens)", () => { // (11) paired NEGATIVE: 404 is on the same battlefield and carries no ∞ mark. expect(renderHook(() => useUnboundedCounterTypes(404)).result.current).toEqual([]); }); - - // 12 and 13 are separate `it`s so that a tag regression reds BOTH families independently — in - // one `it` the first `expect` throws and the second family is never measured. - it("carries the scheduled-collapse tag on the unit-variant family", () => { - // (12) THE DOCTRINE: the ∞ badge ships WHILE a finite collapse is merely scheduled, and the - // tag names which rows the CR 500.5 boundary will cash out. - expect(tokenWire.scheduled_collapse).toEqual([{ axis: "TokensCreated", player: 0 }]); - }); - - it("carries the scheduled-collapse tag on the data-variant family", () => { - // (13) …and the same tag on the counter family. - expect(counterWire.scheduled_collapse[0].axis).toEqual({ Counter: ["Other", "Other"] }); - }); }); diff --git a/crates/engine/src/game/derived_views.rs b/crates/engine/src/game/derived_views.rs index d4ea49b045..f56aff1f61 100644 --- a/crates/engine/src/game/derived_views.rs +++ b/crates/engine/src/game/derived_views.rs @@ -379,25 +379,6 @@ pub struct DerivedViews { /// omitted) when no counter-growth loop is active — the dominant case. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub unbounded_counters: HashMap>, - - /// CR 732.2c: the `(attributed player, axis)` pairs whose DEFERRED unbounded growth has an - /// accepted-but-not-yet-applied finite collapse waiting at the next CR 500.5 boundary. A TAG, - /// NOT a filter: `unbounded_resources` / `unbounded_pile` / `unbounded_counters` above stay - /// projected while scheduled, and this names which of them the boundary will cash out. Same - /// single authority (`GameState::scheduled_collapse_axes`) and the same `attribution_player` - /// keying as `unbounded_resources`, so a row joins its tag by exact `(player, axis)` equality. - /// - /// SCOPE LIMIT — read this before using it as "which ∞ rows stop being ∞". `Mana(_)` axes are - /// deliberately ABSENT: mana is already materialized and spendable (`refill_infinite_mana` - /// re-tops the pool off the store), so tagging it would mislabel a live pool. A mana ∞ *does* - /// end — at the CR 500.5 step/phase end, via `turns::drain_pending_phase_transition_progress`, - /// NOT via a materialization — so this field UNDER-REPORTS that case by design. It answers the - /// narrower question: which ∞ rows name growth that is DEFERRED and will be cashed out by a - /// registered materialization at the boundary. - /// - /// Empty (and omitted) whenever nothing is scheduled — the dominant case. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub scheduled_collapse: Vec, } /// Serialize-only wrapper: the WASM getter passes `&GameState` by reference @@ -812,74 +793,53 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews views.turn_order = turn_order; views.viewer_turn_number = viewer_turn_number; - // CR 732.2c (docs/MagicCompRules.txt:6394) says that once the last player has accepted, the - // shortcut IS taken and the game advances to the ending point the proposal named. THIS ENGINE - // DEFERS that: the growth is not applied at accept, it is parked until the next CR 500.5 - // boundary (docs/MagicCompRules.txt:2122), where `turns.rs:540-591` asks the controller to - // CHOOSE N in `[0, recorded bound]`. `turns.rs:553-556` documents that deferral in its own - // words as an engine tolerance no CR licenses. + // WHY THE THREE ∞ CHANNELS BELOW ARE UNCONDITIONAL — the accept→boundary window. // - // So across accept -> boundary the game has NOT advanced, N is NOT determined, and no growth - // is on the board: what is there is still a certified-unbounded loop with no materialized - // bound. The projection therefore KEEPS the `∞` on every surface and TAGS it scheduled rather - // than hiding it. During that window the badge is load-bearing board-state information; hiding - // it erases the loop's identity from the display while the loop is still the truth of the - // board. + // THE WINDOW IS AN ENGINE DEVIATION, PRE-EXISTING AND DELIBERATE — NOT A RULES ENTITLEMENT, and + // no CR is cited as licensing it. CR 732.2c has the shortcut taken the moment the last player + // accepts, with the game advancing to the last proposed ending point; this engine instead parks + // at priority with the accepted count recorded but its results UNAPPLIED, and settles them at + // the next CR 500.5 boundary (`game::turns`, unchanged by this projection). Nothing below + // claims otherwise. What IS resolved at accept is the count itself + // (`pending_materialization_count`); what is deferred is applying it, plus `turns.rs`' `min: 0` + // under-delivery tolerance, which that file documents in its own words. // - // ONE authority (`GameState::scheduled_collapse_axes`), now exactly ONE consumer here: the - // `scheduled_collapse` tag below. The per-axis resource badge rows, the ∞ object pile and the - // ∞ counter pills are UNFILTERED — they read their own stores and no longer consult this set - // at all. (`clear_collapsed_materializations` is the authority's other caller, unchanged.) + // The two CRs this code does rely on, each for what it actually governs: + // • CR 732.2c — the shortcut is taken at the count every player accepted, so the collapse may + // not EXCEED it. `turns.rs`' `max:` reads the recorded bound for exactly that reason and + // `SubmitPayAmount` rejects an over-collapse. That is a CEILING on the collapse; it says + // nothing about what the display may show, and this projection does not read it. + // • CR 500.5 — the boundary where the deferred growth is applied. // - // The `Mana(_)` `retain` is preserved and REPURPOSED from hide-filter to tag-filter: it now - // means "tag only DEFERRED growth". `Tokens` / `Counters` / `Life` are deferred by - // construction — the growth is not on the board until the boundary applies it. A `Mana(_)` is - // not: `mana_payment::refill_infinite_mana` tops that controller's pool back to - // `INFINITE_MANA_PER_TYPE` off the STORE (which this projection deliberately never touches) - // after every action, so that pool is live and spendable right now and tagging it "scheduled - // collapse" would mislabel it. SCOPE LIMIT: a mana ∞ does end — at the CR 500.5 step/phase - // end, via `turns::drain_pending_phase_transition_progress`, NOT via a materialization — so - // this tag deliberately UNDER-REPORTS "which ∞ rows will stop being ∞". It answers the - // narrower question: which ∞ rows name growth that is DEFERRED and will be cashed out by a - // registered materialization at the boundary. Widen this `retain` only for an axis that gains - // the same already-materialized property. + // WHY `∞` IS RIGHT HERE IS AN ENGINE-STATE ARGUMENT, NOT A RULES ONE. Throughout the window the + // enablers are still on the battlefield and `unbounded_resources` + `unbounded_loop_enablers` + // are deliberately held in lockstep (below), so the controller really does still hold a set of + // actions that could be repeated indefinitely — a CR-732.1b-SHAPED capability, which is the + // same sense the rest of this crate cites CR 732.1b in. `∞` renders that live mark honestly. // - // The store is still never filtered: `unbounded_resources` + `unbounded_loop_enablers` stay in - // CR 104.4b / CR 110.1 lockstep until the CR 500.5 boundary applies the growth, which is what - // keeps `zones::apply_zone_exit_cleanup`'s defuse armed in the meantime. + // And hiding it is strictly worse on display coherence, which is what the old "the badge is a + // lie" comment was really about. The BASE gate filtered the PROJECTION while the STORE still + // said `∞` — a HUD contradicting its own engine — and it also suppressed an already- + // materialized `Mana(_)` axis that `mana_payment::refill_infinite_mana` keeps topping back up, + // i.e. it hid a badge beside a pool the player can visibly keep spending. // - // `clear_collapsed_materializations` reads this SAME authority and is UNCHANGED: it still - // removes each collapsed axis at the boundary, and that is what legitimately ends both the ∞ - // rows and this tag. - let scheduled_axes: BTreeMap> = state - .pending_unbounded_materialization - .iter() - .map(|(&controller, items)| { - let mut axes = state.scheduled_collapse_axes(items); - axes.retain(|a| !matches!(a, ResourceAxis::Mana(_))); - (controller, axes) - }) - .collect(); - - // CR 732.2c: the tag itself — ONE straight projection of the authority above, keyed by the - // SAME `attribution_player` the ∞ rows use so a row and its tag join by exact equality. - for (&controller, axes) in &scheduled_axes { - for &axis in axes { - views.scheduled_collapse.push(UnboundedResourceView { - player: attribution_player(axis, controller), - axis, - }); - } - } + // The three loops below therefore read only their own stores; none consults + // `GameState::scheduled_collapse_axes` (whose sole production caller is + // `clear_collapsed_materializations`). The stores are not filtered either: + // `unbounded_resources` + `unbounded_loop_enablers` stay in CR 104.4b / CR 110.1 lockstep + // until the boundary applies the growth, which is what keeps `zones::apply_zone_exit_cleanup`'s + // defuse armed in the meantime. + // + // What ends each `∞` is the boundary, never this projection: + // `clear_collapsed_materializations` drops the collapsed axes once the growth is applied, and + // `turns::drain_pending_phase_transition_progress` clears a `Mana(_)` axis when the step or + // phase ends (CR 500.5). // CR 732.2a: project every unbounded-resource loop into per-(player, axis) // `∞` HUD rows. Runs in every format (placed BEFORE the Commander // short-circuit below) and stays empty (field omitted) when no loop is // active — the dominant case. The engine owns attribution // (`attribution_player`); the frontend only formats each axis to a family. - // - // CR 732.2c: NOT filtered — see the doctrine block above; scheduled axes are TAGGED in - // `scheduled_collapse`. for (&controller, axes) in &state.unbounded_resources { for &axis in axes { views.unbounded_resources.push(UnboundedResourceView { @@ -894,8 +854,7 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews // left the battlefield (stale member). Public board state (no viewer filtering); // the frontend renders `∞` on any group whose members are all pile members. // - // CR 732.2c: NOT filtered — see the doctrine block above; scheduled axes are TAGGED in - // `scheduled_collapse`. + // Unconditional while a collapse is merely scheduled — see the engine-deviation block above. for ids in state.unbounded_loop_pile.values() { for id in ids { if state.battlefield.contains(id) { @@ -911,8 +870,7 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews // `unbounded_pile`; the frontend renders `∞` (not `×N`) on any counter pill whose // type is in this set. Runs in every format (BEFORE the Commander short-circuit). // - // CR 732.2c: NOT filtered — see the doctrine block above; scheduled axes are TAGGED in - // `scheduled_collapse`. + // Unconditional while a collapse is merely scheduled — see the engine-deviation block above. for targets in state.unbounded_counter_targets.values() { for (id, ct) in targets { if !state.battlefield.contains(id) { diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 3c937ca122..e69b661741 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -3537,8 +3537,8 @@ pub enum PersistentAxisMaterialization { /// across an ordinary zone change, so the `Other` fallback is reached only when the bearer /// truly stopped existing, or when its printed types changed under CR 400.7. In that window /// the derived axis becomes `Counter(_, Other)`, which joins no axis `unbounded_resources` -/// holds ⇒ the row keeps rendering `∞` but loses its `scheduled_collapse` tag, and the -/// boundary's removal finds nothing to take away. Nothing is ever hidden by the miss. +/// holds ⇒ the boundary's removal finds nothing to take away and the row keeps rendering `∞` +/// one boundary longer than it should. Nothing is ever hidden by the miss. /// /// CENSUS (`grep -rn 'objects.remove' crates/`, `#[cfg(test)]` bodies excluded) — FOUR /// production removes, not one. Cease-to-exist (`zones::…replay_resolved_object_cease`, @@ -3557,14 +3557,13 @@ pub enum PersistentAxisMaterialization { /// REACHABILITY BY CONSUMER — with the pill projection no longer mapping to an axis, both /// remaining consumers are LIVE, and byte-identical to the pre-extraction nested /// `counter_axis` helper they already used: -/// • `scheduled_collapse_axes` — read by `derive_views` to build the `scheduled_collapse` -/// TAG and by `clear_collapsed_materializations` to pick removals. Exactly the fail-open -/// described above: an untagged `∞` row, and a removal that finds nothing. +/// • `scheduled_collapse_axes` — read by `clear_collapsed_materializations` to pick the +/// removals. Exactly the fail-open described above: a removal that finds nothing. /// • `clear_collapsed_materializations`' own surviving-target guard. /// /// That fail-open polarity is the SAME one this phase mandates everywhere else (an axis -/// with no registration renders ∞): it can only ever leave an ∞ standing — untagged, or one -/// boundary longer than it should — never hide a real one. A snapshot would have to add a +/// with no registration renders ∞): it can only ever leave an ∞ standing one boundary longer +/// than it should — never hide a real one. A snapshot would have to add a /// serde field to `CounterGrowth` — a saved-game surface change across every construction /// site — to buy a strictly-display improvement, so it is not taken. Removing a non-present /// axis stays a harmless no-op, and `clear_collapsed_materializations`' surviving-target @@ -19819,33 +19818,32 @@ impl GameState { } /// CR 732.2a: the exact `ResourceAxis` set a deferred materialization stash will - /// collapse at the next CR 500.5 boundary. SINGLE AUTHORITY, with exactly two callers: - /// - `clear_collapsed_materializations` REMOVES these axes once the growth was applied; - /// - `game::derived_views::derive_views` TAGS them into `DerivedViews::scheduled_collapse` - /// while the collapse is merely SCHEDULED. A TAG, NOT A FILTER: the `∞` HUD rows, the ∞ - /// object pile and the ∞ counter pills all stay projected. CR 732.2c says the shortcut is - /// taken at accept, but this engine DEFERS the growth to the next CR 500.5 boundary, so - /// across accept→boundary nothing has been applied and N is not chosen yet — what is on - /// the board is still a certified-unbounded loop, the `∞` is load-bearing board state, - /// and this set only names which of those badges the boundary will cash out. + /// collapse at the next CR 500.5 boundary. SINGLE AUTHORITY with ONE production caller, + /// `clear_collapsed_materializations`, which REMOVES these axes once the growth was applied. + /// + /// NOT a display filter, and deliberately not read by `game::derived_views`. This stash is the + /// engine's DEFERRAL of an accepted shortcut's results — an engine deviation, pre-existing and + /// deliberate, that no CR licenses. The count itself is already fixed at accept + /// (`pending_materialization_count`); what is deferred is putting the growth on the board. + /// While it is pending, the `∞` HUD rows, the ∞ object pile and the ∞ counter pills all keep + /// projecting, because the marks and their enablers are still live. This set says nothing + /// about them — it names what the boundary will REMOVE, not what the display may show. /// /// Returns the axes UNFILTERED, including any `Mana(_)` a `DriveSequence` names, because the - /// `clear_collapsed_materializations` caller MUST remove that axis at the boundary. The - /// `derive_views` caller drops `Mana(_)` on the way out so its tag means "DEFERRED growth - /// only": mana is already materialized in the pool (`mana_payment::refill_infinite_mana` - /// re-tops it off this very store until CR 500.5 empties it), so tagging that live, - /// spendable pool "scheduled collapse" would mislabel it. The tag therefore deliberately - /// UNDER-REPORTS mana, whose `∞` does end — at the CR 500.5 step/phase end rather than by a - /// materialization. See the class rule at that call site. + /// caller MUST remove that axis at the boundary. Note the two axis classes end their `∞` by + /// different routes: `Tokens` / `Counters` / `Life` are DEFERRED and end here, when the + /// boundary applies the growth; a `Mana(_)` is already materialized in the pool + /// (`mana_payment::refill_infinite_mana` re-tops it off this very store) and its `∞` ends at + /// the CR 500.5 step/phase end instead. /// - /// Tagging in the PROJECTION rather than removing from the store is load-bearing: + /// Removing at the boundary rather than filtering the store meanwhile is load-bearing: /// the store keeps `unbounded_resources` and `unbounded_loop_enablers` in CR 104.4b / /// CR 110.1 lockstep, which is what `zones::apply_zone_exit_cleanup` reads to defuse a /// capability whose enabler leaves between accept and boundary. /// /// FAIL-CLOSED: only an axis some REGISTERED item actually collapses is returned, so an - /// ∞ axis with no registration (a mana engine registers nothing) is neither tagged here nor - /// removed at the boundary — it keeps its badge. + /// ∞ axis with no registration (a mana engine registers nothing) is never removed here — + /// it keeps its badge until CR 500.5 ends it. /// EXHAUSTIVE over `PersistentAxisMaterialization` (no wildcard) — a future variant /// build-breaks here instead of silently leaking a stale `∞`. pub fn scheduled_collapse_axes( @@ -19916,8 +19914,8 @@ impl GameState { collapsed: &[PersistentAxisMaterialization], ) { // --- Phase 1 (reads): what to remove --- - // The axis set comes from the SHARED authority the ∞-row projection also reads, so - // "tagged while scheduled" and "removed once applied" can never disagree. + // The axis set comes from `scheduled_collapse_axes`, so "what a stash schedules" and + // "what is removed once applied" are one match, never two copies of it. let mut axes_to_remove = self.scheduled_collapse_axes(collapsed); // The token pile drops exactly when the token axis collapses — true for a batched // `Tokens` item and for a `DriveSequence` that names `TokensCreated`. diff --git a/crates/engine/tests/integration/combo_infinite_pile.rs b/crates/engine/tests/integration/combo_infinite_pile.rs index fad7d8fd53..d14c1bf558 100644 --- a/crates/engine/tests/integration/combo_infinite_pile.rs +++ b/crates/engine/tests/integration/combo_infinite_pile.rs @@ -150,8 +150,6 @@ fn drive_all_accept_n(state: &mut GameState, n: u32) { #[test] fn real_4p_object_growth_accept_writes_infinite_pile() { - use engine::analysis::resource::ResourceAxis; - let mut state: GameState = serde_json::from_str(&OFFER_STATE) .expect("the real 4p offer dump must deserialize into the current GameState"); @@ -222,11 +220,11 @@ fn real_4p_object_growth_accept_writes_infinite_pile() { // (2) DERIVED — derive_views projects the pile (battlefield-filtered, public board state). // - // CR 732.2c (option B): this accept ALSO scheduled a finite `TokensCreated` collapse, but the - // growth is DEFERRED to the CR 500.5 boundary — no token has been minted and N is not chosen — - // so the ∞ pile stays PROJECTED while the collapse is merely scheduled, and is TAGGED in - // `scheduled_collapse` instead of being hidden. The scheduling does not change the pile set, - // which is why the same `oracle` comparison now runs directly on the real post-accept state. + // This accept ALSO scheduled a finite `TokensCreated` collapse, but the engine DEFERS applying + // it to the CR 500.5 boundary — an engine deviation, pre-existing and deliberate. No token has + // been minted yet: the `oracle` set below is the tapped fodder P0 ALREADY controlled, and the ∞ + // mark over it is live, so the pile stays PROJECTED. The scheduling does not change the pile + // set, which is why the same `oracle` comparison runs directly on the real post-accept state. // // REVERT-PROBE (RP-1): restore the `collapse_scheduled(controller, &TokensCreated) { continue; }` // guard in `derive_views`' pile loop ⇒ THIS `assert_eq!` fails with an empty `left` while the @@ -234,9 +232,9 @@ fn real_4p_object_growth_accept_writes_infinite_pile() { let derived = derive_views(&state, Some(P0)); let derived_set: BTreeSet = derived.unbounded_pile.iter().copied().collect(); - // CR 732.2c cross-seam pin, PART 1 — compute + (optionally) REGENERATE. Provenance: every - // key/value below is ENGINE-EMITTED (`serde_json::to_value(&derive_views(..))`). The four ∞ keys - // are lifted BY NAME from the real serialized DerivedViews so unrelated derived-view churn + // Cross-seam wire pin, PART 1 — compute + (optionally) REGENERATE. Provenance: every + // key/value below is ENGINE-EMITTED (`serde_json::to_value(&derive_views(..))`). The three ∞ + // keys are lifted BY NAME from the real serialized DerivedViews so unrelated derived-view churn // cannot move this golden, while the field names and value encodings — the part the TS mirror // must match — stay engine-authored. // @@ -254,7 +252,6 @@ fn real_4p_object_growth_accept_writes_infinite_pile() { "unbounded_pile", "unbounded_resources", "unbounded_counters", - "scheduled_collapse", ] .into_iter() .filter_map(|k| wire.get(k).map(|v| (k.to_string(), v.clone()))) @@ -283,7 +280,7 @@ fn real_4p_object_growth_accept_writes_infinite_pile() { "derive_views().unbounded_pile must equal the pile set (battlefield-filtered)" ); - // CR 732.2c cross-seam pin, PART 2 — the drift COMPARE (see PART 1 for why it sits here). + // Cross-seam wire pin, PART 2 — the drift COMPARE (see PART 1 for why it sits here). let committed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).expect("committed wire golden")) .unwrap(); @@ -293,14 +290,6 @@ fn real_4p_object_growth_accept_writes_infinite_pile() { "the client's wire golden drifted from engine output — re-run with UPDATE_WIRE_GOLDEN=1" ); - assert!( - derived - .scheduled_collapse - .iter() - .any(|r| r.player == P0 && r.axis == ResourceAxis::TokensCreated), - "CR 732.2c: the pile is projected AND its TokensCreated axis is tagged scheduled" - ); - // (3) ROUND-TRIP — the pile survives serialize → deserialize (the "reloaded post-accept // shows no pile" fix for POST-FIX saves) AND derive_views re-exposes it. let json = serde_json::to_string(&state).expect("serialize the post-accept state"); @@ -310,10 +299,10 @@ fn real_4p_object_growth_accept_writes_infinite_pile() { Some(&oracle), "the ∞ pile survives a serde round-trip (post-fix saves reload it)" ); - // CR 732.2c (option B): the stash round-trips too, so the reloaded state's collapse is still - // SCHEDULED — and the ∞ pile stays PROJECTED while it is. The scheduling does not change the - // pile set, which is why the same `oracle` comparison now runs directly on the real reloaded - // post-accept state instead of on a stash-cleared clone. + // The stash round-trips too, so the reloaded state's collapse is still SCHEDULED — and the ∞ + // pile stays PROJECTED while it is, for the same reason as (2) above. The scheduling does not + // change the pile set, which is why the same `oracle` comparison now runs directly on the real + // reloaded post-accept state instead of on a stash-cleared clone. let reloaded_set: BTreeSet = derive_views(&reloaded, Some(P0)) .unbounded_pile .iter() @@ -665,11 +654,11 @@ fn build_fresh_4p_cast_offer_accept_writes_infinite_pile() { // (2) DERIVED — derive_views projects the pile. // - // CR 732.2c (option B): the accept also scheduled a finite `TokensCreated` collapse, but that - // growth is DEFERRED to the CR 500.5 boundary, so the ∞ pile stays PROJECTED while it is - // merely scheduled (and is TAGGED in `scheduled_collapse`). The scheduling does not change the - // pile set, which is why the same `oracle` comparison now runs directly on the real - // post-accept state. + // The accept also scheduled a finite `TokensCreated` collapse, but the engine DEFERS applying + // it to the CR 500.5 boundary (an engine deviation, pre-existing and deliberate), so nothing is + // minted yet and the ∞ pile stays PROJECTED while it is merely scheduled. The scheduling does + // not change the pile set, which is why the same `oracle` comparison now runs directly on the + // real post-accept state. let derived_set: BTreeSet = derive_views(runner.state(), Some(P0)) .unbounded_pile .iter() @@ -1478,11 +1467,11 @@ fn real_4p_one_shot_bootstrap_seeds_tapped_infinite_pile_and_w_plus_1_untapped() // derive_views projects the pile; it survives a serde round-trip. // - // CR 732.2c (option B): the accept also scheduled a finite `TokensCreated` collapse, but that - // growth is DEFERRED to the CR 500.5 boundary, so the ∞ pile stays PROJECTED while it is - // merely scheduled (and is TAGGED in `scheduled_collapse`). The scheduling does not change the - // pile set, which is why the same `oracle` comparison now runs directly on the real - // post-accept state. + // The accept also scheduled a finite `TokensCreated` collapse, but the engine DEFERS applying + // it to the CR 500.5 boundary (an engine deviation, pre-existing and deliberate), so nothing is + // minted yet and the ∞ pile stays PROJECTED while it is merely scheduled. The scheduling does + // not change the pile set, which is why the same `oracle` comparison now runs directly on the + // real post-accept state. let derived_set: BTreeSet = derive_views(runner.state(), Some(P0)) .unbounded_pile .iter() diff --git a/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs b/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs index 4d58ee000a..1dbf30140e 100644 --- a/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs +++ b/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs @@ -19,7 +19,6 @@ //! The `kilo_reinjected_pinless_history_suppresses_offer` test is the matched-pair proof that the //! migration is load-bearing (re-injecting the stale prefix flips the offer OFF). -use engine::analysis::resource::ResourceAxis; use engine::game::engine::apply; use engine::types::ability::TargetRef; use engine::types::actions::GameAction; @@ -435,19 +434,20 @@ fn kilo_accept_marks_pentad_charge_as_unbounded_display_target() { "display-only: Pentad's REAL charge count is unchanged by the ∞ mark (CR 701.34a)" ); - // (3) CR 732.2c — THE PER-SURFACE COUNTER-PILL ROW, on a REAL production fixture. The accept - // registers an observed-growth `DriveSequence` naming the charge-counter axis, but the growth - // is DEFERRED to the CR 500.5 boundary, so the pill stays ∞ and carries a scheduled TAG. - // Filter nothing: (2) above (the store write) is unchanged and still passes. + // (3) THE PER-SURFACE COUNTER-PILL ROW, on a REAL production fixture. The accept registers an + // observed-growth `DriveSequence` naming the charge-counter axis, but the engine DEFERS + // applying it to the CR 500.5 boundary — an engine deviation, pre-existing and deliberate. The + // real charge count is unchanged (asserted just above) and the ∞ mark is live, so the pill + // stays ∞ throughout that window. Filter nothing: (2) above is unchanged and still passes. // // REVERT-PROBE (RP-1c, RUN): restore the `collapse_scheduled(..)` guard in `derive_views`' // counter-pill loop ⇒ THIS `assert_eq!` fails (`left: None`) while (2) above and the pile // and row channels stay green. let views = derive_views(&state, None); - // CR 732.2c cross-seam pin, PART 1 — compute + (optionally) REGENERATE. Provenance: every - // key/value below is ENGINE-EMITTED (`serde_json::to_value(&derive_views(..))`). The four ∞ keys - // are lifted BY NAME from the real serialized DerivedViews so unrelated derived-view churn + // Cross-seam wire pin, PART 1 — compute + (optionally) REGENERATE. Provenance: every + // key/value below is ENGINE-EMITTED (`serde_json::to_value(&derive_views(..))`). The three ∞ + // keys are lifted BY NAME from the real serialized DerivedViews so unrelated derived-view churn // cannot move this golden, while the field names and value encodings — the part the TS mirror // must match — stay engine-authored. // @@ -465,7 +465,6 @@ fn kilo_accept_marks_pentad_charge_as_unbounded_display_target() { "unbounded_pile", "unbounded_resources", "unbounded_counters", - "scheduled_collapse", ] .into_iter() .filter_map(|k| wire.get(k).map(|v| (k.to_string(), v.clone()))) @@ -492,10 +491,10 @@ fn kilo_accept_marks_pentad_charge_as_unbounded_display_target() { assert_eq!( views.unbounded_counters.get(&PENTAD), Some(&vec![charge.clone()]), - "CR 732.2c: the ∞ charge pill stays projected while the collapse is merely SCHEDULED" + "the ∞ charge pill stays projected while the collapse is merely SCHEDULED" ); - // CR 732.2c cross-seam pin, PART 2 — the drift COMPARE (see PART 1 for why it sits here). + // Cross-seam wire pin, PART 2 — the drift COMPARE (see PART 1 for why it sits here). let committed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).expect("committed wire golden")) .unwrap(); @@ -505,15 +504,6 @@ fn kilo_accept_marks_pentad_charge_as_unbounded_display_target() { "the client's wire golden drifted from engine output — re-run with UPDATE_WIRE_GOLDEN=1" ); - assert!( - views - .scheduled_collapse - .iter() - .any(|r| r.player == P0 && matches!(r.axis, ResourceAxis::Counter(..))), - "…and the counter axis is TAGGED scheduled, got {:?}", - views.scheduled_collapse - ); - // (4) WIRE ROUND-TRIP (FLIPS on revert): the populated channel serializes, is present on // the wire, and survives a round-trip; an EMPTY derived view omits it (skip_serializing_if). let json = serde_json::to_string(&views).expect("derived views serialize"); diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index e318c4f032..9aeb2bd06d 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -7075,8 +7075,9 @@ fn phase_reachable_ledger_observer_whose_filter_matches_the_class_still_suppress } // =========================================================================== -// R6a — the ∞ badge is a lie once the collapse is SCHEDULED, and CR 732.2c -// bounds the boundary prompt by the count the table accepted. +// R6a — the ∞ badge stays up while a collapse is merely SCHEDULED (the engine defers APPLYING +// an accepted shortcut's growth to the CR 500.5 boundary; that window is an engine deviation, not +// a rules entitlement), and CR 732.2c bounds the boundary prompt by the count the table accepted. // =========================================================================== /// Sprout Swarm in P0's hand in the `witherbloom_sprout_lumaret_simple_4p` capture. @@ -7143,21 +7144,20 @@ fn r6a_drive_to_boundary(state: &mut GameState) { /// R6a-1 (PRIMARY), INVERTED to option (B). Accepting the Witherbloom/Sprout loop writes /// `unbounded_resources = {P0: [Life(0), TokensCreated]}` plus a non-empty ∞ pile, and -/// registers a finite collapse. CR 732.2c says the shortcut IS taken at the accepted N — but -/// THIS ENGINE DEFERS the growth to the CR 500.5 boundary (`turns.rs:540-591`, self-documented -/// as an engine tolerance at `turns.rs:553-556`), where the controller still has to CHOOSE N. -/// Across accept → boundary nothing has been applied and N is not determined: the board still -/// holds a certified-unbounded loop, so the projection KEEPS every `∞` surface and TAGS the -/// scheduled axes in `DerivedViews::scheduled_collapse` instead of hiding them. -/// -/// TAG, NEVER HIDE — and never filter the store either. The store must still carry the mark +/// registers a finite collapse. The COUNT is fixed at accept (`pending_materialization_count`, +/// which bounds the boundary prompt per CR 732.2c); what this engine defers is APPLYING it, until +/// the CR 500.5 boundary (`game::turns`). That deferral is an engine deviation — pre-existing, +/// deliberate, and licensed by no CR. This test pins what the engine DOES during that window, not +/// a claim that the rules put the game there: the marks and their enablers are still live, so the +/// projection KEEPS every `∞` surface. CR 732.2c bounds the collapse; it never licensed hiding a +/// mark the store still carries, which is what the BASE gate used it for. +/// +/// NEVER HIDE — and never filter the store either. The store must still carry the mark /// (CR 104.4b / CR 110.1 lockstep and `zones::apply_zone_exit_cleanup`'s defuse read it until /// the boundary applies the growth), so this row asserts store AND wire. /// /// NON-VACUITY: every wire assertion here is a NON-emptiness paired with the store's own -/// non-emptiness at (1), so a projection that returned nothing fails immediately; and the -/// post-drop emptiness discriminator lives out-of-loop in -/// `unregistered_axis_still_renders_its_infinity_badge`, on the same rig. +/// non-emptiness at (1), so a projection that returned nothing fails immediately. /// /// ASSERTION ORDER inside the viewer loop is PILE FIRST, then rows: RP-1 (pile guard) and /// RP-1d (row guard) each panic on their own line, so whichever comes first hides the other. @@ -7171,10 +7171,8 @@ fn r6a_drive_to_boundary(state: &mut GameState) { /// unreached here and is controlled out-of-loop (above). /// ⓑ RP-1d — restore `if collapse_scheduled(controller, &axis) { continue; }` in the resource /// row loop ⇒ the ROWS assertion below FAILS while the PILE assertion above it passes. -/// ⓒ RP-2 — delete the `scheduled_collapse` tag loop in `derive_views` ⇒ the tag assertion -/// below FAILS (and both wire goldens drift). #[test] -fn scheduled_collapse_still_renders_the_unbounded_badge_tagged() { +fn scheduled_collapse_still_renders_the_unbounded_badge() { let mut state = r6a_offer_state(); // (0) reach-guard: the real cast reached the CR 732.2a offer. @@ -7210,11 +7208,12 @@ fn scheduled_collapse_still_renders_the_unbounded_badge_tagged() { 1, "exactly one controller has a scheduled collapse" ); - // The growth really is finite: P0's life is a concrete number, not ∞. + // The growth is UNMATERIALIZED: the accepted count has not been applied, so P0's life is still + // a concrete number. The ∞ row beside it reports the live loop mark, not the current total. let life = state.players.iter().find(|p| p.id == P0).unwrap().life; assert!( life > 0, - "the axis the badge lies about is a finite life total, got {life}" + "the ∞-badged axis still shows an unmaterialized, finite life total, got {life}" ); // (2) FAIL-CLOSED CONTROL, in the SAME state: every ∞ axis the accept scheduled is @@ -7231,9 +7230,9 @@ fn scheduled_collapse_still_renders_the_unbounded_badge_tagged() { ); // (3) DISCRIMINATOR — on the WIRE, for EVERY viewer (and the spectator view), the ∞ pile and - // both ∞ rows still project, and both scheduled axes carry the tag. ALL THREE ∞ surfaces read - // the one authority, so the HUD can never show a card group's ∞ while hiding its resource - // badge. The PER-SURFACE positive rows live on their own real fixtures — + // both ∞ rows still project. No ∞ surface consults the collapse schedule, so the HUD can never + // show a card group's ∞ while hiding its resource badge. The PER-SURFACE positive rows live on + // their own real fixtures — // `combo_infinite_pile::real_4p_object_growth_accept_writes_infinite_pile` (pile) and // `kilo_live_offer_from_real_dump::kilo_accept_marks_pentad_charge_as_unbounded_display_ // target` (counter pills) — so a regression on ONE surface stays visible even though this @@ -7242,18 +7241,12 @@ fn scheduled_collapse_still_renders_the_unbounded_badge_tagged() { let views = engine::game::derived_views::derive_views(&state, viewer); assert!( !views.unbounded_pile.is_empty(), - "CR 732.2c: the scheduled collapse still projects the ∞ pile (viewer {viewer:?})" + "the scheduled collapse still projects the ∞ pile (viewer {viewer:?})" ); let axes: Vec = views.unbounded_resources.iter().map(|r| r.axis).collect(); assert!( axes.contains(&ResourceAxis::Life(P0)) && axes.contains(&ResourceAxis::TokensCreated), - "CR 732.2c: ...and both ∞ rows beside it (viewer {viewer:?}), got {axes:?}" - ); - let tagged: Vec = views.scheduled_collapse.iter().map(|r| r.axis).collect(); - assert!( - tagged.contains(&ResourceAxis::Life(P0)) - && tagged.contains(&ResourceAxis::TokensCreated), - "CR 732.2c: ...and both axes are TAGGED scheduled (viewer {viewer:?}), got {tagged:?}" + "...and both ∞ rows beside it (viewer {viewer:?}), got {axes:?}" ); } @@ -7266,24 +7259,23 @@ fn scheduled_collapse_still_renders_the_unbounded_badge_tagged() { // // What it DOES guard is the INPUT: `filter_state_for_viewer` is a clone-and-redact with // ZERO `unbounded` references today, so a filtered viewer sees the same ∞ surfaces the - // hot-seat viewer does. If a future redaction ever dropped the ∞ stores or the stash from the - // filtered clone, the broadcast path alone would go dark — remote players would lose the ∞ - // pile and rows (and their scheduled tag) while the local viewer kept them. That asymmetry is - // the regression this row catches. + // hot-seat viewer does. If a future redaction ever dropped the ∞ stores from the filtered + // clone, the broadcast path alone would go dark — remote players would lose the ∞ pile and + // rows while the local viewer kept them. That asymmetry is the regression this row catches. for viewer in [P0, P1, P2, PlayerId(3)] { let filtered = engine::game::visibility::filter_state_for_viewer(&state, viewer); let views = engine::game::derived_views::derive_filtered_views(&state, &filtered, Some(viewer)); assert!( !views.unbounded_resources.is_empty() && !views.unbounded_pile.is_empty(), - "CR 732.2c: the viewer-FILTERED broadcast path projects the same rows (viewer \ + "the viewer-FILTERED broadcast path projects the same rows (viewer \ {viewer:?}), got {:?} / {:?}", views.unbounded_resources, views.unbounded_pile ); } - // (4) THE STORE IS UNTOUCHED — the projection filtered, it did not mutate. + // (4) THE STORE IS UNTOUCHED — the projection read, it did not mutate. assert_eq!( state.unbounded_resources.get(&P0), Some(&marked), @@ -7296,38 +7288,33 @@ fn scheduled_collapse_still_renders_the_unbounded_badge_tagged() { ); } -/// R6a-3 (FAIL-CLOSED), under option (B). Two jobs, on ONE rig: +/// R6a-3 (FAIL-CLOSED), under option (B). ONE rig, TWO arms that fail on DIFFERENT wrong +/// implementations — the pair is what pins "the ∞ rows do not depend on the collapse schedule at +/// all", which is strictly stronger than either arm alone. /// -/// 1. POSITIVE CONTROL for the whole change — while the stash is present, a scheduled collapse -/// still PROJECTS its ∞ rows and the tag names exactly the axes the CR 500.5 boundary will -/// cash out. This is the load-bearing rows control: it is green under the pile-guard probe -/// (RP-1) and red under the row-guard probe (RP-1d), i.e. it discriminates in BOTH -/// directions, which is what lets the sibling test above assert pile-first. -/// 2. DISCRIMINATOR — after dropping the registrations, an ∞ axis that is merely *labellable* -/// must carry NO scheduled tag. `LoopCollapseAxis::from_resource_axis` maps `TokensCreated` / -/// `Counter(..)` / `Life(..)` to a label, so keying the tag on "does this axis have a collapse -/// label" is a one-liner that passes every other row in this file and mislabels an axis -/// nothing will ever collapse. +/// 1. PRE-CLEAR arm (stash PRESENT) — kills any STASH-KEYED hide filter. This is also the +/// load-bearing rows control for the sibling test above: it is green under the pile-guard probe +/// (RP-1) and red under the row-guard probe (RP-1d), i.e. it discriminates in BOTH directions, +/// which is what lets that test assert pile-first. +/// 2. POST-CLEAR arm (stash DROPPED, marks kept) — kills any LABELLABILITY-KEYED hide filter. +/// `LoopCollapseAxis::from_resource_axis` maps `TokensCreated` / `Counter(..)` / `Life(..)` to +/// a label, so "hide every axis that has a collapse label" is a one-liner that passes arm 1's +/// sibling rows and still hides an axis nothing will ever collapse. Arm 2 is the only row in +/// this file that reds it. /// -/// REVERT-PROBE (RP-4, RUN): build the tag by iterating **`state.unbounded_resources`** (which -/// survives the `.clear()`) and keeping axes matching +/// REVERT-PROBE (RP-1d, RUN): restore `if collapse_scheduled(controller, &axis) { continue; }` in +/// `derive_views`' resource-row loop ⇒ arm 1 FAILS (stash present ⇒ rows hidden) while arm 2 stays +/// green (stash cleared ⇒ nothing scheduled ⇒ rows project). That asymmetry is why both arms +/// exist. +/// +/// REVERT-PROBE (RP-4, RUN): hide rows matching /// `matches!(axis, ResourceAxis::TokensCreated | ResourceAxis::Counter(..) | ResourceAxis::Life(_))` /// — a *transcription* of `LoopCollapseAxis::from_resource_axis`'s three `Some` arms /// (`types/game_state.rs:11133/11136/11137`), inlined because that fn is a bare module-private /// `fn` (`:11131`) and `game::derived_views` is a sibling module, so calling it is /// `error[E0624]: associated function from_resource_axis is private`. Do not widen its -/// visibility. ⇒ the post-clear tag becomes 2-element ⇒ the emptiness assertion below FAILS while -/// the pre-clear positive control stays green. A probe that iterates the already-CLEARED stash -/// would be vacuous — this wording is load-bearing. -/// -/// REVERT-PROBE (RP-5, RUN): key the tag on `state.pending_materialization_count` -/// (`types/game_state.rs:13793`) instead of on the stash. That map is NOT cleared by clearing -/// `pending_unbounded_materialization`, and this is a stash-bearing `Fixed(200)` accept, so the -/// post-clear tag stays populated ⇒ the same emptiness assertion FAILS. -/// -/// The two closing `contains` assertions are a REACH-ECHO, not a discriminator: they pin that the -/// ∞ store survives the clear (which is what makes the tag-emptiness above meaningful). They no -/// longer discriminate any candidate implementation on their own. +/// visibility. ⇒ BOTH arms FAIL, and arm 2 is the one that is unreachable by any stash-keyed +/// probe, because the stash is already gone when it runs. #[test] fn unregistered_axis_still_renders_its_infinity_badge() { let mut state = r6a_offer_state(); @@ -7338,8 +7325,7 @@ fn unregistered_axis_still_renders_its_infinity_badge() { ); r6a_declare_and_accept_all(&mut state, P0, 200); - // Keep the marks, DROP the registrations: the exact shape of an axis that is - // collapsible-LABELLED but has nothing scheduled to collapse it. + // Both arms need the STORE marks present; arm 1 additionally needs the registration present. let marked = state .unbounded_resources .get(&P0) @@ -7349,35 +7335,26 @@ fn unregistered_axis_still_renders_its_infinity_badge() { marked.contains(&ResourceAxis::TokensCreated) && marked.contains(&ResourceAxis::Life(P0)), "reach-guard: both labellable axes are marked, got {marked:?}" ); - // POSITIVE CONTROL on the SAME state, BEFORE the drop. CR 732.2c: while the collapse is - // SCHEDULED the ∞ rows stay projected (the user-facing doctrine) and the tag names exactly the - // axes the CR 500.5 boundary will cash out. This pair is what makes the post-drop TAG-EMPTINESS - // below attributable to the missing registration alone. - let scheduled_views = engine::game::derived_views::derive_views(&state, None); - let tagged: Vec = scheduled_views - .scheduled_collapse - .iter() - .map(|r| r.axis) - .collect(); - assert!( - !scheduled_views.unbounded_resources.is_empty(), - "CR 732.2c: a scheduled collapse still PROJECTS its ∞ rows (option B), got {:?}", - scheduled_views.unbounded_resources - ); - assert!( - tagged.contains(&ResourceAxis::TokensCreated) && tagged.contains(&ResourceAxis::Life(P0)), - "both registered axes are TAGGED scheduled while the stash is present, got {tagged:?}" + assert_eq!( + state.pending_unbounded_materialization.len(), + 1, + "reach-guard for arm 1: the registration is present, so a stash-keyed filter would fire" ); - state.pending_unbounded_materialization.clear(); + // ARM 1, BEFORE the drop: while the collapse is merely SCHEDULED the ∞ rows stay projected. + let scheduled_rows = + engine::game::derived_views::derive_views(&state, None).unbounded_resources; + let scheduled_axes: Vec = scheduled_rows.iter().map(|r| r.axis).collect(); assert!( - engine::game::derived_views::derive_views(&state, None) - .scheduled_collapse - .is_empty(), - "FAIL-CLOSED: the tag is keyed on the REGISTRATION, not on labellability or on the accept — \ - with the stash dropped, two still-labellable ∞ axes must carry NO scheduled tag" + scheduled_axes.contains(&ResourceAxis::TokensCreated) + && scheduled_axes.contains(&ResourceAxis::Life(P0)), + "a merely-SCHEDULED collapse still projects both ∞ rows, got {scheduled_axes:?}" ); + // ARM 2: drop the registrations, keep the marks — an ∞ axis that is collapsible-LABELLED but + // has nothing scheduled to collapse it. + state.pending_unbounded_materialization.clear(); + let rows = engine::game::derived_views::derive_views(&state, None).unbounded_resources; let axes: Vec = rows.iter().map(|r| r.axis).collect(); assert!( diff --git a/crates/engine/tests/integration/loop_shortcut_mana_engine.rs b/crates/engine/tests/integration/loop_shortcut_mana_engine.rs index 6d95dbbe9e..eb84871dd0 100644 --- a/crates/engine/tests/integration/loop_shortcut_mana_engine.rs +++ b/crates/engine/tests/integration/loop_shortcut_mana_engine.rs @@ -815,19 +815,18 @@ fn cond_a_nontargeted_opponent_depletion_noops_at_exhaustion_not_abort() { /// true, user-visible and revert-detectable (RP-6 below) — it is simply no longer the thing /// that distinguishes candidate implementations, because option (B) projects EVERY ∞ row. /// -/// WHY THE "TAG MUST BE EMPTY HERE" RE-ANCHOR IS NOT ADDED: it would be VACUOUS on this rig. -/// Reach-guard (2) below already asserts `pending_unbounded_materialization` is EMPTY, and an -/// empty stash makes the tag empty for the CORRECT implementation as well as for every wrong -/// one (labellability-keyed, count-keyed — `pending_materialization_count` is empty here too, -/// asserted by the sibling `mana_engine_accept_records_no_collapse_bound` — and -/// `from_resource_axis(Mana(_))` is `None` anyway). The tag discrimination therefore lives on -/// rigs with non-Mana axes: `loop_shortcut::unregistered_axis_still_renders_its_infinity_badge` -/// (post-clear tag emptiness) and `scheduled_drive_still_renders_the_already_spendable_mana_badge` -/// below, whose ONE stash names both a `Mana(_)` and a deferred `Life(P0)`. +/// WHY IT NO LONGER DISCRIMINATES: reach-guard (2) below asserts `pending_unbounded_ +/// materialization` is EMPTY on this rig, so no schedule-keyed hide filter — stash-keyed or +/// count-keyed (`pending_materialization_count` is empty here too, asserted by the sibling +/// `mana_engine_accept_records_no_collapse_bound`) — could fire against this row anyway. The +/// schedule-independence discrimination therefore lives on rigs where a schedule IS present: +/// `loop_shortcut::unregistered_axis_still_renders_its_infinity_badge` and +/// `scheduled_drive_still_renders_the_already_spendable_mana_badge` below, whose ONE stash names +/// both a `Mana(_)` and a deferred `Life(P0)`. /// /// REVERT-PROBE (RP-6, RUN): append `views.unbounded_resources.clear();` at the END of /// `derive_views` (re-kill the row channel unconditionally) ⇒ this row FAILS while the ∞ PILE -/// assertion in `loop_shortcut::scheduled_collapse_still_renders_the_unbounded_badge_tagged` +/// assertion in `loop_shortcut::scheduled_collapse_still_renders_the_unbounded_badge` /// stays green — a different channel. /// /// The `shared_card_db()` guard below is DORMANT in a normal checkout: `integration_cards.json` @@ -1010,23 +1009,26 @@ fn mana_engine_accept_records_no_collapse_bound() { ); } -/// R6a FIX-ROUND-3 (CR 732.2c + CR 500.5), now the MULTI-AUTHORITY row for the scheduled-collapse -/// TAG. The `PersistentAxisMaterialization::DriveSequence` arm of `scheduled_collapse_axes` -/// returns the loop's WHOLE axis set (`collapsed_axes` == `proposal.unbounded`), so ONE stash -/// here names TWO axes: an already-materialized `Mana(Colorless)` and a deferred `Life(P0)`. The -/// tag must attach to exactly one of them. +/// R6a FIX-ROUND-3 (CR 500.5), now the MULTI-AXIS row: the +/// `PersistentAxisMaterialization::DriveSequence` arm of `scheduled_collapse_axes` returns the +/// loop's WHOLE axis set (`collapsed_axes` == `proposal.unbounded`), so ONE stash here names TWO +/// axes — an already-materialized `Mana(Colorless)` and a deferred `Life(P0)`. Both keep their ∞ +/// row while the collapse is merely scheduled, and they get there for DIFFERENT reasons, which is +/// what makes this the strongest rig in the file for the projection's schedule-independence. /// -/// That distinction is the class rule. `Tokens` / `Counters` / `Life` each name a DEFERRED -/// materialization — the growth is not on the board until the boundary applies it, so they are -/// exactly what "scheduled collapse" means. Mana is ALREADY MATERIALIZED at accept: +/// The `Life(P0)` axis is DEFERRED: no life has been gained, and none will be until the CR 500.5 +/// boundary applies the growth. That deferral is an engine deviation, pre-existing and deliberate, +/// and no CR licenses it — nothing here claims one does. What it means for the DISPLAY is only +/// that the mark and its enablers are still live through the window, so the ∞ renders current +/// engine state rather than a stale mark. +/// +/// The `Mana(Colorless)` axis is ALREADY MATERIALIZED at accept: /// `mana_payment::refill_infinite_mana` re-tops the flagged pool to `INFINITE_MANA_PER_TYPE` off /// `unbounded_resources` (the STORE, which the projection deliberately never filters) after every /// action, so throughout the accept→boundary window the player can really spend an unbounded -/// pool. Tagging it "scheduled collapse" would mislabel a live pool. CR 500.5 is what ends the -/// mana badge: the step/phase end drains the pool and +/// pool. CR 500.5 is what ends that badge: the step/phase end drains the pool and /// `turns::drain_pending_phase_transition_progress` clears the axis (covered by -/// `combo_infinite_pile`'s E4 mana axis-clear row, not re-proved here) — NOT a materialization, -/// which is the documented under-report in `DerivedViews::scheduled_collapse`'s scope limit. +/// `combo_infinite_pile`'s E4 mana axis-clear row, not re-proved here) — NOT a materialization. /// /// HONEST SCOPE. Everything except one write is real: real cards through the real parser, a real /// two-beat Basalt+Power period, a real `DeclareShortcut`/`RespondToShortcut` accept that marks @@ -1040,12 +1042,12 @@ fn mana_engine_accept_records_no_collapse_bound() { /// `proposal.unbounded.clone()` that production writes. Same graft technique as /// `combo_infinite_pile::real_4p_observed_drive_sequence_replays_captured_period_n_times`. /// -/// REVERT-PROBE (RP-3, RUN): delete the `axes.retain(|a| !matches!(a, ResourceAxis::Mana(_)))` -/// that builds `scheduled_axes` in `derive_views` ⇒ (6b)'s negative FAILS — the already-spendable -/// `Mana(Colorless)` axis gets tagged "scheduled collapse" while its pool is still being refilled. -/// (6b)'s positive (`Life(P0)` IS tagged) is the paired control that keeps the probe honest: a -/// blanket "tag nothing" is not a passing alternative. The retain is the SOLE cause of the -/// mana/deferred distinction. +/// REVERT-PROBE (RP-1d, RUN): restore `if collapse_scheduled(controller, &axis) { continue; }` in +/// `derive_views`' resource-row loop ⇒ (6) FAILS — `Life(P0)` is in the `DriveSequence`'s +/// `collapsed_axes`, so the restored guard hides its row. (5) is the paired control that keeps +/// the probe honest: BASE also carried an `axes.retain(|a| !matches!(a, ResourceAxis::Mana(_)))` +/// on the hide-set, so the mana row survived that guard and (5) stayed green — a blanket "hide +/// every scheduled axis" and a blanket "hide nothing" are distinguished by this pair. #[test] fn scheduled_drive_still_renders_the_already_spendable_mana_badge() { use engine::types::game_state::PersistentAxisMaterialization; @@ -1136,9 +1138,9 @@ fn scheduled_drive_still_renders_the_already_spendable_mana_badge() { }, ); - // (4) REACH-GUARD ON THE SEAM: the shared authority really does name the Mana axis, so the - // unfiltered hide-set WOULD have suppressed it. Without this, (5) could pass because the - // stash never reached the `DriveSequence` arm at all. + // (4) REACH-GUARD ON THE SEAM: the collapse authority really does name BOTH axes, so a + // schedule-keyed hide filter would have suppressed both rows below. Without this, (5) and (6) + // could pass because the stash never reached the `DriveSequence` arm at all. let state = rig.runner.state(); let scheduled = state.scheduled_collapse_axes( state @@ -1154,37 +1156,25 @@ fn scheduled_drive_still_renders_the_already_spendable_mana_badge() { ); for viewer in [None, Some(P0), Some(P1)] { - let views = engine::game::derived_views::derive_views(state, viewer); - let axes: Vec = views.unbounded_resources.iter().map(|r| r.axis).collect(); + let rows = engine::game::derived_views::derive_views(state, viewer).unbounded_resources; + let axes: Vec = rows.iter().map(|r| r.axis).collect(); // (5) the already-materialized mana axis keeps its ∞ row on the WIRE. assert!( axes.contains(&ResourceAxis::Mana(ManaType::Colorless)), - "CR 732.2c/CR 500.5: mana is already in the pool and still being refilled, so a \ + "CR 500.5: mana is already in the pool and still being refilled, so a \ merely-scheduled drive must NOT hide its ∞ row (viewer {viewer:?}), got {axes:?}" ); - // (6) under option (B) the DEFERRED axis of the same `DriveSequence` also keeps its ∞ row - // — nothing has been applied yet, so both rows project. The mana/deferred distinction the - // `axes.retain(!Mana(_))` draws now lives in the TAG below, not in the row set. + // (6) DISCRIMINATOR — the DEFERRED axis of the SAME `DriveSequence` also keeps its ∞ row. + // Nothing has been applied yet, so both rows project even though the collapse authority + // names both axes at (4). assert!( axes.contains(&ResourceAxis::Life(P0)), - "CR 732.2c: the deferred Life axis of the same scheduled drive still projects its ∞ \ + "the deferred Life axis of the same scheduled drive still projects its ∞ \ row while the collapse is merely scheduled (viewer {viewer:?}), got {axes:?}" ); - // (6b) DISCRIMINATOR — the tag separates the two axes of the SAME stash: the deferred one - // is tagged, the already-materialized one is not. - let tagged: Vec = views.scheduled_collapse.iter().map(|r| r.axis).collect(); - assert!( - tagged.contains(&ResourceAxis::Life(P0)), - "the DEFERRED axis of the scheduled drive is TAGGED, got {tagged:?}" - ); - assert!( - !tagged.contains(&ResourceAxis::Mana(ManaType::Colorless)), - "…and the ALREADY-MATERIALIZED mana axis of the SAME stash is NOT — this is where the \ - `axes.retain(!Mana(_))` earns its keep, got {tagged:?}" - ); } - // (7) THE STORE IS UNTOUCHED — the projection filtered, it did not mutate. The boundary + // (7) THE STORE IS UNTOUCHED — the projection read, it did not mutate. The boundary // clear still reads both axes from here. assert_eq!( state From cfe2b042f0c458011825b4d134dbd465d08d5082 Mon Sep 17 00:00:00 2001 From: lgray Date: Tue, 4 Aug 2026 20:40:59 -0500 Subject: [PATCH 3/9] fix(engine): stop citing CR 104.4b/110.1 for an engine bookkeeping invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The maintainer's remaining review finding: the `unbounded_resources` <-> `unbounded_loop_enablers` lockstep was documented as required by CR 104.4b / CR 110.1. Verified against the rules text, neither governs it — CR 104.4b is the mandatory-loop draw rule and CR 110.1 defines a permanent. The lockstep is an engine-state invariant held for exactly one consumer: `zones::apply_zone_exit_cleanup` reads the enabler map to defuse a capability whose enabler leaves, so a desynced store leaves that defuse unarmed. Swept the whole defect class rather than only the two sites this PR introduced. Of the four cited, two were mine and two predate the branch (9169d8f44); the same sentence also lived at five pre-existing sites in `game_state.rs` — including the one this PR copied. Fixing only the copies leaves the template. `zones.rs:559` is deliberately preserved: it cites CR 104.4b / CR 110.1 / CR 700.4 for the defuse *behaviour* — an enabling permanent leaving the battlefield — which CR 110.1 genuinely governs ("it stops being a permanent as it's moved to another zone"). Purging every occurrence would be the same defect mirrored; the remedy is to reserve citations for what each rule governs. Also corrects `derived_views.rs`, where under the heading "the two CRs this code does rely on, each for what it actually governs" the CR 500.5 bullet read "the boundary where the deferred growth is applied". CR 500.5 governs until-end-of- step expiry and mana-pool emptying; it is the timing landmark and the reason a `Mana(_)` infinity ends there, but it does not license cashing out deferred token/life/counter growth at that moment. That placement is part of the same uncited engine deviation the block already describes four lines above. No CR number is added: the added-line CR set is empty; the removed set is {CR 104.4b, CR 110.1}. The two `src` edits are comment-only (code lines cmp-identical once trailing comments are stripped, with a positive control); the two test edits change assert message strings only. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/derived_views.rs | 14 ++++++++++---- crates/engine/src/types/game_state.rs | 19 +++++++++++-------- .../engine/tests/integration/loop_shortcut.rs | 4 ++-- .../integration/loop_shortcut_mana_engine.rs | 2 +- 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/game/derived_views.rs b/crates/engine/src/game/derived_views.rs index f56aff1f61..becbee39b4 100644 --- a/crates/engine/src/game/derived_views.rs +++ b/crates/engine/src/game/derived_views.rs @@ -809,7 +809,12 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews // not EXCEED it. `turns.rs`' `max:` reads the recorded bound for exactly that reason and // `SubmitPayAmount` rejects an over-collapse. That is a CEILING on the collapse; it says // nothing about what the display may show, and this projection does not read it. - // • CR 500.5 — the boundary where the deferred growth is applied. + // • CR 500.5 — the TIMING LANDMARK only: it defines the step/phase end, at which + // until-end-of-step effects expire and unspent mana empties. That mana drain is the one + // thing here CR 500.5 genuinely governs (`turns::drain_pending_phase_transition_progress`, + // and it is why a `Mana(_)` ∞ ends there). It does NOT license CASHING OUT the deferred + // token/life/counter growth at that moment — the engine chose that landmark, and that + // choice is part of the same uncited deviation described above. // // WHY `∞` IS RIGHT HERE IS AN ENGINE-STATE ARGUMENT, NOT A RULES ONE. Throughout the window the // enablers are still on the battlefield and `unbounded_resources` + `unbounded_loop_enablers` @@ -826,9 +831,10 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews // The three loops below therefore read only their own stores; none consults // `GameState::scheduled_collapse_axes` (whose sole production caller is // `clear_collapsed_materializations`). The stores are not filtered either: - // `unbounded_resources` + `unbounded_loop_enablers` stay in CR 104.4b / CR 110.1 lockstep - // until the boundary applies the growth, which is what keeps `zones::apply_zone_exit_cleanup`'s - // defuse armed in the meantime. + // `unbounded_resources` + `unbounded_loop_enablers` are held in lockstep until the boundary + // applies the growth. That lockstep is an ENGINE-STATE invariant, required by no CR — it exists + // for exactly one consumer: `zones::apply_zone_exit_cleanup` reads the enabler map to defuse a + // capability whose enabler leaves, so a desynced store would leave that defuse unarmed. // // What ends each `∞` is the boundary, never this projection: // `clear_collapsed_materializations` drops the collapsed axes once the growth is applied, and diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index e69b661741..cec899dfa3 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -19739,7 +19739,8 @@ impl GameState { entry.extend(axes.iter().copied()); } - /// CR 104.4b / CR 110.1: single write authority for `unbounded_loop_enablers` — + /// Single write authority for `unbounded_loop_enablers` (an engine bookkeeping map; no CR + /// mandates it) — /// only the Interactive B5 bridge arm (`interactive_loop_bridge` Path C) calls /// this. Overwrites (idempotent re-registration each re-detection beat with the /// same stable board). A no-op for an empty set (nothing to defuse on later). @@ -19837,9 +19838,10 @@ impl GameState { /// the CR 500.5 step/phase end instead. /// /// Removing at the boundary rather than filtering the store meanwhile is load-bearing: - /// the store keeps `unbounded_resources` and `unbounded_loop_enablers` in CR 104.4b / - /// CR 110.1 lockstep, which is what `zones::apply_zone_exit_cleanup` reads to defuse a - /// capability whose enabler leaves between accept and boundary. + /// the store keeps `unbounded_resources` and `unbounded_loop_enablers` in lockstep — an + /// ENGINE-STATE invariant required by no CR, held for exactly one consumer: + /// `zones::apply_zone_exit_cleanup` reads the enabler map to defuse a capability whose + /// enabler leaves between accept and boundary. /// /// FAIL-CLOSED: only an axis some REGISTERED item actually collapses is returned, so an /// ∞ axis with no registration (a mana engine registers nothing) is never removed here — @@ -19904,7 +19906,7 @@ impl GameState { /// `DriveSequence` CAN name one (its `collapsed_axes` is the loop's whole `proposal.unbounded` /// set) and then removing it here is correct — that loop's mana really did end with it. /// Drops `unbounded_resources[controller]` - /// (and its `unbounded_loop_enablers` entry in CR 104.4b/CR 110.1 lockstep, mirroring + /// (and its `unbounded_loop_enablers` entry in engine-state lockstep, mirroring /// `clear_unbounded_mana_loop`) only when its axis set becomes empty. Always removes /// the whole `pending_unbounded_materialization` list (owned by `take_` at the submit /// site). Leaves `clear_unbounded_mana_loop` / `clear_unbounded_loop` untouched. @@ -19976,7 +19978,7 @@ impl GameState { axes.retain(|a| !axes_to_remove.contains(a)); if axes.is_empty() { self.unbounded_resources.remove(&controller); - self.unbounded_loop_enablers.remove(&controller); // CR 104.4b / CR 110.1 lockstep + self.unbounded_loop_enablers.remove(&controller); // engine-state lockstep } } self.pending_unbounded_materialization.remove(&controller); @@ -19986,7 +19988,8 @@ impl GameState { /// CR 500.5 + CR 106.4: end a loop-backed ∞-mana capability at a step/phase boundary — an /// AXIS-SCOPED clear, not the whole-player `clear_unbounded_loop`. Removes every /// `ResourceAxis::Mana(_)` axis from `unbounded_resources`. If that empties the player's axis - /// set, drop the player key AND its `unbounded_loop_enablers` entry IN LOCKSTEP (CR 104.4b / CR 110.1): + /// set, drop the player key AND its `unbounded_loop_enablers` entry IN LOCKSTEP (an engine-state + /// invariant, not a rules requirement): /// enablers track the PRESENCE of any unbounded axis, and the `zones.rs` defuse hook /// (`apply_zone_exit_cleanup`, `:534`–`:544`) whole-clears a controller's capability when ANY /// enabler leaves. Leaving enablers orphaned (no backing axis) is a landmine — a later @@ -20001,7 +20004,7 @@ impl GameState { axes.retain(|a| !matches!(a, ResourceAxis::Mana(_))); if axes.is_empty() { self.unbounded_resources.remove(&controller); - self.unbounded_loop_enablers.remove(&controller); // CR 104.4b / CR 110.1 lockstep-iff-empty + self.unbounded_loop_enablers.remove(&controller); // engine-state lockstep-iff-empty } } } diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index 9aeb2bd06d..1d9a25816d 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -7153,7 +7153,7 @@ fn r6a_drive_to_boundary(state: &mut GameState) { /// mark the store still carries, which is what the BASE gate used it for. /// /// NEVER HIDE — and never filter the store either. The store must still carry the mark -/// (CR 104.4b / CR 110.1 lockstep and `zones::apply_zone_exit_cleanup`'s defuse read it until +/// (the engine-state enabler lockstep and `zones::apply_zone_exit_cleanup`'s defuse read it until /// the boundary applies the growth), so this row asserts store AND wire. /// /// NON-VACUITY: every wire assertion here is a NON-emptiness paired with the store's own @@ -7279,7 +7279,7 @@ fn scheduled_collapse_still_renders_the_unbounded_badge() { assert_eq!( state.unbounded_resources.get(&P0), Some(&marked), - "the ∞ store must survive the projection (CR 104.4b / CR 110.1 lockstep + the \ + "the ∞ store must survive the projection (the engine-state enabler lockstep + the \ zone-exit defuse still need it until the boundary)" ); assert!( diff --git a/crates/engine/tests/integration/loop_shortcut_mana_engine.rs b/crates/engine/tests/integration/loop_shortcut_mana_engine.rs index eb84871dd0..c5be7290c9 100644 --- a/crates/engine/tests/integration/loop_shortcut_mana_engine.rs +++ b/crates/engine/tests/integration/loop_shortcut_mana_engine.rs @@ -1182,6 +1182,6 @@ fn scheduled_drive_still_renders_the_already_spendable_mana_badge() { .get(&P0) .map(|a| a.iter().copied().collect::>()), Some(collapsed_axes), - "the ∞ store survives the projection (CR 104.4b / CR 110.1 lockstep)" + "the ∞ store survives the projection (engine-state enabler lockstep)" ); } From 6e486060e21739cd34a017beec6ac3d16caec650 Mon Sep 17 00:00:00 2001 From: lgray Date: Tue, 4 Aug 2026 22:18:57 -0500 Subject: [PATCH 4/9] fix(engine): drop the false revocability claim and make the R6a rows discriminate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three maintainer findings on the current head. (1) The projection claimed the displayed ∞ capability was revocable via `zones::apply_zone_exit_cleanup`. It is not, for this class. That defuse is gated on a non-empty `unbounded_loop_enablers`, and the only production writer of that map is the Interactive Path-C arm; `materialize_object_growth_shortcut` never registers enablers, so the gate never matches an object-growth mark — i.e. never for the token and counter families this projection exists to display. `engine_resolution_choices.rs` already documents that gap in those words and tracks it as a pre-existing deferred follow-up. The comment now says what the projection does NOT claim, and discloses the consequence rather than burying it: because the defuse is inert here, an enabler leaving between accept and boundary leaves a stale ∞ that this projection now renders where the previous gate happened to hide it. That is a pre-existing engine gap surfaced by a display fix. The lockstep sentence is downgraded to match — for this class the enabler map is empty, so the invariant is vacuously satisfied and is load-bearing only for the Path-C class that populates it. (2) Two assertions in the R6a test did not discriminate. `life > 0` also passes after materialization, so it could not pin the unmaterialized state its own comment claimed; it now captures a baseline before the accept and asserts equality. The per-viewer filtered-broadcast row asserted only non-emptiness, so a partial projection that dropped an axis or a pile member still passed; it now asserts the exact axis set and the full pile membership, both derived from the store so the expectation cannot drift from what the accept wrote, plus a control that the expectations are themselves non-trivial. Each strengthened assertion is proven discriminating by an isolated mutant that reds it by name: redacting one axis from the filtered clone only (left {Life(P0)} vs right {Life(P0), TokensCreated}) — deliberately mutating `filter_state_for_viewer` rather than the shared projection, so the pre-existing unfiltered assertion cannot dominate and mask the new row; dropping one pile member while leaving the pile non-empty; and simulating premature materialization (746 vs 546). No production behaviour changes: the engine delta is comment-only. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/derived_views.rs | 34 ++++++++---- .../engine/tests/integration/loop_shortcut.rs | 54 +++++++++++++++---- 2 files changed, 68 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/game/derived_views.rs b/crates/engine/src/game/derived_views.rs index becbee39b4..44fe19f0a5 100644 --- a/crates/engine/src/game/derived_views.rs +++ b/crates/engine/src/game/derived_views.rs @@ -817,10 +817,26 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews // choice is part of the same uncited deviation described above. // // WHY `∞` IS RIGHT HERE IS AN ENGINE-STATE ARGUMENT, NOT A RULES ONE. Throughout the window the - // enablers are still on the battlefield and `unbounded_resources` + `unbounded_loop_enablers` - // are deliberately held in lockstep (below), so the controller really does still hold a set of - // actions that could be repeated indefinitely — a CR-732.1b-SHAPED capability, which is the - // same sense the rest of this crate cites CR 732.1b in. `∞` renders that live mark honestly. + // loop's enabling permanents are still on the battlefield and `unbounded_resources` still + // carries the mark, so the controller really does still hold a set of actions that could be + // repeated indefinitely — a CR-732.1b-SHAPED capability, which is the same sense the rest of + // this crate cites CR 732.1b in. `∞` renders that live mark honestly. + // + // WHAT THIS PROJECTION DOES **NOT** CLAIM: that the mark is REVOCABLE for this class. The + // zone-exit defuse (`zones::apply_zone_exit_cleanup`) is gated on a NON-EMPTY + // `unbounded_loop_enablers`, and the only production writer of that map is the Interactive + // Path-C arm (`engine.rs`'s `register_unbounded_loop_enablers` call). + // `materialize_object_growth_shortcut` never registers enablers, so for the OBJECT-GROWTH class + // — which is exactly the token and counter families this projection displays — the defuse gate + // never matches and is INERT. `engine_resolution_choices.rs` documents that gap in those words + // and tracks it as a pre-existing deferred follow-up; it is not introduced here. + // + // CONSEQUENCE, STATED RATHER THAN BURIED: because that defuse is inert for this class, an + // enabler leaving the battlefield between accept and boundary leaves a STALE `∞` in the store, + // and this projection now RENDERS it where the previous gate happened to HIDE it. That is a + // pre-existing engine gap being SURFACED by a display fix, not a new defect — but it is a real + // user-visible consequence of unhiding, and it is the reason the enabler-registration follow-up + // matters. What actually ends the mark for this class is the boundary, below. // // And hiding it is strictly worse on display coherence, which is what the old "the badge is a // lie" comment was really about. The BASE gate filtered the PROJECTION while the STORE still @@ -830,11 +846,11 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews // // The three loops below therefore read only their own stores; none consults // `GameState::scheduled_collapse_axes` (whose sole production caller is - // `clear_collapsed_materializations`). The stores are not filtered either: - // `unbounded_resources` + `unbounded_loop_enablers` are held in lockstep until the boundary - // applies the growth. That lockstep is an ENGINE-STATE invariant, required by no CR — it exists - // for exactly one consumer: `zones::apply_zone_exit_cleanup` reads the enabler map to defuse a - // capability whose enabler leaves, so a desynced store would leave that defuse unarmed. + // `clear_collapsed_materializations`). The stores are not filtered either: `unbounded_resources` + // keeps the mark until the boundary applies the growth. (`unbounded_loop_enablers` is held in + // lockstep with it as an ENGINE-STATE invariant required by no CR — but see the inertness note + // above: for the object-growth class that map is EMPTY, so the lockstep is vacuously satisfied + // here and is load-bearing only for the Interactive Path-C class that populates it.) // // What ends each `∞` is the boundary, never this projection: // `clear_collapsed_materializations` drops the collapsed axes once the growth is applied, and diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index 1d9a25816d..14ec883857 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -7182,6 +7182,11 @@ fn scheduled_collapse_still_renders_the_unbounded_badge() { state.waiting_for ); + // BASELINE, captured BEFORE the accept so the unmaterialized claim below is falsifiable. + // A `life > 0` assertion would also pass AFTER materialization (200 accepted gains would leave + // life well above 0), so it could not distinguish the state this test exists to pin. + let life_before = state.players.iter().find(|p| p.id == P0).unwrap().life; + r6a_declare_and_accept_all(&mut state, P0, 200); // (1) POSITIVE CONTROL — the accept really marked the ∞ axes in the STORE. Without @@ -7208,12 +7213,16 @@ fn scheduled_collapse_still_renders_the_unbounded_badge() { 1, "exactly one controller has a scheduled collapse" ); - // The growth is UNMATERIALIZED: the accepted count has not been applied, so P0's life is still - // a concrete number. The ∞ row beside it reports the live loop mark, not the current total. + // The growth is UNMATERIALIZED: the accepted count has not been applied, so P0's life is + // EXACTLY what it was before the accept. The ∞ row beside it reports the live loop mark, not + // the current total. Asserting EQUALITY against the pre-accept baseline (not `> 0`) is what + // makes this row discriminating: a premature materialization of the accepted 200 Life(P0) + // gains moves this number and reds the row, whereas `life > 0` survives it. let life = state.players.iter().find(|p| p.id == P0).unwrap().life; - assert!( - life > 0, - "the ∞-badged axis still shows an unmaterialized, finite life total, got {life}" + assert_eq!( + life, life_before, + "the ∞-badged Life(P0) axis must be UNMATERIALIZED at this point — life must equal its \ + pre-accept baseline, got {life} vs {life_before}" ); // (2) FAIL-CLOSED CONTROL, in the SAME state: every ∞ axis the accept scheduled is @@ -7262,16 +7271,39 @@ fn scheduled_collapse_still_renders_the_unbounded_badge() { // hot-seat viewer does. If a future redaction ever dropped the ∞ stores from the filtered // clone, the broadcast path alone would go dark — remote players would lose the ∞ pile and // rows while the local viewer kept them. That asymmetry is the regression this row catches. + // EXACT MEMBERSHIP, not non-emptiness. A non-empty check passes a PARTIAL projection that + // drops one of the two axes or loses pile members, which is precisely the regression described + // above. These rows pin the SET and the FULL membership, both compared against the store so the + // expectation cannot drift away from what the accept actually wrote. + let expected_axes: std::collections::BTreeSet = marked.iter().copied().collect(); + let expected_pile: std::collections::BTreeSet = state + .unbounded_loop_pile + .values() + .flat_map(|ids| ids.iter().copied()) + .collect(); + assert!( + expected_axes.len() >= 2 && !expected_pile.is_empty(), + "control: the expectations themselves must be non-trivial, got {expected_axes:?} / \ + {} pile members", + expected_pile.len() + ); for viewer in [P0, P1, P2, PlayerId(3)] { let filtered = engine::game::visibility::filter_state_for_viewer(&state, viewer); let views = engine::game::derived_views::derive_filtered_views(&state, &filtered, Some(viewer)); - assert!( - !views.unbounded_resources.is_empty() && !views.unbounded_pile.is_empty(), - "the viewer-FILTERED broadcast path projects the same rows (viewer \ - {viewer:?}), got {:?} / {:?}", - views.unbounded_resources, - views.unbounded_pile + let got_axes: std::collections::BTreeSet = + views.unbounded_resources.iter().map(|r| r.axis).collect(); + assert_eq!( + got_axes, expected_axes, + "the viewer-FILTERED broadcast path must project EVERY marked ∞ axis, not merely some \ + (viewer {viewer:?})" + ); + let got_pile: std::collections::BTreeSet = + views.unbounded_pile.iter().copied().collect(); + assert_eq!( + got_pile, expected_pile, + "the viewer-FILTERED broadcast path must project the FULL ∞ pile membership \ + (viewer {viewer:?})" ); } From 50d7ca4b09bd2ec6c1fffcd12f5944160942b273 Mon Sep 17 00:00:00 2001 From: lgray Date: Wed, 5 Aug 2026 03:25:21 -0500 Subject: [PATCH 5/9] =?UTF-8?q?fix(engine):=20revoke=20a=20dead=20object-g?= =?UTF-8?q?rowth=20=E2=88=9E=20badge=20at=20the=20projection,=20not=20the?= =?UTF-8?q?=20defuse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An accepted object-growth loop shortcut marks `unbounded_resources` and registers its display backing (`unbounded_loop_pile` / `unbounded_counter_targets`). The pile and counter-pill projections already drop members that have left the battlefield, but the resource-row loop had no liveness check at all — so once every backing member was gone the HUD kept rendering an `∞` badge beside an already-empty `∞` pile. The obvious fix, registering the loop's enablers so `zones::apply_zone_exit_cleanup`'s defuse fires, is the wrong one here. That defuse calls `clear_unbounded_loop`, which is not a display clear: it drops six per-controller maps, including `pending_unbounded_materialization` and `pending_materialization_count`. The boundary prompt requires a non-empty stash, so one dying Saproling would silently cancel growth the whole table had already accepted — and CR 732.2c has the shortcut taken the moment the last player accepts, so that is a rules regression bought to fix a display bug. The liveness authority therefore lives at the projection. `object_growth_backing` answers "does this axis' registered display set still have a member on the battlefield" (CR 110.1), and the row loop drops the row only on `Some(false)`. The `Option` carries a distinction a bool would erase: `Some(false)` is a registered backing that is now entirely off-battlefield; `None` is an axis that never registered one (a mana engine; an untapped-growth loop whose empty pile seed early-returns), and it keeps its badge. The match is exhaustive so a future `ResourceAxis` must pick a side rather than silently inherit "unbacked". Nothing on the write path changes — the stores stay unfiltered because the boundary collapse and the defuse both still read them. Two stale claims that made this design hard to see are corrected in the same change. `zones.rs` said `clear_unbounded_loop` "removes BOTH maps in lockstep" when it removes six, and the unit test pinning that lockstep was named `..._removes_both_maps_...` while asserting three. It is renamed to `..._removes_all_six_maps_in_lockstep` and extended to cover the counter targets, the accepted-collapse stash and the CR 732.2c bound; deleting the stash removal from `clear_unbounded_loop` now reds it, which it did not before. `engine_resolution_choices.rs`' follow-up-F2 note is updated to say the defuse stays inert deliberately and where the display half now lives. Mutation matrix, measured over the 164-test loop/∞ blast radius: drop the row guard → subject arm reds 1 failed / 163 passed row guard → unconditional `continue` → control arm reds 8 failed / 156 passed never-registered arm → `Some(false)` → 4 failed, incl. both mana-engine badge tests drop the stash removal from `clear_unbounded_loop` → renamed unit test reds, 0 collateral Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/derived_views.rs | 89 +++++++- .../src/game/engine_resolution_choices.rs | 18 +- crates/engine/src/game/zones.rs | 4 +- crates/engine/src/types/game_state.rs | 54 ++++- .../tests/integration/combo_infinite_pile.rs | 213 ++++++++++++++++++ 5 files changed, 359 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/game/derived_views.rs b/crates/engine/src/game/derived_views.rs index 44fe19f0a5..caf1851b23 100644 --- a/crates/engine/src/game/derived_views.rs +++ b/crates/engine/src/game/derived_views.rs @@ -832,11 +832,16 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews // and tracks it as a pre-existing deferred follow-up; it is not introduced here. // // CONSEQUENCE, STATED RATHER THAN BURIED: because that defuse is inert for this class, an - // enabler leaving the battlefield between accept and boundary leaves a STALE `∞` in the store, - // and this projection now RENDERS it where the previous gate happened to HIDE it. That is a - // pre-existing engine gap being SURFACED by a display fix, not a new defect — but it is a real - // user-visible consequence of unhiding, and it is the reason the enabler-registration follow-up - // matters. What actually ends the mark for this class is the boundary, below. + // enabler leaving the battlefield between accept and boundary leaves a STALE `∞` in the STORE. + // The store is deliberately NOT filtered (the defuse and the boundary both read it), so the + // live-authority check lives HERE, at the projection: `object_growth_backing` drops a row whose + // whole registered display set has left the battlefield, exactly as the pile and counter loops + // already drop individual departed members. That is a DISPLAY revocation only — it never + // touches `pending_unbounded_materialization`, so the growth the table accepted still lands. + // Registering enablers instead would route this through `clear_unbounded_loop`, a SIX-map wipe + // that also destroys the accepted collapse stash and its CR 732.2c bound; see + // `types::game_state::clear_unbounded_loop`. What ends the MARK for this class is still the + // boundary, below. // // And hiding it is strictly worse on display coherence, which is what the old "the badge is a // lie" comment was really about. The BASE gate filtered the PROJECTION while the STORE still @@ -864,6 +869,14 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews // (`attribution_player`); the frontend only formats each axis to a family. for (&controller, axes) in &state.unbounded_resources { for &axis in axes { + // CR 732.2a + CR 110.1: an object-growth ∞ whose ENTIRE registered display set + // has left the battlefield has no live board backing left — drop the row rather + // than render an ∞ beside an already-empty ∞ pile. `None` (never registered a + // backing set, e.g. a mana engine) keeps the badge; see `object_growth_backing` + // for why that asymmetry is typed rather than collapsed into a bool. + if object_growth_backing(state, controller, axis) == Some(false) { + continue; + } views.unbounded_resources.push(UnboundedResourceView { player: attribution_player(axis, controller), axis, @@ -1075,6 +1088,72 @@ fn attribution_player(axis: ResourceAxis, controller: PlayerId) -> PlayerId { } } +/// CR 732.2a: whether the object-growth `∞` display set the accept registered for `axis` +/// still has LIVE authority — i.e. at least one registered member is still on the +/// battlefield (CR 110.1: a permanent stops being one as it moves to another zone). +/// +/// The `Option` is the whole point, and the two negative answers are NOT the same thing: +/// +/// - `Some(false)` = the axis HAS a registered board backing and every member of it has +/// left the battlefield. The `∞` has no live authority behind it ⇒ **drop the row**, +/// rather than render an `∞` badge beside an already-empty `∞` pile. +/// - `None` = the axis NEVER registered a backing set. A mana engine registers no pile at +/// all, and an untapped-growth loop's pile seed is a no-op on an empty set +/// (`register_unbounded_loop_pile` early-returns). There is no live authority to consult +/// ⇒ **badge unchanged**. Collapsing this into a `bool` would silently hide every +/// unbacked `∞`, which is the opposite of the intended fix. +/// +/// This is the SINGLE authority for "is this object-growth display set still on the board". +/// The pile and counter-target loops in `derive_views` apply the same +/// `state.battlefield.contains` test at MEMBER level; this is its SET-level closure, so the +/// row, the pile and the counter pill can never disagree about the same board. +/// +/// Read-only: recomputed from live state on every `derive_views` call, nothing is stored, +/// so nothing can go stale. Deliberately not a `clear_unbounded_loop` from the zone-exit +/// defuse — that call drops six maps including `pending_unbounded_materialization` and its +/// CR 732.2c bound, i.e. it would cancel growth the table has already unanimously accepted +/// ("the shortcut is taken" the moment the last player accepts). Revoking the BADGE is a +/// display decision; revoking the agreed GROWTH is not ours to make here. +fn object_growth_backing( + state: &GameState, + controller: PlayerId, + axis: ResourceAxis, +) -> Option { + match axis { + // The ∞ pile IS the registered backing set for the token axis + // (`register_unbounded_loop_pile`, written at accept by + // `materialize_object_growth_shortcut`). + ResourceAxis::TokensCreated => state + .unbounded_loop_pile + .get(&controller) + .map(|pile| pile.iter().any(|id| state.battlefield.contains(id))), + // CR 701.34a: the per-object Generic counter targets the loop proliferates each + // cycle are the counter axis' registered backing. + ResourceAxis::Counter(..) => state + .unbounded_counter_targets + .get(&controller) + .map(|targets| targets.iter().any(|(id, _)| state.battlefield.contains(id))), + // No registered board backing exists for these axes — no live authority to consult, + // badge unchanged. Exhaustive on purpose: a future ResourceAxis variant must decide + // which side it lands on rather than silently defaulting to "unbacked". + ResourceAxis::Mana(_) + | ResourceAxis::Life(_) + | ResourceAxis::DamageDealt(_) + | ResourceAxis::LibraryDelta(_) + | ResourceAxis::Poison(_) + | ResourceAxis::Trigger(_) + | ResourceAxis::CardsDrawn + | ResourceAxis::Casts + | ResourceAxis::LandfallTriggers + | ResourceAxis::CombatPhases + | ResourceAxis::ExtraTurns + | ResourceAxis::DeathTriggers + | ResourceAxis::EtbTriggers + | ResourceAxis::LtbTriggers + | ResourceAxis::SacTriggers => None, + } +} + /// Aggregate player-affecting conditions into render-ready rows. /// /// Two sources, neither of which introduces new game logic: diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index c1fad005ac..b434a6b5eb 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -2409,13 +2409,17 @@ pub(super) fn handle_resolution_choice( // ∞-gated, so a fresh manual re-loop re-offers and re-registers a stash; and // (b) debug toggle-off — `clear_unbounded_loop` via `engine_debug.rs:417`. // NOTE: the enabler-departure clear (`clear_unbounded_loop` from - // `zones.rs:544-554`) is INERT for this object-growth ∞-mark class, because - // `materialize_object_growth_shortcut` (engine.rs) never calls - // `register_unbounded_loop_enablers` (only the Interactive Path-C arm at - // engine.rs:682 does), so `zones.rs`'s `unbounded_loop_enablers.contains(id)` - // gate never matches an object-growth mark. Registering enablers for the - // object-growth path is a PRE-EXISTING, broader gap (deferred follow-up F2), not - // introduced by this declined-axis handling. + // `zones::apply_zone_exit_cleanup`) is INERT for this object-growth ∞-mark + // class, because `materialize_object_growth_shortcut` (engine.rs) never calls + // `register_unbounded_loop_enablers` (only the Interactive Path-C arm does), so + // `zones.rs`'s `unbounded_loop_enablers.contains(id)` gate never matches an + // object-growth mark. It STAYS inert deliberately: `clear_unbounded_loop` drops + // SIX maps including `pending_unbounded_materialization`, so registering + // enablers here would let one departing token cancel the collapse the table + // unanimously accepted (CR 732.2c: the shortcut is taken at the last accept). + // The DISPLAY half of follow-up F2 is instead covered live at the projection by + // `derived_views::object_growth_backing`, which drops an ∞ row whose entire + // registered display set has left the battlefield without touching the stash. state.clear_collapsed_materializations(player, &collapsed); // Continue the boundary fixpoint (§7): re-draining either prompts the // next APNAP player with a stash or restores Priority now. diff --git a/crates/engine/src/game/zones.rs b/crates/engine/src/game/zones.rs index b842105e49..932a762af4 100644 --- a/crates/engine/src/game/zones.rs +++ b/crates/engine/src/game/zones.rs @@ -561,8 +561,8 @@ pub(crate) fn apply_zone_exit_cleanup( // enabled (every-enabler: `interactive_loop_bridge` Path C). Gated on a // non-empty enabler map so Off/On games (which never populate it — only the // Interactive B5 arm does) pay nothing and stay byte-identical. Whole- - // capability clear per controller whose enabler set contains this object - // (`clear_unbounded_loop` removes BOTH maps in lockstep). + // capability clear per controller whose enabler set contains this object: + // `clear_unbounded_loop` drops SIX maps, incl. the accepted-collapse stash. if !state.unbounded_loop_enablers.is_empty() { let revoked: Vec = state .unbounded_loop_enablers diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index cec899dfa3..a935bf2cd0 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -23442,12 +23442,21 @@ mod tests { ); } - /// PR-7 Phase 4c (B5 defuse): `clear_unbounded_loop` must remove ALL THREE - /// `unbounded_resources` / `unbounded_loop_enablers` / `unbounded_loop_pile` - /// maps for the controller in lockstep — the `zones.rs` defuse hook relies on - /// a single call revoking the whole capability. + /// PR-7 Phase 4c (B5 defuse): `clear_unbounded_loop` must remove ALL SIX per-controller + /// maps in lockstep — `unbounded_resources` / `unbounded_loop_enablers` / + /// `unbounded_loop_pile` / `unbounded_counter_targets` / + /// `pending_unbounded_materialization` / `pending_materialization_count`. The `zones.rs` + /// defuse hook relies on a single call revoking the whole capability. + /// + /// The last two are why this call is NOT a display clear and must never be wired to an + /// object-growth mark: dropping the stash and its CR 732.2c bound cancels growth every + /// player already accepted. Anything that only wants to stop RENDERING an ∞ belongs at + /// the projection (`derived_views::object_growth_backing`), not here. + /// + /// (Renamed from `..._removes_both_maps_in_lockstep`: the old name said TWO and the old + /// body asserted THREE, while the function has cleared six since the stash landed.) #[test] - fn clear_unbounded_loop_removes_both_maps_in_lockstep() { + fn clear_unbounded_loop_removes_all_six_maps_in_lockstep() { let mut state = GameState::new_two_player(7); state.mark_unbounded_loop( PlayerId(0), @@ -23455,9 +23464,28 @@ mod tests { ); state.register_unbounded_loop_enablers(PlayerId(0), BTreeSet::from([ObjectId(1)])); state.register_unbounded_loop_pile(PlayerId(0), BTreeSet::from([ObjectId(1)])); + state.register_unbounded_counter_targets( + PlayerId(0), + vec![(ObjectId(1), CounterType::Generic("charge".to_string()))], + ); + state.register_pending_materialization( + PlayerId(0), + PersistentAxisMaterialization::Life { + player: PlayerId(0), + per_cycle_delta: 1, + }, + ); + state.pending_materialization_count.insert(PlayerId(0), 7); assert!(state.unbounded_resources.contains_key(&PlayerId(0))); assert!(state.unbounded_loop_enablers.contains_key(&PlayerId(0))); assert!(state.unbounded_loop_pile.contains_key(&PlayerId(0))); + assert!(state.unbounded_counter_targets.contains_key(&PlayerId(0))); + assert!(state + .pending_unbounded_materialization + .contains_key(&PlayerId(0))); + assert!(state + .pending_materialization_count + .contains_key(&PlayerId(0))); state.clear_unbounded_loop(PlayerId(0)); @@ -23473,6 +23501,22 @@ mod tests { !state.unbounded_loop_pile.contains_key(&PlayerId(0)), "clear_unbounded_loop must remove the unbounded_loop_pile entry" ); + assert!( + !state.unbounded_counter_targets.contains_key(&PlayerId(0)), + "clear_unbounded_loop must remove the unbounded_counter_targets entry" + ); + assert!( + !state + .pending_unbounded_materialization + .contains_key(&PlayerId(0)), + "clear_unbounded_loop must remove the accepted-collapse stash entry" + ); + assert!( + !state + .pending_materialization_count + .contains_key(&PlayerId(0)), + "clear_unbounded_loop must remove the CR 732.2c accepted-count bound" + ); } /// PR-7 v4 (CR 732.2a): the deferred-materialization stash round-trips through serde diff --git a/crates/engine/tests/integration/combo_infinite_pile.rs b/crates/engine/tests/integration/combo_infinite_pile.rs index d14c1bf558..51f9654e10 100644 --- a/crates/engine/tests/integration/combo_infinite_pile.rs +++ b/crates/engine/tests/integration/combo_infinite_pile.rs @@ -1548,6 +1548,219 @@ fn real_4p_one_shot_bootstrap_seeds_tapped_infinite_pile_and_w_plus_1_untapped() ); } +/// The one-shot-bootstrap rig driven to the ACCEPTED state: the real buyback+convoke Sprout +/// Swarm cast (convoking the one-shot Witherbloom for the {G}) → CR 732.2a offer → APNAP +/// accept of `Fixed(5)`. The two setup mutations are the same rules-neutral ones +/// `real_4p_one_shot_bootstrap_seeds_tapped_infinite_pile_and_w_plus_1_untapped` documents in +/// full (untap 405 so ZERO tapped fodder exists; Green-first Witherbloom so +/// `GameRunner::convoke_with` picks a colour the engine already pip-matches). +/// +/// This rig is chosen for the ∞-row backing arms below because its accept-time seed makes the +/// pile provably ONE object, so "the last backing member leaves" is a single `move_to_zone`. +fn one_shot_bootstrap_accepted_state() -> GameState { + let mut state: GameState = serde_json::from_str(&UNTAPPED_PRECAST_STATE) + .expect("the real untapped-precast 4p dump must deserialize into the current GameState"); + state + .objects + .get_mut(&ObjectId(405)) + .expect("fixture carries Saproling 405") + .tapped = false; + { + let w = state + .objects + .get_mut(&ObjectId(401)) + .expect("fixture carries Witherbloom 401"); + w.color = vec![ManaColor::Green, ManaColor::Black]; + w.base_color = vec![ManaColor::Green, ManaColor::Black]; + } + let mut runner = GameRunner::from_state(state); + let outcome = runner + .cast(ObjectId(402)) + .accept_optional() + .convoke_with(&[ObjectId(401)]) + .commit() + .resolve(); + assert!( + matches!( + outcome.final_waiting_for(), + WaitingFor::LoopShortcut { proposer, .. } if *proposer == P0 + ), + "rig reach-guard: the convoked recast must surface P0's CR 732.2a offer, got {:?}", + outcome.final_waiting_for() + ); + drive_all_accept_n(runner.state_mut(), 5); + runner.state().clone() +} + +/// MED-1 (CR 732.2a + CR 110.1): an object-growth `∞` ROW dies with its registered backing. +/// +/// ONE rig, TWO arms, THE SAME assertion — `derive_views(..).unbounded_resources` contains +/// `ResourceAxis::TokensCreated`: +/// +/// | arm (in run order) | what leaves the battlefield | THE assertion | +/// |--------------------|-----------------------------------|---------------| +/// | control | a non-pile untapped Saproling | **present** | +/// | subject | the pile's ONLY member (the seed) | **absent** | +/// +/// The control is the matched pair, not a second scenario: same fixture, same cast, same +/// accept, same `move_to_zone` chokepoint, differing only in WHICH object departs. That is +/// what makes the subject arm's absence attributable to the backing check rather than to the +/// zone move. It runs FIRST on purpose — see the comment at that arm. +/// +/// MUTATIONS (two-sided, RUN): +/// - **DROP** the `object_growth_backing(..) == Some(false)` guard in `derive_views`' resource +/// row loop ⇒ the SUBJECT arm reds ("…must be dropped, got [TokensCreated]" — the pre-fix +/// behaviour: an ∞ row beside an already-empty ∞ pile); the control stays green, and no +/// other test in the loop/∞ blast radius moves (1 failure / 164). +/// - **TRIVIALIZE** that guard to an unconditional `continue` ⇒ the CONTROL arm reds ("…must +/// persist, got []"); the subject arm's own assertions still pass. Collateral is 7 further +/// ∞-row-presence tests (8 failed / 156 passed), which is correct: hiding every row breaks +/// every test that asserts one is shown. +/// - Third probe, for the `Some(false)`/`None` asymmetry the helper's doc comment claims: +/// return `Some(false)` from `object_growth_backing`'s never-registered arm ⇒ 4 tests red, +/// including both `loop_shortcut_mana_engine` badge tests. The `None` branch is load-bearing, +/// not decorative. +/// +/// The store guards below are the anti-"register enablers instead" tripwire: routing this +/// through `zones`' defuse would call `clear_unbounded_loop`, which also wipes +/// `pending_unbounded_materialization` and its CR 732.2c bound — i.e. one dying token would +/// cancel the collapse the whole table accepted. These rows go red the moment that happens. +#[test] +fn object_growth_infinity_row_dies_with_its_last_pile_member() { + use engine::analysis::resource::ResourceAxis; + use engine::game::zones::move_to_zone; + use engine::types::events::GameEvent; + + let base = one_shot_bootstrap_accepted_state(); + + // ── REACH GUARDS: the accepted state really carries the row and a one-member backing set. + // Without these, the subject arm's "absent" could pass on a state that never had a row. + assert_eq!( + base.unbounded_resources.len(), + 1, + "reach-guard: exactly one controller carries ∞ marks on this rig" + ); + let marked = base + .unbounded_resources + .get(&P0) + .expect("the accept marks P0's ∞ axes") + .clone(); + assert!( + marked.contains(&ResourceAxis::TokensCreated), + "reach-guard: the object-growth accept marks TokensCreated, got {marked:?}" + ); + let pile = base + .unbounded_loop_pile + .get(&P0) + .expect("the object-growth accept registers a ∞ pile") + .clone(); + assert_eq!( + pile.len(), + 1, + "reach-guard: this rig's seeded pile is exactly ONE object, so a single departure \ + empties the whole backing set" + ); + let seed_id = *pile.iter().next().unwrap(); + assert!( + base.battlefield.contains(&seed_id), + "reach-guard: the pile member is on the battlefield at accept" + ); + + let rows = |state: &GameState| -> Vec { + derive_views(state, Some(P0)) + .unbounded_resources + .iter() + .map(|r| r.axis) + .collect() + }; + + // ── CONTROL FIRST (matched pair, and the wire-level non-vacuity anchor for the subject + // arm below): same rig, same `move_to_zone` chokepoint — but the object that leaves is NOT + // in the pile, so the backing survives and the row must PERSIST. Deliberately ordered + // BEFORE the subject: an equivalent "row present on the untouched base state" guard would + // panic first under the TRIVIALIZE mutation and MASK this arm, leaving the pair one-sided. + let mut control = base.clone(); + let bystander = *p0_untapped_saprolings(&control) + .iter() + .next() + .expect("the W+1 untapped remainder supplies a non-pile bystander"); + assert_ne!( + bystander, seed_id, + "control precondition: the departing object is NOT a pile member" + ); + let mut events: Vec = Vec::new(); + move_to_zone(&mut control, bystander, Zone::Graveyard, &mut events); + assert!( + !control.battlefield.contains(&bystander), + "the control's departure really happened" + ); + let control_rows = rows(&control); + assert!( + control_rows.contains(&ResourceAxis::TokensCreated), + "THE assertion (control): the registered pile still has a live member, so the ∞ row \ + must persist, got {control_rows:?}" + ); + + // ── SUBJECT: the last (only) backing member leaves through the real production + // chokepoint `zones::move_to_zone`, not by hand-editing the pile. + let mut subject = base.clone(); + let mut events: Vec = Vec::new(); + move_to_zone(&mut subject, seed_id, Zone::Graveyard, &mut events); + assert!( + !subject.battlefield.contains(&seed_id), + "the departure really happened (CR 110.1: it stopped being a permanent)" + ); + let subject_rows = rows(&subject); + assert!( + !subject_rows.contains(&ResourceAxis::TokensCreated), + "THE assertion (subject): with its ENTIRE registered pile off the battlefield the \ + TokensCreated ∞ row must be dropped, got {subject_rows:?}" + ); + + // SCOPE: only the BACKED axis is dropped — the wire is exactly the marked set minus + // `TokensCreated`, so a guard that hid MORE than the unbacked axis fails here. Stated + // honestly: this rig marks few axes, so this row is weak on its own. The load-bearing + // control for the `None` (never-registered ⇒ badge unchanged) branch is + // `loop_shortcut_mana_engine::mana_engine_accept_still_renders_its_infinity_badge`, which + // reds if `object_growth_backing`'s catch-all arm returns `Some(false)` instead of `None`. + let expected_after: BTreeSet = marked + .iter() + .copied() + .filter(|axis| *axis != ResourceAxis::TokensCreated) + .collect(); + assert_eq!( + subject_rows.iter().copied().collect::>(), + expected_after, + "scope: exactly the backed axis leaves the wire; every unbacked axis keeps its ∞" + ); + + // STORE: a DISPLAY revocation only. Nothing here may touch the accepted-collapse stash, + // the mark, or the pile — the boundary and the zone-exit defuse still read all three. + assert!( + subject.pending_unbounded_materialization.contains_key(&P0), + "the accepted-collapse stash must SURVIVE — dropping a row may not cancel growth the \ + table already unanimously accepted (CR 732.2c)" + ); + assert!( + subject.pending_materialization_count.contains_key(&P0), + "…and so must its CR 732.2c accepted-count bound" + ); + assert!( + subject + .unbounded_loop_pile + .get(&P0) + .is_some_and(|p| p.contains(&seed_id)), + "the STORE is not filtered: it still carries the departed member" + ); + assert!( + subject + .unbounded_resources + .get(&P0) + .is_some_and(|axes| axes.contains(&ResourceAxis::TokensCreated)), + "the MARK survives too; only the projection stops rendering it" + ); +} + /// T-NEW-2 (REVISION 2 — the BLOCKER-1 discriminator): a convoke=None UNTAPPED-growth loop must /// NOT seed. Build-fresh Sprout Swarm with Convoke STRIPPED and `mana_cost = base_mana_cost = {1}` /// so Witherbloom's affinity for creatures fully covers base{1}+buyback{3}={4} with {0} mana and From 74dc8e5d629a7caededeb6f0a918be5d8f95f54f Mon Sep 17 00:00:00 2001 From: lgray Date: Wed, 5 Aug 2026 03:25:38 -0500 Subject: [PATCH 6/9] =?UTF-8?q?test(engine):=20pin=20the=20=E2=88=9E=20pil?= =?UTF-8?q?e's=20store/wire=20split=20against=20a=20stale=20member?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scheduled_collapse_still_renders_the_unbounded_badge` built its `expected_pile` oracle straight out of `unbounded_loop_pile`, unfiltered. The projection emits only members still on the battlefield, so a legitimately stale stored id would have made that equality indict the projection for being right. The oracle now applies the same CR 110.1 membership filter the projection does. Stated plainly: that filter is a no-op on this fixture — nothing is stale there — so it is latent correctness, not a discriminating test. Which is exactly why it does not ship alone. `stale_pile_member_is_omitted_from_the_wire_but_kept_in_the_store` is the case that makes the distinction bite. It drives the real R6a buyback+convoke recast to an accepted 502-member pile, moves one member to the graveyard through the production chokepoint `zones::move_to_zone`, and then asserts, for every viewer, that the wire omits it, that the wire is exactly store ∩ battlefield (exact membership, so dropping EXTRA members also fails), and that the wire lost exactly one member — while the STORE still carries it. That last row is the point of the test, not a bonus assertion. It is the discriminator against "fixing" this by pruning `unbounded_loop_pile` at zone exit, which greens all three wire rows and reds only the store row. The store has to survive the departure: the boundary collapse and the zone-exit defuse both read it. Measured over the 164-test loop/∞ blast radius: delete the pile loop's battlefield filter → this test reds, 1 failed / 163 passed (the oracle-filtered sibling stays GREEN — no member is stale on its fixture, which is the evidence that filter is latent rather than load-bearing today) prune the STORE instead of the wire → only the store rows red; wire rows stay green Before this test the pile loop's liveness filter had no runnable guard at all. Assisted-by: ClaudeCode:claude-opus-5 --- .../engine/tests/integration/loop_shortcut.rs | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index 14ec883857..c2aaa562b6 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -7276,10 +7276,18 @@ fn scheduled_collapse_still_renders_the_unbounded_badge() { // above. These rows pin the SET and the FULL membership, both compared against the store so the // expectation cannot drift away from what the accept actually wrote. let expected_axes: std::collections::BTreeSet = marked.iter().copied().collect(); + // CR 110.1: the projection emits only pile members still ON THE BATTLEFIELD, so the oracle + // must model that filter. A legitimately stale STORED id (the store is deliberately + // unfiltered) is the projection being RIGHT, not a regression — without this filter the + // equality below would indict the correct behaviour. On this fixture every stored member is + // still on the battlefield here, so the filter is a no-op TODAY: it is latent correctness, + // and `stale_pile_member_is_omitted_from_the_wire_but_kept_in_the_store` below is the case + // that actually makes the stale/live distinction bite. let expected_pile: std::collections::BTreeSet = state .unbounded_loop_pile .values() .flat_map(|ids| ids.iter().copied()) + .filter(|id| state.battlefield.contains(id)) .collect(); assert!( expected_axes.len() >= 2 && !expected_pile.is_empty(), @@ -7320,6 +7328,121 @@ fn scheduled_collapse_still_renders_the_unbounded_badge() { ); } +/// MED-2 (CR 732.2a + CR 110.1): a pile member that has LEFT the battlefield is omitted from +/// the WIRE while the STORE keeps it. Sibling of the oracle filter added to +/// `scheduled_collapse_still_renders_the_unbounded_badge` above, which is a no-op on that +/// fixture (nothing is stale there) — this test is what makes the distinction bite. +/// +/// The store/wire split is the whole contract: `unbounded_loop_pile` must stay unfiltered +/// because the boundary collapse and `zones::apply_zone_exit_cleanup`'s defuse both read it, +/// so the liveness filter has to live in the projection. +/// +/// MUTATIONS (RUN, measured over the 164-test loop/∞ blast radius): +/// - Delete `if state.battlefield.contains(id)` from `derive_views`' pile loop ⇒ row (1) reds +/// here ("the wire omits the stale member (viewer None)") and NOTHING else moves — 1 failed +/// / 163 passed. That zero collateral is the finding, not a footnote: before this test, the +/// pile loop's liveness filter had no runnable guard at all. In particular the oracle filter +/// added to `scheduled_collapse_still_renders_the_unbounded_badge` stays GREEN under this +/// mutation, because no member is stale on that fixture — the filter there is latent +/// correctness, and THIS test is what makes the distinction bite. +/// - "Fix" it by pruning the STORE instead of the wire (drop the departing id from +/// `unbounded_loop_pile` in `zones::apply_zone_exit_cleanup`) ⇒ rows (1), (2) and (4) go +/// GREEN and only row (3) reds. Row (3) is the discriminator against that wrong fix. +#[test] +fn stale_pile_member_is_omitted_from_the_wire_but_kept_in_the_store() { + use engine::game::zones::move_to_zone; + use engine::types::zones::Zone; + use std::collections::BTreeSet; + + let mut state = r6a_offer_state(); + assert!( + matches!(state.waiting_for, WaitingFor::LoopShortcut { proposer, .. } if proposer == P0), + "reach-guard: at the offer, got {:?}", + state.waiting_for + ); + r6a_declare_and_accept_all(&mut state, P0, 200); + + let stored: BTreeSet = state + .unbounded_loop_pile + .get(&P0) + .expect("the object-growth accept registers a ∞ pile") + .clone(); + assert!( + stored.len() >= 2, + "reach-guard: this rig's pile has >= 2 members, so removing ONE leaves a non-empty \ + wire — the case is about a STALE member, not about the whole backing set dying \ + (that is `object_growth_infinity_row_dies_with_its_last_pile_member`), got {}", + stored.len() + ); + assert!( + stored.iter().all(|id| state.battlefield.contains(id)), + "reach-guard: BEFORE the departure every stored member is on the battlefield, so the \ + wire/store divergence below is caused by the departure and nothing else" + ); + + let departed = *stored.iter().next().unwrap(); + let mut events: Vec = Vec::new(); + move_to_zone(&mut state, departed, Zone::Graveyard, &mut events); + assert!( + !state.battlefield.contains(&departed), + "the departure really happened (CR 110.1: it stopped being a permanent)" + ); + + // (3) THE STORE IS NOT FILTERED. This is the discriminator against a "fix" that prunes + // `unbounded_loop_pile` itself: that mutation satisfies (1), (2) and (4) and reds only + // this row. The store must survive because the boundary collapse and the zone-exit + // defuse both read it. + assert!( + state + .unbounded_loop_pile + .get(&P0) + .is_some_and(|pile| pile.contains(&departed)), + "(3) the STORE must still carry the departed member — only the wire filters" + ); + + let expected: BTreeSet = stored + .iter() + .copied() + .filter(|id| state.battlefield.contains(id)) + .collect(); + assert_eq!( + expected.len(), + stored.len() - 1, + "control: exactly ONE stored member became stale" + ); + + for viewer in [None, Some(P0), Some(P1), Some(P2), Some(PlayerId(3))] { + let views = engine::game::derived_views::derive_views(&state, viewer); + let wire: BTreeSet = views.unbounded_pile.iter().copied().collect(); + assert!( + !wire.contains(&departed), + "(1) the wire omits the stale member (viewer {viewer:?})" + ); + assert_eq!( + wire, expected, + "(2) EXACT membership: the wire is stored ∩ battlefield, so a projection that \ + dropped EXTRA members fails here too (viewer {viewer:?})" + ); + assert_eq!( + views.unbounded_pile.len(), + stored.len() - 1, + "(4) exactly one member is lost between store and wire (viewer {viewer:?})" + ); + } + + // The ROW survives: the rest of the pile still backs the axis. This is the `Some(true)` + // arm of `derived_views::object_growth_backing` — a partial departure is not a revocation. + let axes: Vec = engine::game::derived_views::derive_views(&state, None) + .unbounded_resources + .iter() + .map(|row| row.axis) + .collect(); + assert!( + axes.contains(&ResourceAxis::TokensCreated), + "one stale member leaves live backing behind, so the ∞ row persists, got {axes:?}" + ); +} + /// R6a-3 (FAIL-CLOSED), under option (B). ONE rig, TWO arms that fail on DIFFERENT wrong /// implementations — the pair is what pins "the ∞ rows do not depend on the collapse schedule at /// all", which is strictly stronger than either arm alone. From cc2d5f5c2e16d86b87bd3022e6ee6a481bb91e4f Mon Sep 17 00:00:00 2001 From: lgray Date: Wed, 5 Aug 2026 03:37:03 -0500 Subject: [PATCH 7/9] =?UTF-8?q?test(engine):=20give=20the=20Counter=20arm?= =?UTF-8?q?=20of=20the=20=E2=88=9E=20backing=20check=20a=20runtime=20witne?= =?UTF-8?q?ss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `object_growth_backing` matches exhaustively on `ResourceAxis`, and until now exactly one of its two backed arms was ever executed. `TokensCreated` carried every behavioural test; `Counter(..)` was held up only by the compiler's exhaustiveness check, which proves the arm exists and nothing about what it returns. An arm no test runs is an arm no test can catch a regression in — and this one is a display revocation, so a regression there is silent by construction. The helper is a pure predicate over live state, so the arm does not need a certified counter-growth loop to witness it: a registered target on the battlefield and the same target moved off it are the only two inputs that distinguish its answers. That is the building-block level the repo's testing rule asks for, and it is why this sits in the lib test module rather than in an integration fixture — no such fixture exists, which is precisely how the arm came to be untested. Matched pair on ONE assertion, the Counter row's presence on the wire. The control runs first and asserts the row PRESENT, so it doubles as the non-vacuity anchor: a lone "row absent" assertion would pass just as happily against a projection that never emits the row at all. Measured both directions: collapsing the arm to `None` reds the subject ("must be dropped, got [Counter(Plus1Plus1, Creature)]"), collapsing it to `Some(false)` reds the control ("must persist, got []"). Neither arm can pass for the wrong reason. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/derived_views.rs | 72 +++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/crates/engine/src/game/derived_views.rs b/crates/engine/src/game/derived_views.rs index caf1851b23..5f2c12b18b 100644 --- a/crates/engine/src/game/derived_views.rs +++ b/crates/engine/src/game/derived_views.rs @@ -1698,6 +1698,78 @@ mod tests { ); } + /// The `Counter(..)` arm of [`object_growth_backing`] shipped with no runtime witness: no + /// fixture exists in which a counter-growth loop's registered targets ALL leave the + /// battlefield, so that arm was exercised only by the compiler's exhaustiveness check while + /// its sibling `TokensCreated` arm carried every behavioural test. An arm nothing runs is an + /// arm nothing can catch a regression in. The helper is a pure predicate over live state, so + /// the arm is witnessed directly here rather than by standing up a whole certified loop — + /// the building-block level the repo's testing rule asks for. + /// + /// Matched pair on ONE assertion (is the Counter row on the wire?): the control leaves a + /// registered target on the battlefield, the subject moves the only one to the graveyard. + /// Collapsing the arm to `None` reds the SUBJECT; collapsing it to `Some(false)` reds the + /// CONTROL — so neither arm can pass for the wrong reason. + #[test] + fn counter_axis_infinity_row_dies_with_its_last_registered_target() { + use crate::analysis::resource::{CounterClass, ObjectClass, ResourceAxis}; + use crate::game::zones::move_to_zone; + use crate::types::counter::CounterType; + use crate::types::events::GameEvent; + + // CR 122.1a: a +1/+1 counter on a creature — a concrete instance of the object-agnostic + // axis a counter-growth certificate marks. + let axis = ResourceAxis::Counter(CounterClass::Plus1Plus1, ObjectClass::Creature); + + let build = || { + let mut state = GameState::new_two_player(42); + let target = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Counter target".to_string(), + Zone::Battlefield, + ); + state.mark_unbounded_loop(PlayerId(0), &[axis]); + state.register_unbounded_counter_targets( + PlayerId(0), + vec![(target, CounterType::Generic("charge".to_string()))], + ); + (state, target) + }; + + let rows = |state: &GameState| -> Vec { + derive_views(state, Some(PlayerId(0))) + .unbounded_resources + .iter() + .map(|r| r.axis) + .collect() + }; + + // CONTROL runs FIRST so it doubles as the non-vacuity anchor: it proves this wire can + // carry the row at all, which a "row absent" assertion alone would never establish. + let (control, _kept) = build(); + assert!( + rows(&control).contains(&axis), + "THE assertion (control): a registered target still on the battlefield keeps the ∞ row, got {:?}", + rows(&control) + ); + + // SUBJECT: the only registered target leaves ⇒ the backing set is empty ⇒ row dropped. + let (mut subject, target) = build(); + let mut events: Vec = Vec::new(); + move_to_zone(&mut subject, target, Zone::Graveyard, &mut events); + assert!( + !subject.battlefield.contains(&target), + "precondition: the target really left the battlefield" + ); + assert!( + !rows(&subject).contains(&axis), + "THE assertion (subject): with every registered target gone the ∞ row must be dropped, got {:?}", + rows(&subject) + ); + } + #[test] fn blocker_assignment_pairs_are_sorted_and_exclude_stale_objects() { let mut state = GameState::new_two_player(42); From ca24248c00d4889768791f6a2bf4d5264150b733 Mon Sep 17 00:00:00 2001 From: lgray Date: Wed, 5 Aug 2026 04:07:14 -0500 Subject: [PATCH 8/9] docs(engine): correct the claim this change's own guard falsified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment-only, and the point is which comment. The projection's design note said the three `∞` loops "read only their own stores". Adding the row guard falsified that sentence in the same commit: `object_growth_backing` deliberately cross-reads the pile store, the counter-target store and the live battlefield, because whether a ROW is still live is a question about those backing sets and not about its own. This PR exists because a comment asserted an inert defuse that nothing checked; leaving a comment that this PR's own code disproves would be the same defect, shipped by its fix. The helper's "the row, the pile and the counter pill can never disagree" line is also narrowed, because it claimed more than it earns. What the shared `state.battlefield.contains` test buys is FRESHNESS — all three read the same board in the same frame, so none is staler than another. It does not buy GRANULARITY: the backing stores are keyed by controller, not by axis, so a controller carrying two counter axes keeps axis A's row alive while A's per-object pill is already gone, because `any(...)` still sees axis B's live targets. That is now stated rather than implied away. It errs safely — over-keeping a badge, never over-dropping one — and the note records why the obvious tightening is not free: a counter loop's certified axis is object-agnostic, so filtering on `collapsed_counter_axis` equality could drop the row outright. Anyone attempting it needs a fixture proving the two axis spaces agree first. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/derived_views.rs | 28 +++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/game/derived_views.rs b/crates/engine/src/game/derived_views.rs index 5f2c12b18b..470794a422 100644 --- a/crates/engine/src/game/derived_views.rs +++ b/crates/engine/src/game/derived_views.rs @@ -849,10 +849,15 @@ pub fn derive_views(state: &GameState, viewer: Option) -> DerivedViews // materialized `Mana(_)` axis that `mana_payment::refill_infinite_mana` keeps topping back up, // i.e. it hid a badge beside a pool the player can visibly keep spending. // - // The three loops below therefore read only their own stores; none consults - // `GameState::scheduled_collapse_axes` (whose sole production caller is - // `clear_collapsed_materializations`). The stores are not filtered either: `unbounded_resources` - // keeps the mark until the boundary applies the growth. (`unbounded_loop_enablers` is held in + // The three loops below therefore read only the `∞` stores and the LIVE battlefield; none + // consults `GameState::scheduled_collapse_axes` (whose sole production caller is + // `clear_collapsed_materializations`). This sentence used to read "only their own stores", + // which the row guard below falsified the moment it was added: `object_growth_backing` + // deliberately cross-reads the pile and counter-target stores, because whether a ROW is still + // live is a question about those backing sets, not about its own. Corrected here rather than + // left standing — a stale claim introduced by the commit that exists to fix stale claims is + // the one defect this change cannot afford. The stores are not filtered either: + // `unbounded_resources` keeps the mark until the boundary applies the growth. (`unbounded_loop_enablers` is held in // lockstep with it as an ENGINE-STATE invariant required by no CR — but see the inertness note // above: for the object-growth class that map is EMPTY, so the lockstep is vacuously satisfied // here and is load-bearing only for the Interactive Path-C class that populates it.) @@ -1105,8 +1110,19 @@ fn attribution_player(axis: ResourceAxis, controller: PlayerId) -> PlayerId { /// /// This is the SINGLE authority for "is this object-growth display set still on the board". /// The pile and counter-target loops in `derive_views` apply the same -/// `state.battlefield.contains` test at MEMBER level; this is its SET-level closure, so the -/// row, the pile and the counter pill can never disagree about the same board. +/// `state.battlefield.contains` test at MEMBER level; this is its SET-level closure, so all +/// three read the same board in the same frame and none can be staler than another. +/// +/// GRANULARITY, stated because the freshness guarantee above does not imply it: the backing +/// stores are keyed by CONTROLLER, not by axis (`unbounded_counter_targets` is +/// `BTreeMap>`). So if one controller carries two +/// counter axes and only axis A's targets leave, `any(...)` is still true and A's ROW survives +/// while A's per-object pill is already gone. That is deliberate for now, and it errs in the +/// safe direction — it over-KEEPS a badge, never over-drops one. Making it axis-precise via +/// `collapsed_counter_axis` is NOT a free tightening: a counter loop's certified axis is +/// object-agnostic (see `materialize_object_growth_shortcut`), so the marked-axis space may not +/// coincide with that mapping and a naive equality filter would drop the row entirely. It needs +/// a counter-growth fixture measuring the two spaces agree before anyone tries it. /// /// Read-only: recomputed from live state on every `derive_views` call, nothing is stored, /// so nothing can go stale. Deliberately not a `clear_unbounded_loop` from the zone-exit From 21a256a2477896d965bf8181458320f08d463692 Mon Sep 17 00:00:00 2001 From: lgray Date: Wed, 5 Aug 2026 06:35:40 -0500 Subject: [PATCH 9/9] =?UTF-8?q?fix(engine):=20refuse=20counter-axis=20?= =?UTF-8?q?=E2=88=9E=20revocation=20a=20controller-keyed=20store=20can't?= =?UTF-8?q?=20justify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `object_growth_backing` answered every `ResourceAxis::Counter(..)` from `unbounded_counter_targets[controller]`. That store is keyed by controller alone, and its contents are derived axis-blind: `register_unbounded_counter_targets` is fed by `current_period_counter_targets` -> `grown_generic_counter_targets`, which takes no axis argument and returns one undifferentiated `Generic`-only set for the whole proposal. Marked axes are unioned per controller (`mark_unbounded_loop`'s `entry.extend`), so a controller can hold both `Counter(Plus1Plus1, Creature)` and the display channel's object-agnostic `Counter(Other, Other)` while only the latter's targets are ever registered. With those targets off the battlefield the guard dropped EVERY counter row, including the one whose backing it had never consulted -- an over-drop, the opposite of the "conservative, over-keeps only" claim the previous revision shipped with. Measured: the subject assertion caught `[]`, not a stale survivor. Re-keying by `(controller, ResourceAxis)` would not fix it -- the key would assert a scope the derivation never had. The rule applied instead: a controller-keyed backing store can answer an axis-scoped question if and only if the axis is a UNIT variant. `TokensCreated` is a unit variant, so `unbounded_loop_pile[controller]` IS that axis' backing (a bijection) and the token revocation -- the stale-pile case this PR was opened for -- stays sound. `Counter(CounterClass, ObjectClass)` is a data variant, strictly coarser than the store's key, so it returns `None`: refuse rather than guess. The match stays exhaustive and wildcard-free, and the doc now states that rule as the criterion for placing a future axis variant. Replaces `counter_axis_infinity_row_dies_with_its_last_registered_target`, which asserted the removed behaviour and was itself misrepresentative: it declared `Counter(Plus1Plus1, Creature)` while registering a `Generic("charge")` target, and because the arm read neither the axis parameters nor the target's `CounterType`, every coordinate in its name was free -- it would have passed with any axis and any counter type. CR 701.34a / CR 732.2a. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/derived_views.rs | 116 +++++++++++++++--------- 1 file changed, 71 insertions(+), 45 deletions(-) diff --git a/crates/engine/src/game/derived_views.rs b/crates/engine/src/game/derived_views.rs index 470794a422..e296dcad9e 100644 --- a/crates/engine/src/game/derived_views.rs +++ b/crates/engine/src/game/derived_views.rs @@ -1113,16 +1113,32 @@ fn attribution_player(axis: ResourceAxis, controller: PlayerId) -> PlayerId { /// `state.battlefield.contains` test at MEMBER level; this is its SET-level closure, so all /// three read the same board in the same frame and none can be staler than another. /// -/// GRANULARITY, stated because the freshness guarantee above does not imply it: the backing -/// stores are keyed by CONTROLLER, not by axis (`unbounded_counter_targets` is -/// `BTreeMap>`). So if one controller carries two -/// counter axes and only axis A's targets leave, `any(...)` is still true and A's ROW survives -/// while A's per-object pill is already gone. That is deliberate for now, and it errs in the -/// safe direction — it over-KEEPS a badge, never over-drops one. Making it axis-precise via -/// `collapsed_counter_axis` is NOT a free tightening: a counter loop's certified axis is -/// object-agnostic (see `materialize_object_growth_shortcut`), so the marked-axis space may not -/// coincide with that mapping and a naive equality filter would drop the row entirely. It needs -/// a counter-growth fixture measuring the two spaces agree before anyone tries it. +/// GRANULARITY — the rule that decides which axes may consult a backing store at all: +/// +/// > A CONTROLLER-keyed backing store can answer an AXIS-scoped question if and only if the +/// > axis is a UNIT variant. +/// +/// `TokensCreated` is a unit variant, so a controller can hold at most one of it and +/// `unbounded_loop_pile[controller]` IS that axis' backing — a bijection, no granularity is +/// assumed that the store does not have. `Counter(CounterClass, ObjectClass)` is a DATA +/// variant: `mark_unbounded_loop` unions arbitrarily many per controller (`entry.extend`), so a +/// controller-keyed store is strictly coarser than the axis, and it returns `None` here. +/// +/// An earlier revision of this function did read `unbounded_counter_targets` for `Counter(..)`, +/// and its doc claimed the error direction was safe — "over-KEEPS a badge, never over-drops +/// one". That was FALSE, and measured so: one accepted proposal can carry both +/// `Counter(Plus1Plus1, Creature)` (`analysis::corpus`'s `ResourceFamily::Counters`) and the +/// display channel's object-agnostic `Counter(Other, Other)`, while only the latter's targets +/// are ever registered — so when those targets left the battlefield the guard dropped EVERY +/// counter row, including the one whose backing it had never consulted. +/// +/// Re-keying the store by `(controller, ResourceAxis)` would not fix it. The targets are +/// axis-blind at the DERIVATION, not just at the key: `register_unbounded_counter_targets` is +/// fed by `game::engine::current_period_counter_targets` → +/// `analysis::resource::grown_generic_counter_targets`, which takes no axis argument and +/// returns one undifferentiated `Generic`-only set for the whole proposal. A per-axis key would +/// assert a scope nothing derives. Revoking a counter row needs an axis-scoped authority to +/// exist first; until one does, this refuses rather than guesses. /// /// Read-only: recomputed from live state on every `derive_views` call, nothing is stored, /// so nothing can go stale. Deliberately not a `clear_unbounded_loop` from the zone-exit @@ -1143,16 +1159,16 @@ fn object_growth_backing( .unbounded_loop_pile .get(&controller) .map(|pile| pile.iter().any(|id| state.battlefield.contains(id))), - // CR 701.34a: the per-object Generic counter targets the loop proliferates each - // cycle are the counter axis' registered backing. - ResourceAxis::Counter(..) => state - .unbounded_counter_targets - .get(&controller) - .map(|targets| targets.iter().any(|(id, _)| state.battlefield.contains(id))), // No registered board backing exists for these axes — no live authority to consult, // badge unchanged. Exhaustive on purpose: a future ResourceAxis variant must decide - // which side it lands on rather than silently defaulting to "unbacked". - ResourceAxis::Mana(_) + // which side it lands on rather than silently defaulting to "unbacked"; the + // unit-variant rule in this function's doc is the criterion for choosing. + // + // `Counter(..)` is here rather than reading `unbounded_counter_targets` because that + // store cannot answer a per-axis question — see the GRANULARITY note above. Witnessed + // by `counter_rows_are_not_revoked_by_a_controller_keyed_backing_set`. + ResourceAxis::Counter(..) + | ResourceAxis::Mana(_) | ResourceAxis::Life(_) | ResourceAxis::DamageDealt(_) | ResourceAxis::LibraryDelta(_) @@ -1714,28 +1730,36 @@ mod tests { ); } - /// The `Counter(..)` arm of [`object_growth_backing`] shipped with no runtime witness: no - /// fixture exists in which a counter-growth loop's registered targets ALL leave the - /// battlefield, so that arm was exercised only by the compiler's exhaustiveness check while - /// its sibling `TokensCreated` arm carried every behavioural test. An arm nothing runs is an - /// arm nothing can catch a regression in. The helper is a pure predicate over live state, so - /// the arm is witnessed directly here rather than by standing up a whole certified loop — - /// the building-block level the repo's testing rule asks for. + /// A controller-keyed backing store can answer an axis-scoped question only when the axis is + /// a UNIT variant. `TokensCreated` is one — at most one per controller, so + /// `unbounded_loop_pile[controller]` IS that axis' backing. `Counter(CounterClass, + /// ObjectClass)` is not: `mark_unbounded_loop` unions arbitrary axes for one controller, and + /// the backing derivation (`current_period_counter_targets` → `grown_generic_counter_targets`) + /// accepts NO axis — it diffs every shared object's growable `Generic` counters and returns + /// ONE undifferentiated set for the whole proposal. + /// + /// So the controller-keyed `Some(false)` this PR first shipped revoked EVERY counter row at + /// once, including axes whose backing was never in that set: a certified proposal can carry + /// both `Counter(Plus1Plus1, Creature)` (`analysis::corpus`'s `ResourceFamily::Counters`) and + /// the display channel's object-agnostic `Counter(Other, Other)`, while only the latter's + /// Generic targets are ever registered. That is an over-DROP — the opposite of the + /// "conservative, over-keeps only" claim the first revision shipped with. /// - /// Matched pair on ONE assertion (is the Counter row on the wire?): the control leaves a - /// registered target on the battlefield, the subject moves the only one to the graveyard. - /// Collapsing the arm to `None` reds the SUBJECT; collapsing it to `Some(false)` reds the - /// CONTROL — so neither arm can pass for the wrong reason. + /// Two-sided on ONE assertion (are both rows on the wire?): restoring the controller-keyed + /// `Some(false)` arm reds the SUBJECT — both rows vanish, including the axis whose backing was + /// never consulted. The CONTROL runs FIRST as the non-vacuity anchor: it proves this wire can + /// carry two counter rows at all, which a "rows survived" assertion alone cannot establish. #[test] - fn counter_axis_infinity_row_dies_with_its_last_registered_target() { + fn counter_rows_are_not_revoked_by_a_controller_keyed_backing_set() { use crate::analysis::resource::{CounterClass, ObjectClass, ResourceAxis}; use crate::game::zones::move_to_zone; use crate::types::counter::CounterType; use crate::types::events::GameEvent; - // CR 122.1a: a +1/+1 counter on a creature — a concrete instance of the object-agnostic - // axis a counter-growth certificate marks. - let axis = ResourceAxis::Counter(CounterClass::Plus1Plus1, ObjectClass::Creature); + // The two axes one accepted counter-growth proposal can carry at once. Only the + // object-agnostic one is the display channel's, and only ITS targets get registered. + let plus1_axis = ResourceAxis::Counter(CounterClass::Plus1Plus1, ObjectClass::Creature); + let generic_axis = ResourceAxis::Counter(CounterClass::Other, ObjectClass::Other); let build = || { let mut state = GameState::new_two_player(42); @@ -1743,10 +1767,12 @@ mod tests { &mut state, CardId(1), PlayerId(0), - "Counter target".to_string(), + "Pentad Prism".to_string(), Zone::Battlefield, ); - state.mark_unbounded_loop(PlayerId(0), &[axis]); + state.mark_unbounded_loop(PlayerId(0), &[plus1_axis, generic_axis]); + // CR 701.34a: the registered backing is the GENERIC channel's, derived axis-blind. + // Nothing here backs `plus1_axis` — that is the whole point. state.register_unbounded_counter_targets( PlayerId(0), vec![(target, CounterType::Generic("charge".to_string()))], @@ -1762,27 +1788,27 @@ mod tests { .collect() }; - // CONTROL runs FIRST so it doubles as the non-vacuity anchor: it proves this wire can - // carry the row at all, which a "row absent" assertion alone would never establish. + // CONTROL first — the reach anchor: both marked axes reach the wire. let (control, _kept) = build(); + let control_rows = rows(&control); assert!( - rows(&control).contains(&axis), - "THE assertion (control): a registered target still on the battlefield keeps the ∞ row, got {:?}", - rows(&control) + control_rows.contains(&plus1_axis) && control_rows.contains(&generic_axis), + "THE assertion (control): both marked counter axes reach the wire, got {control_rows:?}" ); - // SUBJECT: the only registered target leaves ⇒ the backing set is empty ⇒ row dropped. + // SUBJECT: the only registered (Generic) target leaves the battlefield. let (mut subject, target) = build(); let mut events: Vec = Vec::new(); move_to_zone(&mut subject, target, Zone::Graveyard, &mut events); assert!( !subject.battlefield.contains(&target), - "precondition: the target really left the battlefield" + "precondition: the registered target really left the battlefield" ); + let subject_rows = rows(&subject); assert!( - !rows(&subject).contains(&axis), - "THE assertion (subject): with every registered target gone the ∞ row must be dropped, got {:?}", - rows(&subject) + subject_rows.contains(&plus1_axis) && subject_rows.contains(&generic_axis), + "THE assertion (subject): a controller-keyed backing set must not revoke ANY counter \ + row — least of all `plus1_axis`, whose backing was never registered, got {subject_rows:?}" ); }