diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 3ff8874280..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[]; /** 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..0e043bc155 --- /dev/null +++ b/client/src/test/fixtures/unbounded-counter-wire.json @@ -0,0 +1,18 @@ +{ + "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..f57ef0a47f --- /dev/null +++ b/client/src/test/fixtures/unbounded-token-wire.json @@ -0,0 +1,14 @@ +{ + "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..33d47fde39 --- /dev/null +++ b/client/src/viewmodel/__tests__/unboundedWireSeam.test.ts @@ -0,0 +1,114 @@ +/** + * ∞-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 + * 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([]); + }); +}); diff --git a/crates/engine/src/game/derived_views.rs b/crates/engine/src/game/derived_views.rs index 97e4c720b2..e296dcad9e 100644 --- a/crates/engine/src/game/derived_views.rs +++ b/crates/engine/src/game/derived_views.rs @@ -793,53 +793,79 @@ 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. + // WHY THE THREE ∞ CHANNELS BELOW ARE UNCONDITIONAL — the accept→boundary window. // - // 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. + // 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. // - // FAIL-CLOSED: only axes a registered materialization really collapses are hidden, so an - // unregistered ∞ axis (a mana engine registers none) still renders. + // 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 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. // - // 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 - // `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. + // WHY `∞` IS RIGHT HERE IS AN ENGINE-STATE ARGUMENT, NOT A RULES ONE. Throughout the window the + // 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. // - // `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 - .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(); - let collapse_scheduled = |controller: PlayerId, axis: &ResourceAxis| -> bool { - scheduled_collapse - .get(&controller) - .is_some_and(|axes| axes.contains(axis)) - }; + // 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. + // 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 + // 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. + // + // 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.) + // + // 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 @@ -848,7 +874,12 @@ 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 { - if collapse_scheduled(controller, &axis) { + // 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 { @@ -863,14 +894,8 @@ 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; - } + // 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) { views.unbounded_pile.push(*id); @@ -885,22 +910,12 @@ 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 { + // 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) { continue; } - if collapse_scheduled( - controller, - &crate::types::game_state::collapsed_counter_axis(state, *id, ct), - ) { - continue; - } views .unbounded_counters .entry(*id) @@ -1078,6 +1093,99 @@ 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 all +/// three read the same board in the same frame and none can be staler than another. +/// +/// 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 +/// 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))), + // 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"; 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(_) + | 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: @@ -1622,6 +1730,88 @@ mod tests { ); } + /// 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. + /// + /// 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_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; + + // 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); + let target = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Pentad Prism".to_string(), + Zone::Battlefield, + ); + 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()))], + ); + (state, target) + }; + + let rows = |state: &GameState| -> Vec { + derive_views(state, Some(PlayerId(0))) + .unbounded_resources + .iter() + .map(|r| r.axis) + .collect() + }; + + // CONTROL first — the reach anchor: both marked axes reach the wire. + let (control, _kept) = build(); + let control_rows = rows(&control); + assert!( + 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 (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 registered target really left the battlefield" + ); + let subject_rows = rows(&subject); + assert!( + 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:?}" + ); + } + #[test] fn blocker_assignment_pairs_are_sorted_and_exclude_stale_objects() { let mut state = GameState::new_two_player(42); 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 b39878c02d..a935bf2cd0 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 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`, @@ -3552,19 +3554,16 @@ 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 `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 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 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 @@ -19740,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). @@ -19819,26 +19819,33 @@ 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. + /// 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(_)` 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. + /// 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. /// - /// Hiding 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. + /// Removing at the boundary rather than filtering the store meanwhile is load-bearing: + /// 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) 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( @@ -19899,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. @@ -19909,8 +19916,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 - // "hidden 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`. @@ -19971,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); @@ -19981,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 @@ -19996,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 } } } @@ -23434,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), @@ -23447,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)); @@ -23465,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 ee2cca96f8..51f9654e10 100644 --- a/crates/engine/tests/integration/combo_infinite_pile.rs +++ b/crates/engine/tests/integration/combo_infinite_pile.rs @@ -220,38 +220,76 @@ 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. + // 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. // - // 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(); + + // 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. + // + // 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", + ] + .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)" ); + // 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(); + assert_eq!( + serde_json::Value::Object(golden), + committed, + "the client's wire golden drifted from engine output — re-run with UPDATE_WIRE_GOLDEN=1" + ); + // (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 +299,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)) + // 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() .copied() @@ -625,22 +654,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)) + // 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() .copied() @@ -1448,22 +1467,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)) + // 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() .copied() @@ -1539,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 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..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 @@ -434,44 +434,74 @@ 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) 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: 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); + + // 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. + // + // 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", + ] + .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" + "the ∞ charge pill stays projected while the collapse is merely SCHEDULED" + ); + + // 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(); + assert_eq!( + serde_json::Value::Object(golden), + committed, + "the client's wire golden drifted from engine output — re-run with UPDATE_WIRE_GOLDEN=1" ); // (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..c2aaa562b6 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. @@ -7141,30 +7142,37 @@ 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. 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 +/// (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 +/// 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. +/// 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. #[test] -fn scheduled_collapse_renders_no_unbounded_badge() { +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. @@ -7174,6 +7182,11 @@ fn scheduled_collapse_renders_no_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 @@ -7200,11 +7213,16 @@ fn scheduled_collapse_renders_no_unbounded_badge() { 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 + // 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 axis the badge lies about is a 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 @@ -7220,25 +7238,24 @@ 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. 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 - // 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(), + "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), + "...and both ∞ rows beside it (viewer {viewer:?}), got {axes:?}" ); } @@ -7246,34 +7263,63 @@ 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 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(); + // 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(), + "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(), - "CR 732.2c: the viewer-FILTERED broadcast path hides 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:?})" ); } - // (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), - "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!( @@ -7282,19 +7328,148 @@ 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*. +/// 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. /// -/// 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. +/// 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. /// -/// 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. +/// 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. +/// +/// 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-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. ⇒ 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(); @@ -7305,8 +7480,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) @@ -7316,14 +7490,24 @@ 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. + assert_eq!( + state.pending_unbounded_materialization.len(), + 1, + "reach-guard for arm 1: the registration is present, so a stash-keyed filter would fire" + ); + + // 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) - .unbounded_resources - .is_empty(), - "control: with the stash present the scheduled rows are hidden" + 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; diff --git a/crates/engine/tests/integration/loop_shortcut_mana_engine.rs b/crates/engine/tests/integration/loop_shortcut_mana_engine.rs index 52c6b62253..c5be7290c9 100644 --- a/crates/engine/tests/integration/loop_shortcut_mana_engine.rs +++ b/crates/engine/tests/integration/loop_shortcut_mana_engine.rs @@ -806,22 +806,31 @@ 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 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 (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` +/// 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 +1009,26 @@ 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 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. +/// +/// 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. /// -/// 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: +/// 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 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. 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. /// /// 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 +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 (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-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; @@ -1123,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 @@ -1143,23 +1158,23 @@ 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. + // (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) 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) 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 stays hidden (viewer \ - {viewer:?}), got {axes:?}" + axes.contains(&ResourceAxis::Life(P0)), + "the deferred Life axis of the same scheduled drive still projects its ∞ \ + row while the collapse is merely scheduled (viewer {viewer:?}), got {axes:?}" ); } - // (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 @@ -1167,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)" ); }